wing-ops/frontend/src/common/components/auth/LoginPage.tsx

454 lines
17 KiB
TypeScript

import { useState, useEffect } from 'react';
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google';
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, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore();
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID;
useEffect(() => {
const saved = localStorage.getItem('wing_remember');
if (saved) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserId(saved);
setRemember(true);
}
}, []);
const handleGoogleSuccess = async (response: CredentialResponse) => {
if (response.credential) {
clearError();
try {
await googleLogin(response.credential);
} catch {
// 에러는 authStore에서 관리
}
}
};
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 className="w-screen h-screen flex overflow-hidden relative bg-bg-base">
{/* 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
className="w-full flex flex-col items-start justify-center relative z-[1] px-[120px] py-[40px]"
style={{ paddingLeft: 120, paddingRight: 50 }}
>
<div style={{ width: '100%', maxWidth: 360 }}>
{/* Logo */}
<div className="text-center mb-9">
<img
src="/wing_logo_text_white.svg"
alt="WING 해양환경 위기대응 통합시스템"
className="h-7 mx-auto block wing-logo"
/>
</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 className="mb-4">
<label
className="block text-caption font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<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
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
style={{
padding: '11px 14px 11px 38px',
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(--stroke-default)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
</div>
{/* Password */}
<div className="mb-5">
<label
className="block text-caption font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<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"
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
style={{
padding: '11px 14px 11px 38px',
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(--stroke-default)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between mb-5">
<label className="flex items-center gap-1.5 text-label-2 text-fg-disabled cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
</label>
<button
type="button"
className="text-label-2 text-color-accent cursor-pointer bg-transparent border-none"
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
</button>
</div>
{/* Pending approval */}
{pendingMessage && (
<div
className="flex items-start gap-2 text-label-2 rounded-sm mb-4"
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.08)',
border: '1px solid rgba(6,182,212,0.2)',
color: '#67e8f9',
}}
>
<span className="text-sm shrink-0 mt-px">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</span>
<span>{pendingMessage}</span>
</div>
)}
{/* Error */}
{error && (
<div
className="flex items-center gap-1.5 text-label-2 rounded-sm mb-4"
style={{
padding: '8px 12px',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171',
}}
>
<span className="text-title-4">
<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}
className="w-full text-color-accent text-sm font-bold rounded-md border"
style={{
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))',
borderColor: 'rgba(6,182,212,0.3)',
cursor: isLoading ? 'wait' : 'pointer',
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 className="flex items-center justify-center gap-2">
<span
style={{
width: 14,
height: 14,
border: '2px solid rgba(6,182,212,0.3)',
borderTop: '2px solid var(--color-accent)',
borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite',
display: 'inline-block',
}}
/>
...
</span>
) : (
'로그인'
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 bg-border h-px" />
<span className="text-caption text-fg-disabled"></span>
<div className="flex-1 bg-border h-px" />
</div>
{/* Google / Certificate */}
<div className="flex flex-col gap-2">
{GOOGLE_ENABLED && (
<div className="flex justify-center rounded-md overflow-hidden">
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => {
/* 팝업 닫힘 등 — 별도 처리 불필요 */
}}
theme="filled_black"
size="large"
shape="rectangular"
width={304}
/>
</div>
)}
<button
type="button"
className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-label-2 font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
style={{ transition: 'background 0.15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
>
<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>
</div>
{/* Demo accounts info (DEV only) */}
{import.meta.env.DEV && (
<div
className="rounded-md mt-6"
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.04)',
border: '1px solid rgba(6,182,212,0.08)',
}}
>
<div className="text-caption font-bold text-color-accent mb-1.5"> </div>
<div className="flex flex-col gap-[3px]">
{DEMO_ACCOUNTS.map((acc) => (
<div
key={acc.id}
onClick={() => {
setUserId(acc.id);
setPassword(acc.password);
clearError();
}}
className="flex justify-between items-center cursor-pointer rounded"
style={{
padding: '4px 6px',
transition: 'background 0.15s',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = 'rgba(6,182,212,0.06)')
}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span className="text-caption text-fg-sub font-mono">
{acc.id} / {acc.password}
</span>
<span className="text-caption text-fg-disabled">{acc.label}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* end form card */}
{/* Footer */}
<div className="text-center text-caption text-fg-disabled mt-6 leading-[1.6]">
<div>WING V2.0 | </div>
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
&copy; 2026 Korea Coast Guard. All rights reserved.
</div>
</div>
</div>
</div>
</div>
);
}