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 확인됨
253 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|