kcg-ai-monitoring/frontend/src/features/auth/LoginPage.tsx
htlee c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
2026-04-08 13:04:23 +09:00

253 lines
11 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 } from '@/app/auth/AuthContext';
import { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
/*
* SFR-01: 시스템 로그인 및 권한 관리
* - 백엔드 ID/PW 인증 (자체 백엔드 + JWT 쿠키)
* - GPKI/SSO는 향후 Phase 9 도입 (현재 비활성)
* - 비밀번호 정책, 계정 잠금 정책은 백엔드에서 처리
* - 모든 로그인 시도(성공/실패)는 백엔드 DB에 기록
*
* 데모 퀵로그인은 DemoQuickLogin 컴포넌트로 분리됨
* (VITE_SHOW_DEMO_LOGIN=true일 때만 표시).
*/
type AuthMethod = 'password' | 'gpki' | 'sso';
const ERROR_MESSAGES: Record<string, string> = {
USER_NOT_FOUND: '존재하지 않는 계정입니다.',
ACCOUNT_LOCKED: '계정이 잠겨있습니다. 관리자에게 문의하세요.',
WRONG_PROVIDER: '다른 인증 방식으로 가입된 계정입니다.',
MAX_FAIL_LOCKED: '5회 연속 실패로 계정이 잠금 처리되었습니다.',
NETWORK_ERROR: '네트워크 오류가 발생했습니다.',
};
function translateError(reason: string): string {
// WRONG_PASSWORD:N → 시도 N회 메시지
if (reason.startsWith('WRONG_PASSWORD:')) {
const cnt = reason.substring('WRONG_PASSWORD:'.length);
return `비밀번호가 올바르지 않습니다. (${cnt}/5)`;
}
if (reason.startsWith('ACCOUNT_NOT_ACTIVE:')) {
return '활성화되지 않은 계정입니다. 관리자 승인이 필요합니다.';
}
return ERROR_MESSAGES[reason] ?? `로그인 실패: ${reason}`;
}
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);
// user 상태가 확정된 후 대시보드로 이동
useEffect(() => {
if (user) navigate('/dashboard', { replace: true });
}, [user, navigate]);
const doLogin = async (account: string, pw: string) => {
setError('');
setLoading(true);
try {
await login(account, pw);
// 성공 시 useEffect가 navigate 처리
} catch (e) {
if (e instanceof LoginError) {
setError(translateError(e.reason));
} else {
setError(translateError('NETWORK_ERROR'));
}
} finally {
setLoading(false);
}
};
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (authMethod !== 'password') return;
if (!userId.trim()) { setError(t('error.emptyId')); return; }
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
doLogin(userId, password);
};
const handleDemoSelect = (acct: DemoAccount) => {
setUserId(acct.account);
setPassword(acct.password);
doLogin(acct.account, acct.password);
};
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string; disabled?: boolean }[] = [
{ key: 'password', icon: Lock, label: t('authMethod.password'), desc: t('authMethod.passwordDesc') },
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc'), disabled: true },
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc'), disabled: true },
];
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}
type="button"
onClick={() => { if (!m.disabled) { setAuthMethod(m.key); setError(''); } }}
disabled={m.disabled}
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.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''}
>
<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
aria-label={t('form.userId')}
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
aria-label={t('form.password')}
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-on-vivid 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>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
</form>
)}
{/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && (
<div className="space-y-4 text-center py-12">
<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"> (Phase 9)</p>
</div>
)}
{/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && (
<div className="space-y-4 text-center py-12">
<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"> (Phase 9)</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>
);
}