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>
332 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|