fix(auth): 비자동승인 도메인 Google OAuth PENDING 안내 UX 개선 #16
@ -45,9 +45,14 @@ router.post('/oauth/google', async (req, res) => {
|
|||||||
const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || ''
|
const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || ''
|
||||||
const userAgent = req.headers['user-agent'] || ''
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
|
|
||||||
const userInfo = await googleLogin(credential, ipAddr, userAgent, res)
|
const result = await googleLogin(credential, ipAddr, userAgent, res)
|
||||||
|
|
||||||
res.json({ success: true, user: userInfo })
|
if (result.type === 'pending') {
|
||||||
|
res.json({ success: true, pending: true, message: result.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, user: result.user })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
res.status(err.status).json({ error: err.message })
|
res.status(err.status).json({ error: err.message })
|
||||||
|
|||||||
@ -16,12 +16,18 @@ interface GoogleProfile {
|
|||||||
hd?: string // hosted domain (Google Workspace)
|
hd?: string // hosted domain (Google Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoogleLoginResult {
|
||||||
|
type: 'success' | 'pending'
|
||||||
|
user?: Awaited<ReturnType<typeof getUserInfo>>
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function googleLogin(
|
export async function googleLogin(
|
||||||
credential: string,
|
credential: string,
|
||||||
ipAddr: string,
|
ipAddr: string,
|
||||||
userAgent: string,
|
userAgent: string,
|
||||||
res: Response
|
res: Response
|
||||||
) {
|
): Promise<GoogleLoginResult> {
|
||||||
const profile = await verifyGoogleToken(credential)
|
const profile = await verifyGoogleToken(credential)
|
||||||
|
|
||||||
// 1. OAUTH_SUB로 기존 사용자 조회
|
// 1. OAUTH_SUB로 기존 사용자 조회
|
||||||
@ -38,7 +44,7 @@ export async function googleLogin(
|
|||||||
userId = user.user_id
|
userId = user.user_id
|
||||||
|
|
||||||
if (user.user_stts_cd === 'PENDING') {
|
if (user.user_stts_cd === 'PENDING') {
|
||||||
throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403)
|
return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' }
|
||||||
}
|
}
|
||||||
if (user.user_stts_cd === 'LOCKED') {
|
if (user.user_stts_cd === 'LOCKED') {
|
||||||
throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403)
|
throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403)
|
||||||
@ -66,16 +72,28 @@ export async function googleLogin(
|
|||||||
['GOOGLE', profile.sub, profile.email, userId]
|
['GOOGLE', profile.sub, profile.email, userId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (user.user_stts_cd === 'PENDING') {
|
||||||
|
return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' }
|
||||||
|
}
|
||||||
if (user.user_stts_cd !== 'ACTIVE') {
|
if (user.user_stts_cd !== 'ACTIVE') {
|
||||||
throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403)
|
throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 신규 사용자 생성
|
// 신규 사용자 생성
|
||||||
userId = await createOAuthUser(profile)
|
userId = await createOAuthUser(profile)
|
||||||
|
|
||||||
|
// PENDING 상태로 생성된 경우 등록 완료 안내
|
||||||
|
const statusResult = await authPool.query(
|
||||||
|
'SELECT USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE USER_ID = $1',
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
if (statusResult.rows[0]?.user_stts_cd === 'PENDING') {
|
||||||
|
return { type: 'pending', message: '계정이 등록되었습니다. 관리자 승인 후 로그인할 수 있습니다.' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 처리
|
// ACTIVE 사용자 로그인 처리
|
||||||
await authPool.query(
|
await authPool.query(
|
||||||
'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1',
|
'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1',
|
||||||
[userId]
|
[userId]
|
||||||
@ -85,17 +103,6 @@ export async function googleLogin(
|
|||||||
|
|
||||||
const userInfo = await getUserInfo(userId)
|
const userInfo = await getUserInfo(userId)
|
||||||
|
|
||||||
// PENDING 사용자는 JWT 발급하지 않음
|
|
||||||
if (userInfo.roles.length === 0) {
|
|
||||||
const userStatus = await authPool.query(
|
|
||||||
'SELECT USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE USER_ID = $1',
|
|
||||||
[userId]
|
|
||||||
)
|
|
||||||
if (userStatus.rows[0]?.user_stts_cd === 'PENDING') {
|
|
||||||
throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = signToken({
|
const token = signToken({
|
||||||
sub: userInfo.id,
|
sub: userInfo.id,
|
||||||
acnt: userInfo.account,
|
acnt: userInfo.account,
|
||||||
@ -104,7 +111,7 @@ export async function googleLogin(
|
|||||||
})
|
})
|
||||||
|
|
||||||
setTokenCookie(res, token)
|
setTokenCookie(res, token)
|
||||||
return userInfo
|
return { type: 'success', user: userInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyGoogleToken(credential: string): Promise<GoogleProfile> {
|
async function verifyGoogleToken(credential: string): Promise<GoogleProfile> {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export function LoginPage() {
|
|||||||
const [userId, setUserId] = useState('')
|
const [userId, setUserId] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [remember, setRemember] = useState(false)
|
const [remember, setRemember] = useState(false)
|
||||||
const { login, googleLogin, isLoading, error, clearError } = useAuthStore()
|
const { login, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore()
|
||||||
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID
|
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -213,6 +213,21 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending approval */}
|
||||||
|
{pendingMessage && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px', marginBottom: 16, borderRadius: 6,
|
||||||
|
background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)',
|
||||||
|
fontSize: 11, color: '#67e8f9', fontFamily: 'var(--fK)',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</span>
|
||||||
|
<span>{pendingMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@ -13,6 +13,8 @@ export interface AuthUser {
|
|||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
user: AuthUser
|
user: AuthUser
|
||||||
|
pending?: boolean
|
||||||
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginApi(account: string, password: string): Promise<AuthUser> {
|
export async function loginApi(account: string, password: string): Promise<AuthUser> {
|
||||||
@ -20,8 +22,18 @@ export async function loginApi(account: string, password: string): Promise<AuthU
|
|||||||
return response.data.user
|
return response.data.user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PendingApprovalError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PendingApprovalError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function googleLoginApi(credential: string): Promise<AuthUser> {
|
export async function googleLoginApi(credential: string): Promise<AuthUser> {
|
||||||
const response = await api.post<LoginResponse>('/auth/oauth/google', { credential })
|
const response = await api.post<LoginResponse>('/auth/oauth/google', { credential })
|
||||||
|
if (response.data.pending) {
|
||||||
|
throw new PendingApprovalError(response.data.message || '관리자 승인 후 로그인할 수 있습니다.')
|
||||||
|
}
|
||||||
return response.data.user
|
return response.data.user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { loginApi, googleLoginApi, logoutApi, fetchMe } from '../services/authApi'
|
import { loginApi, googleLoginApi, logoutApi, fetchMe, PendingApprovalError } from '../services/authApi'
|
||||||
import type { AuthUser } from '../services/authApi'
|
import type { AuthUser } from '../services/authApi'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@ -7,6 +7,7 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
pendingMessage: string | null
|
||||||
login: (account: string, password: string) => Promise<void>
|
login: (account: string, password: string) => Promise<void>
|
||||||
googleLogin: (credential: string) => Promise<void>
|
googleLogin: (credential: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
@ -20,6 +21,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
pendingMessage: null,
|
||||||
|
|
||||||
login: async (account: string, password: string) => {
|
login: async (account: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@ -34,11 +36,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
googleLogin: async (credential: string) => {
|
googleLogin: async (credential: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null, pendingMessage: null })
|
||||||
try {
|
try {
|
||||||
const user = await googleLoginApi(credential)
|
const user = await googleLoginApi(credential)
|
||||||
set({ user, isAuthenticated: true, isLoading: false })
|
set({ user, isAuthenticated: true, isLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PendingApprovalError) {
|
||||||
|
set({ isLoading: false, pendingMessage: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.'
|
const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.'
|
||||||
set({ isLoading: false, error: message })
|
set({ isLoading: false, error: message })
|
||||||
throw err
|
throw err
|
||||||
@ -70,5 +76,5 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
return user.permissions.includes(resource)
|
return user.permissions.includes(resource)
|
||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null, pendingMessage: null }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user