kcg-ai-monitoring/frontend/src/features/auth/LoginPage.tsx
htlee e6319a571c refactor: 모노레포 구조로 전환 (frontend/ + backend/ + database/)
Phase 1: 모노레포 디렉토리 구조 구축

- 기존 React 프로젝트를 frontend/ 디렉토리로 이동 (git mv)
- backend/ 디렉토리 생성 (Phase 2에서 Spring Boot 초기화)
- database/migration/ 디렉토리 생성 (Phase 2에서 Flyway 마이그레이션)
- 루트 .gitignore에 frontend/, backend/ 경로 반영
- 루트 CLAUDE.md를 모노레포 가이드로 갱신
- Makefile 추가 (dev/build/lint 통합 명령)
- frontend/vite.config.ts에 /api → :8080 백엔드 proxy 설정
- .githooks/pre-commit을 모노레포 구조에 맞게 갱신
  (frontend/ 변경 시 frontend/ 내부에서 검증)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:47:24 +09:00

332 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
/*
* SFR-01: 시스템 로그인 및 권한 관리
* - 해양경찰 SSO·공무원증·GPKI 등 기존 인증체계 로그인 연동
* - 역할 기반 권한 관리(RBAC)
* - 비밀번호 정책, 계정 잠금 정책
* - 감사 로그 기록
* - 5회 연속 실패 시 계정 잠금(30분)
*/
type AuthMethod = 'password' | 'gpki' | 'sso';
// SFR-01: 시뮬레이션 계정 (역할별)
const DEMO_ACCOUNTS: Record<string, { pw: string; name: string; rank: string; org: string; role: UserRole }> = {
admin: { pw: 'admin1234!', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: 'ADMIN' },
operator: { pw: 'oper12345!', name: '이상호', rank: '경위', org: '서해지방해경청', role: 'OPERATOR' },
analyst: { pw: 'anal12345!', name: '정해진', rank: '주무관', org: '남해지방해경청', role: 'ANALYST' },
field: { pw: 'field1234!', name: '박민수', rank: '경사', org: '5001함 삼봉', role: 'FIELD' },
viewer: { pw: 'view12345!', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: 'VIEWER' },
};
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30분
export function LoginPage() {
const { t } = useTranslation('auth');
const navigate = useNavigate();
const { user, login } = useAuth();
const [authMethod, setAuthMethod] = useState<AuthMethod>('password');
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [failCount, setFailCount] = useState(0);
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
// user 상태가 확정된 후 대시보드로 이동
useEffect(() => {
if (user) navigate('/dashboard', { replace: true });
}, [user, navigate]);
const doLogin = (method: AuthMethod, account?: typeof DEMO_ACCOUNTS[string]) => {
const u = account || DEMO_ACCOUNTS['operator'];
login({
id: userId || u.role,
name: u.name,
rank: u.rank,
org: u.org,
role: u.role,
authMethod: method,
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
});
};
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// SFR-01: 계정 잠금 확인
if (lockedUntil && Date.now() < lockedUntil) {
const remainMin = Math.ceil((lockedUntil - Date.now()) / 60000);
setError(t('error.locked', { minutes: remainMin }));
return;
}
if (authMethod === 'password') {
if (!userId.trim()) { setError(t('error.emptyId')); return; }
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
if (password.length < 9) { setError(t('error.invalidPassword')); return; }
}
setLoading(true);
setTimeout(() => {
setLoading(false);
// SFR-01: ID/PW 인증 시 계정 검증
const account = DEMO_ACCOUNTS[userId.toLowerCase()];
if (authMethod === 'password' && (!account || account.pw !== password)) {
const newCount = failCount + 1;
setFailCount(newCount);
if (newCount >= MAX_LOGIN_ATTEMPTS) {
setLockedUntil(Date.now() + LOCKOUT_DURATION);
setError(t('error.maxFailed', { max: MAX_LOGIN_ATTEMPTS }));
} else {
setError(t('error.wrongCredentials', { count: newCount, max: MAX_LOGIN_ATTEMPTS }));
}
return;
}
setFailCount(0);
setLockedUntil(null);
doLogin(authMethod, account);
}, 1200);
};
const DEMO_ROLE_LABELS: Record<UserRole, string> = {
ADMIN: t('demo.admin'),
OPERATOR: t('demo.operator'),
ANALYST: t('demo.analyst'),
FIELD: t('demo.field'),
VIEWER: t('demo.viewer'),
};
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string }[] = [
{ key: 'password', icon: Lock, label: t('authMethod.password'), desc: t('authMethod.passwordDesc') },
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc') },
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc') },
];
return (
<div className="min-h-screen bg-background flex items-center justify-center relative overflow-hidden">
{/* 배경 그리드 */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: 'linear-gradient(#3b82f6 1px, transparent 1px), linear-gradient(90deg, #3b82f6 1px, transparent 1px)',
backgroundSize: '60px 60px',
}} />
{/* 로그인 카드 */}
<div className="relative z-10 w-full max-w-md mx-4">
{/* 로고 영역 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
<Shield className="w-8 h-8 text-blue-400" />
</div>
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
</div>
<div className="bg-card border border-border rounded-2xl shadow-2xl shadow-black/40 overflow-hidden">
{/* 인증 방식 선택 탭 */}
<div className="flex border-b border-border">
{authMethods.map((m) => (
<button
key={m.key}
onClick={() => { setAuthMethod(m.key); setError(''); }}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
authMethod === m.key
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<m.icon className="w-4 h-4" />
<span className="text-[9px] font-medium whitespace-nowrap">{m.label}</span>
</button>
))}
</div>
<div className="p-6">
{/* ID/PW 로그인 */}
{authMethod === 'password' && (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block whitespace-nowrap">{t('form.userId')}</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder={t('form.userIdPlaceholder')}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-10 pr-4 py-2.5 text-sm text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20"
autoComplete="username"
/>
</div>
</div>
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block whitespace-nowrap">{t('form.password')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('form.passwordPlaceholder')}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-10 pr-10 py-2.5 text-sm text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
>
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* 비밀번호 정책 안내 */}
<div className="bg-surface-overlay rounded-lg p-3 space-y-1">
<div className="text-[9px] text-hint font-medium">{t('passwordPolicy.title')}</div>
<ul className="text-[9px] text-hint space-y-0.5">
<li>{t('passwordPolicy.minLength')}</li>
<li>{t('passwordPolicy.changeInterval')}</li>
<li>{t('passwordPolicy.lockout')}</li>
<li>{t('passwordPolicy.reuse')}</li>
</ul>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('button.authenticating')}
</>
) : t('button.login')}
</button>
{/* 데모 퀵로그인 */}
<div className="pt-2 border-t border-border">
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
<div className="grid grid-cols-5 gap-1.5">
{Object.entries(DEMO_ACCOUNTS).map(([key, acct]) => (
<button
key={key}
type="button"
onClick={() => doLogin('password', acct)}
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap"
>
{DEMO_ROLE_LABELS[acct.role]}
</button>
))}
</div>
</div>
</form>
)}
{/* GPKI 인증 */}
{authMethod === 'gpki' && (
<div className="space-y-4">
<div className="text-center py-6">
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1">{t('gpki.desc')}</p>
</div>
<div className="bg-surface-overlay rounded-lg p-4 border border-dashed border-slate-700/50">
<div className="text-center">
<div className="text-[10px] text-hint mb-2">{t('gpki.certStatus')}</div>
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
<span className="text-[11px] text-yellow-400">{t('gpki.certWaiting')}</span>
</div>
</div>
</div>
<button
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('gpki', DEMO_ACCOUNTS['operator']); }, 1500); }}
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('gpki.authenticating')}</>
) : t('gpki.start')}
</button>
<p className="text-[9px] text-hint text-center">
{t('gpki.internalOnly')}
</p>
</div>
)}
{/* SSO 연동 */}
{authMethod === 'sso' && (
<div className="space-y-4">
<div className="text-center py-6">
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1">{t('sso.desc')}</p>
</div>
<div className="bg-green-900/20 border border-green-700/30 rounded-lg p-3">
<div className="flex items-center gap-2 text-[10px] text-green-400">
<Shield className="w-3.5 h-3.5" />
{t('sso.tokenDetected')}
</div>
</div>
<button
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('sso', DEMO_ACCOUNTS['operator']); }, 800); }}
disabled={loading}
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('sso.authenticating')}</>
) : t('sso.autoLogin')}
</button>
<p className="text-[9px] text-hint text-center">
{t('sso.sessionNote')}
</p>
</div>
)}
</div>
{/* 하단 정보 */}
<div className="px-6 py-3 bg-background border-t border-border">
<div className="flex items-center justify-between text-[8px] text-hint">
<span>{t('footer.version')}</span>
<span>{t('footer.org')}</span>
</div>
</div>
</div>
{/* 접근 권한 안내 */}
<div className="mt-4 text-center">
<p className="text-[9px] text-hint">
{t('accessNotice')}
</p>
</div>
</div>
</div>
);
}