- frontend: ESLint 에러 86건 수정 (unused-vars, set-state-in-effect, static-components 등) - backend: simulation.ts req.params 타입 단언 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
15 KiB
TypeScript
347 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
|
|
/* Demo accounts (개발 모드 전용) */
|
|
const DEMO_ACCOUNTS = [
|
|
{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' },
|
|
]
|
|
|
|
export function LoginPage() {
|
|
const [userId, setUserId] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [remember, setRemember] = useState(false)
|
|
const { login, isLoading, error, clearError } = useAuthStore()
|
|
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem('wing_remember')
|
|
if (saved) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setUserId(saved)
|
|
setRemember(true)
|
|
}
|
|
}, [])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
clearError()
|
|
|
|
if (!userId.trim() || !password.trim()) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await login(userId.trim(), password)
|
|
if (remember) {
|
|
localStorage.setItem('wing_remember', userId.trim())
|
|
} else {
|
|
localStorage.removeItem('wing_remember')
|
|
}
|
|
} catch {
|
|
// 에러는 authStore에서 관리
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
width: '100vw', height: '100vh', display: 'flex',
|
|
background: '#001028', overflow: 'hidden', position: 'relative',
|
|
}}>
|
|
{/* Background image */}
|
|
<div style={{
|
|
position: 'absolute', inset: 0,
|
|
backgroundImage: 'url(/24.png)',
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}} />
|
|
{/* Overlay */}
|
|
<div style={{
|
|
position: 'absolute', inset: 0,
|
|
background: 'linear-gradient(90deg, rgba(0,8,20,0.4) 0%, rgba(0,8,20,0.2) 25%, rgba(0,8,20,0.05) 50%, rgba(0,8,20,0.15) 75%, rgba(0,8,20,0.5) 100%)',
|
|
}} />
|
|
<div style={{
|
|
position: 'absolute', inset: 0,
|
|
background: 'linear-gradient(180deg, rgba(0,6,18,0.15) 0%, transparent 30%, transparent 70%, rgba(0,6,18,0.4) 100%)',
|
|
}} />
|
|
|
|
{/* Center: Login Form */}
|
|
<div style={{
|
|
width: '100%', display: 'flex', flexDirection: 'column',
|
|
alignItems: 'flex-start', justifyContent: 'center',
|
|
padding: '40px 50px 40px 120px', position: 'relative', zIndex: 1,
|
|
}}>
|
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
|
{/* Logo */}
|
|
<div style={{ textAlign: 'center', marginBottom: 36 }}>
|
|
<img
|
|
src="/wing_logo_text_white.svg"
|
|
alt="WING 해양환경 위기대응 통합시스템"
|
|
style={{ height: 28, margin: '0 auto', display: 'block' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Form card */}
|
|
<div style={{
|
|
padding: '32px 28px', borderRadius: 12,
|
|
background: 'linear-gradient(180deg, rgba(4,16,36,0.88) 0%, rgba(2,10,26,0.92) 100%)',
|
|
border: '1px solid rgba(60,120,180,0.12)',
|
|
backdropFilter: 'blur(20px)',
|
|
boxShadow: '0 8px 48px rgba(0,0,0,0.5)',
|
|
}}>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{/* User ID */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={{
|
|
display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)',
|
|
fontFamily: 'var(--fK)', marginBottom: 6, letterSpacing: '0.3px',
|
|
}}>
|
|
아이디
|
|
</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<span style={{
|
|
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
|
|
fontSize: 14, color: 'var(--t3)', pointerEvents: 'none',
|
|
}}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={userId}
|
|
onChange={(e) => { setUserId(e.target.value); clearError() }}
|
|
placeholder="사용자 아이디 입력"
|
|
autoComplete="username"
|
|
autoFocus
|
|
style={{
|
|
width: '100%', padding: '11px 14px 11px 38px',
|
|
background: 'var(--bg2)', border: '1px solid var(--bd)',
|
|
borderRadius: 8, color: 'var(--t1)', fontSize: 13,
|
|
fontFamily: 'var(--fK)', outline: 'none',
|
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
|
}}
|
|
onFocus={(e) => {
|
|
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
|
|
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
|
}}
|
|
onBlur={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--bd)'
|
|
e.currentTarget.style.boxShadow = 'none'
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<label style={{
|
|
display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)',
|
|
fontFamily: 'var(--fK)', marginBottom: 6, letterSpacing: '0.3px',
|
|
}}>
|
|
비밀번호
|
|
</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<span style={{
|
|
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
|
|
fontSize: 14, color: 'var(--t3)', pointerEvents: 'none',
|
|
}}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
</span>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => { setPassword(e.target.value); clearError() }}
|
|
placeholder="비밀번호 입력"
|
|
autoComplete="current-password"
|
|
style={{
|
|
width: '100%', padding: '11px 14px 11px 38px',
|
|
background: 'var(--bg2)', border: '1px solid var(--bd)',
|
|
borderRadius: 8, color: 'var(--t1)', fontSize: 13,
|
|
fontFamily: 'var(--fK)', outline: 'none',
|
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
|
}}
|
|
onFocus={(e) => {
|
|
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
|
|
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
|
}}
|
|
onBlur={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--bd)'
|
|
e.currentTarget.style.boxShadow = 'none'
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remember + Forgot */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
marginBottom: 20,
|
|
}}>
|
|
<label style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)', cursor: 'pointer',
|
|
}}>
|
|
<input
|
|
type="checkbox"
|
|
checked={remember}
|
|
onChange={(e) => setRemember(e.target.checked)}
|
|
style={{ accentColor: 'var(--cyan)' }}
|
|
/>
|
|
아이디 저장
|
|
</label>
|
|
<button type="button" style={{
|
|
fontSize: 11, color: 'var(--cyan)', fontFamily: 'var(--fK)',
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
textDecoration: 'none',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
|
>
|
|
비밀번호 찾기
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div style={{
|
|
padding: '8px 12px', marginBottom: 16, borderRadius: 6,
|
|
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
|
|
fontSize: 11, color: '#f87171', fontFamily: 'var(--fK)',
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
}}>
|
|
<span style={{ fontSize: 13 }}>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
|
</span>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Login button */}
|
|
<button type="submit" disabled={isLoading} style={{
|
|
width: '100%', padding: '12px',
|
|
background: isLoading
|
|
? 'rgba(6,182,212,0.15)'
|
|
: 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))',
|
|
border: '1px solid rgba(6,182,212,0.3)',
|
|
borderRadius: 8, color: 'var(--cyan)',
|
|
fontSize: 14, fontWeight: 700, cursor: isLoading ? 'wait' : 'pointer',
|
|
fontFamily: 'var(--fK)',
|
|
transition: 'all 0.2s',
|
|
boxShadow: '0 4px 16px rgba(6,182,212,0.1)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isLoading) {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.3), rgba(59,130,246,0.2))'
|
|
e.currentTarget.style.boxShadow = '0 6px 24px rgba(6,182,212,0.15)'
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isLoading) {
|
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))'
|
|
e.currentTarget.style.boxShadow = '0 4px 16px rgba(6,182,212,0.1)'
|
|
}
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
|
<span style={{
|
|
width: 14, height: 14, border: '2px solid rgba(6,182,212,0.3)',
|
|
borderTop: '2px solid var(--cyan)', borderRadius: '50%',
|
|
animation: 'loginSpin 0.8s linear infinite', display: 'inline-block',
|
|
}} />
|
|
인증 중...
|
|
</span>
|
|
) : '로그인'}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Divider */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, margin: '24px 0',
|
|
}}>
|
|
<div style={{ flex: 1, height: 1, background: 'var(--bd)' }} />
|
|
<span style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>또는</span>
|
|
<div style={{ flex: 1, height: 1, background: 'var(--bd)' }} />
|
|
</div>
|
|
|
|
{/* SSO / Certificate */}
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="button" style={{
|
|
flex: 1, padding: '10px', borderRadius: 8,
|
|
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
|
color: 'var(--t2)', fontSize: 11, fontWeight: 600,
|
|
fontFamily: 'var(--fK)', cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
공무원 인증서
|
|
</button>
|
|
<button type="button" style={{
|
|
flex: 1, padding: '10px', borderRadius: 8,
|
|
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
|
color: 'var(--t2)', fontSize: 11, fontWeight: 600,
|
|
fontFamily: 'var(--fK)', cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
|
SSO 로그인
|
|
</button>
|
|
</div>
|
|
|
|
{/* Demo accounts info (DEV only) */}
|
|
{import.meta.env.DEV && (
|
|
<div style={{
|
|
marginTop: 24, padding: '10px 12px', borderRadius: 8,
|
|
background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)',
|
|
}}>
|
|
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fK)', marginBottom: 6 }}>
|
|
데모 계정
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{DEMO_ACCOUNTS.map((acc) => (
|
|
<div key={acc.id}
|
|
onClick={() => { setUserId(acc.id); setPassword(acc.password); clearError() }}
|
|
style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '4px 6px', borderRadius: 4, cursor: 'pointer',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(6,182,212,0.06)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<span style={{ fontSize: 9, color: 'var(--t2)', fontFamily: 'var(--fM)' }}>
|
|
{acc.id} / {acc.password}
|
|
</span>
|
|
<span style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
|
{acc.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>{/* end form card */}
|
|
|
|
{/* Footer */}
|
|
<div style={{
|
|
marginTop: 24, textAlign: 'center', fontSize: 9,
|
|
color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.6,
|
|
}}>
|
|
<div>WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템</div>
|
|
<div style={{ marginTop: 2, color: 'rgba(134,144,166,0.6)' }}>
|
|
© 2026 Korea Coast Guard. All rights reserved.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|