207 lines
7.9 KiB
TypeScript
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>
|
|
)
|
|
}
|