guardia-hrm/frontend/src/pages/LoginPage.tsx

194 lines
9.4 KiB
TypeScript

import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import { verify2fa, signup, findId, resetPasswordHelper } from '../api/uiws'
type Helper = null | 'signup' | 'findId' | 'resetPw'
export default function LoginPage() {
const nav = useNavigate()
const [form, setForm] = useState({ username: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// UIWS 2FA — 1차 통과 후 코드 입력 단계
const [twofa, setTwofa] = useState<{ verifyToken: string; maskedEmail: string } | null>(null)
const [code, setCode] = useState('')
// 로그인 보조 3종 모달
const [helper, setHelper] = useState<Helper>(null)
const afterAuthenticated = async () => {
const me = await api.get('/auth/me')
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
nav('/dashboard')
}
const login = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError('')
try {
const { data } = await api.post('/auth/login', form)
const payload = data.data || {}
if (payload.twofa === 'true') {
// 2FA 활성 — 이메일 인증코드 입력 단계로 전환(access 토큰 미발급)
setTwofa({ verifyToken: payload.verifyToken, maskedEmail: payload.maskedEmail || '' })
return
}
localStorage.setItem('hrm_token', payload.token)
await afterAuthenticated()
} catch (err: any) {
const msg = err?.response?.data?.message || ''
setError(msg.includes('잠겼') || msg.includes('승인')
? msg : '아이디 또는 비밀번호가 올바르지 않습니다.')
} finally { setLoading(false) }
}
const verify = async (e: React.FormEvent) => {
e.preventDefault()
if (!twofa) return
setLoading(true); setError('')
try {
const { data } = await verify2fa(twofa.verifyToken, code)
localStorage.setItem('hrm_token', data.data.token)
await afterAuthenticated()
} catch {
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally { setLoading(false) }
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-blue-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
<p className="text-blue-300 mt-2">AI </p>
</div>
{!twofa ? (
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input type="text" value={form.username}
onChange={e => setForm({ ...form, username: e.target.value })}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="admin" required />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input type="password" value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••" required />
</div>
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<button type="submit" disabled={loading}
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{loading ? '로그인 중...' : '로그인'}
</button>
{/* UIWS 로그인 보조 3종 */}
<div className="flex items-center justify-center gap-3 text-xs text-slate-500">
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('signup'); setError('') }}></button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('findId'); setError('') }}> </button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('resetPw'); setError('') }}> </button>
</div>
<p className="text-center text-xs text-slate-400">
GUARDiA HRM v1.0 | AI
</p>
</form>
) : (
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div className="text-center">
<p className="text-sm font-semibold text-slate-700">2 </p>
<p className="text-xs text-slate-500 mt-1">
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
</p>
</div>
<input type="text" inputMode="numeric" value={code} autoFocus
onChange={e => setCode(e.target.value)}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-center tracking-widest text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="6자리 코드" maxLength={6} required />
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<button type="submit" disabled={loading}
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{loading ? '확인 중...' : '인증 확인'}
</button>
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
className="w-full text-xs text-slate-400 hover:text-slate-600"> </button>
</form>
)}
</div>
{helper && <HelperModal kind={helper} onClose={() => setHelper(null)} />}
</div>
)
}
/** 로그인 보조 3종 모달 — 회원가입 / 아이디찾기 / 비밀번호 초기화. */
function HelperModal({ kind, onClose }: { kind: Exclude<Helper, null>; onClose: () => void }) {
const [f, setF] = useState({ username: '', password: '', displayName: '', email: '' })
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const set = (k: string, v: string) => setF({ ...f, [k]: v })
const TITLE: Record<string, string> = { signup: '회원가입', findId: '아이디 찾기', resetPw: '비밀번호 초기화' }
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true); setMsg('')
try {
if (kind === 'signup') {
const { data } = await signup({ username: f.username, password: f.password, displayName: f.displayName, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
} else if (kind === 'findId') {
const { data } = await findId({ displayName: f.displayName, email: f.email })
setMsg(data.data?.found ? `회원님의 아이디: ${data.data.maskedUsername}` : '일치하는 계정을 찾을 수 없습니다.')
} else {
const { data } = await resetPasswordHelper({ username: f.username, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
}
} catch {
setMsg('요청 처리 중 오류가 발생했습니다.')
} finally { setBusy(false) }
}
const input = (k: string, ph: string, type = 'text') => (
<input type={type} value={(f as any)[k]} onChange={e => set(k, e.target.value)}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={ph} required />
)
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onClick={onClose}>
<form onClick={e => e.stopPropagation()} onSubmit={submit}
className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">{TITLE[kind]}</h2>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600"></button>
</div>
{kind === 'signup' && <>
{input('username', '아이디')}
{input('password', '비밀번호(4자 이상)', 'password')}
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'findId' && <>
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'resetPw' && <>
{input('username', '아이디')}
{input('email', '이메일', 'email')}
</>}
{msg && <p className="text-sm text-blue-700 bg-blue-50 rounded-lg px-3 py-2">{msg}</p>}
<button type="submit" disabled={busy}
className="w-full py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{busy ? '처리 중...' : '확인'}
</button>
</form>
</div>
)
}