guardia-fa/frontend/src/pages/Login.tsx
2026-07-04 08:34:52 +09:00

207 lines
7.9 KiB
TypeScript

import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { login, verifyOtp } from '../api/client'
/**
* FA 로그인 — UIWS/OCR 2FA(OTP) 레이어 이식. [GUARDiA-FA]
* 1단계: 아이디/비밀번호 → /api/fa/auth/login (flat 응답).
* - twofa="false": 즉시 { token, username, role, workstationCode } 발급(기존 단일 로그인 회귀 0).
* - twofa="true": verifyMethod 로 2차 분기.
* · OTP : Authenticator 6자리 → /verify-otp
* · OTP_SETUP : QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
* 2단계: verify-token + 코드 → access 발급 → /dashboard.
* 보안: OTP secret/QR·인증코드는 화면 표시용만 — 로그/저장 절대 금지.
*/
type VerifyMethod = 'OTP' | 'OTP_SETUP'
export default function Login() {
const [step, setStep] = useState<'login' | 'verify'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('OTP')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const isSetup = verifyMethod === 'OTP_SETUP'
// access 발급 후 세션 저장(기존 fa_token / fa_user 스키마 보존 — 회귀 0).
const finish = (d: any) => {
localStorage.setItem('fa_token', d.token)
localStorage.setItem('fa_user', JSON.stringify({
username: d.username,
role: d.role,
workstationCode: d.workstationCode,
}))
navigate('/dashboard')
}
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await login(username, password)
const d = res.data || {}
if (d.twofa === 'true') {
setVerifyToken(d.verifyToken || '')
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'OTP')
setQrImage(d.qrImage || '')
setSecret(d.secret || '')
setCode('')
setStep('verify')
return
}
if (!d.token) throw new Error('no token')
finish(d)
} catch (err: any) {
setError(err.response?.data?.error || err.response?.data?.message || '로그인 실패')
} finally {
setLoading(false)
}
}
const submitVerify = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await verifyOtp(verifyToken, code)
const d = res.data || {}
if (!d.token) throw new Error('no token')
finish(d)
} catch (err: any) {
setError(err.response?.data?.error || err.response?.data?.message || '인증 실패 — 코드를 확인하세요.')
} finally {
setLoading(false)
}
}
const backToLogin = () => {
setStep('login'); setCode(''); setError('')
setQrImage(''); setSecret('') // 시크릿 잔존 방지
}
const inputCls =
'w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500'
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="bg-slate-800 rounded-xl p-8 w-96 border border-slate-700">
<div className="text-center mb-8">
<div className="text-blue-400 text-3xl font-bold mb-2">GUARDiA FA</div>
<div className="text-slate-400 text-sm">Factory Automation Platform</div>
<div className="text-slate-500 text-xs mt-1">e-Paper · QR WIP · MES </div>
</div>
{step === 'login' ? (
<form onSubmit={submitLogin} className="space-y-4">
<div>
<label className="block text-sm text-slate-300 mb-1"></label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className={inputCls}
placeholder="username"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className={inputCls}
placeholder="password"
autoComplete="current-password"
required
/>
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
) : (
<form onSubmit={submitVerify} className="space-y-4">
<div className="text-center">
<div className="text-white text-lg font-semibold mb-1">2 </div>
{isSetup ? (
<p className="text-slate-400 text-xs">
. QR을 Authenticator (Google·Microsoft)
6 .
</p>
) : (
<p className="text-slate-400 text-xs">
Authenticator 6 .
</p>
)}
</div>
{isSetup && qrImage && (
<div className="flex justify-center">
<img src={qrImage} alt="OTP QR" width={176} height={176}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{isSetup && secret && (
<div>
<div className="text-[11px] text-slate-500 mb-1">QR </div>
<code className="block text-xs text-blue-300 bg-slate-900 border border-slate-700 rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
<div>
<label className="block text-sm text-slate-300 mb-1"> </label>
<input
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
autoFocus
placeholder="000000"
className={inputCls + ' tracking-[0.4em] text-center'}
/>
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<button
type="submit"
disabled={loading || code.length !== 6}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
>
{loading ? '인증 중...' : isSetup ? '등록하고 로그인' : '로그인'}
</button>
<button
type="button"
onClick={backToLogin}
className="w-full border border-slate-600 text-slate-300 hover:bg-slate-700 rounded-lg py-2 text-sm transition-colors"
>
</button>
</form>
)}
{step === 'login' && (
<div className="mt-4 text-xs text-slate-500 text-center">
계정: admin / admin123
</div>
)}
</div>
</div>
)
}