Merge pull request 'fix(auth): 비자동승인 도메인 Google OAuth PENDING 안내 UX 개선' (#15) from feature/auth-system into develop

This commit is contained in:
htlee 2026-02-28 11:10:14 +09:00
커밋 62546144fa
5개의 변경된 파일66개의 추가작업 그리고 21개의 파일을 삭제

파일 보기

@ -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 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) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })

파일 보기

@ -16,12 +16,18 @@ interface GoogleProfile {
hd?: string // hosted domain (Google Workspace)
}
export interface GoogleLoginResult {
type: 'success' | 'pending'
user?: Awaited<ReturnType<typeof getUserInfo>>
message?: string
}
export async function googleLogin(
credential: string,
ipAddr: string,
userAgent: string,
res: Response
) {
): Promise<GoogleLoginResult> {
const profile = await verifyGoogleToken(credential)
// 1. OAUTH_SUB로 기존 사용자 조회
@ -38,7 +44,7 @@ export async function googleLogin(
userId = user.user_id
if (user.user_stts_cd === 'PENDING') {
throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403)
return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' }
}
if (user.user_stts_cd === 'LOCKED') {
throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403)
@ -66,16 +72,28 @@ export async function googleLogin(
['GOOGLE', profile.sub, profile.email, userId]
)
if (user.user_stts_cd === 'PENDING') {
return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' }
}
if (user.user_stts_cd !== 'ACTIVE') {
throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403)
}
} else {
// 신규 사용자 생성
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(
'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1',
[userId]
@ -85,17 +103,6 @@ export async function googleLogin(
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({
sub: userInfo.id,
acnt: userInfo.account,
@ -104,7 +111,7 @@ export async function googleLogin(
})
setTokenCookie(res, token)
return userInfo
return { type: 'success', user: userInfo }
}
async function verifyGoogleToken(credential: string): Promise<GoogleProfile> {

파일 보기

@ -11,7 +11,7 @@ export function LoginPage() {
const [userId, setUserId] = useState('')
const [password, setPassword] = useState('')
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
useEffect(() => {
@ -213,6 +213,21 @@ export function LoginPage() {
</button>
</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 && (
<div style={{

파일 보기

@ -13,6 +13,8 @@ export interface AuthUser {
interface LoginResponse {
success: boolean
user: AuthUser
pending?: boolean
message?: string
}
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
}
export class PendingApprovalError extends Error {
constructor(message: string) {
super(message)
this.name = 'PendingApprovalError'
}
}
export async function googleLoginApi(credential: string): Promise<AuthUser> {
const response = await api.post<LoginResponse>('/auth/oauth/google', { credential })
if (response.data.pending) {
throw new PendingApprovalError(response.data.message || '관리자 승인 후 로그인할 수 있습니다.')
}
return response.data.user
}

파일 보기

@ -1,5 +1,5 @@
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'
interface AuthState {
@ -7,6 +7,7 @@ interface AuthState {
isAuthenticated: boolean
isLoading: boolean
error: string | null
pendingMessage: string | null
login: (account: string, password: string) => Promise<void>
googleLogin: (credential: string) => Promise<void>
logout: () => Promise<void>
@ -20,6 +21,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false,
isLoading: true,
error: null,
pendingMessage: null,
login: async (account: string, password: string) => {
set({ isLoading: true, error: null })
@ -34,11 +36,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
googleLogin: async (credential: string) => {
set({ isLoading: true, error: null })
set({ isLoading: true, error: null, pendingMessage: null })
try {
const user = await googleLoginApi(credential)
set({ user, isAuthenticated: true, isLoading: false })
} catch (err) {
if (err instanceof PendingApprovalError) {
set({ isLoading: false, pendingMessage: err.message })
return
}
const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.'
set({ isLoading: false, error: message })
throw err
@ -70,5 +76,5 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return user.permissions.includes(resource)
},
clearError: () => set({ error: null }),
clearError: () => set({ error: null, pendingMessage: null }),
}))