- workspace→repos 전체 소스 클린 재동기화(부분 auto-sync 컴파일불가 해소) - db/202_ai_provider_claude.sql: hrm_settings ai.provider UPSERT claude(멱등, 104 시드 이후) - application.yml schema-locations 에 202 등재(mode=always) - 키 미설정/실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import api from '../api/client'
|
|
import { verify2fa, verifyOtp, signup, findId, resetPasswordHelper } from '../api/uiws'
|
|
|
|
type Helper = null | 'signup' | 'findId' | 'resetPw'
|
|
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
|
|
|
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차 통과 후 코드 입력 단계.
|
|
// · EMAIL : 이메일 인증코드(maskedEmail) → /verify (하위호환)
|
|
// · OTP : Authenticator 6자리 → /verify-otp
|
|
// · OTP_SETUP : 최초 로그인 — QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
|
const [twofa, setTwofa] = useState<{
|
|
verifyToken: string; verifyMethod: VerifyMethod
|
|
maskedEmail: string; qrImage: string; secret: string
|
|
} | null>(null)
|
|
const [code, setCode] = useState('')
|
|
const isOtp = twofa?.verifyMethod === 'OTP' || twofa?.verifyMethod === 'OTP_SETUP'
|
|
const isSetup = twofa?.verifyMethod === 'OTP_SETUP'
|
|
|
|
// 로그인 보조 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 활성 — verifyMethod 로 분기(access 토큰 미발급). 시크릿/QR은 화면 표시용만.
|
|
setTwofa({
|
|
verifyToken: payload.verifyToken,
|
|
verifyMethod: (payload.verifyMethod as VerifyMethod) || 'EMAIL',
|
|
maskedEmail: payload.maskedEmail || '',
|
|
qrImage: payload.qrImage || '',
|
|
secret: payload.secret || '',
|
|
})
|
|
setCode('')
|
|
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 {
|
|
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
|
|
const { data } = isOtp
|
|
? await verifyOtp(twofa.verifyToken, code)
|
|
: 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>
|
|
{isSetup ? (
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
|
등록한 뒤 6자리 코드를 입력하세요.
|
|
</p>
|
|
) : isOtp ? (
|
|
<p className="text-xs text-slate-500 mt-1">Authenticator 앱에 표시된 6자리 코드를 입력하세요.</p>
|
|
) : (
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isSetup && twofa.qrImage && (
|
|
<div className="flex justify-center">
|
|
<img src={twofa.qrImage} alt="OTP QR" width={180} height={180}
|
|
style={{ background: '#fff', padding: 8, borderRadius: 8, border: '1px solid #e2e8f0' }} />
|
|
</div>
|
|
)}
|
|
{isSetup && twofa.secret && (
|
|
<div>
|
|
<p className="text-[11px] text-slate-400 mb-1">QR 스캔이 안 되면 수동 입력 키</p>
|
|
<code className="block text-xs text-blue-700 bg-slate-50 border border-slate-200 rounded-md px-2 py-1.5 break-all select-all">
|
|
{twofa.secret}
|
|
</code>
|
|
</div>
|
|
)}
|
|
<input type="text" inputMode="numeric" value={code} autoFocus
|
|
autoComplete="one-time-code"
|
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
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 || code.length !== 6}
|
|
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
|
{loading ? '확인 중...' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
|
</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>
|
|
)
|
|
}
|