TASK-020 버그 수정:
- BUG-1: adminFetch Authorization 헤더 우선순위 수정 (JWT > API Key)
하네스 저장 실패 에러 핸들링 개선 (401/non-ok 명시적 처리)
- BUG-2: /admin/logs useEffect 초기 로드 추가 (마운트 시 자동 조회)
- BUG-3: costs/record python3 → node/python3/jq fallback 체인
TASK-021 JWT BE:
- User 모델 추가 (Prisma schema)
- AuthService: register(초대코드 검증+bcrypt) / login / refresh / getMe
- JwtStrategy (passport-jwt), JwtGuard, CompositeGuard (JWT+API Key)
- AuthController: /api/auth/{register,login,refresh,me}
- ThrottlerModule: 로그인 5회/분 Rate limiting
- CompositeGuard로 AdminController, CostsController Guard 전환
- EventsGateway: Socket.IO handshake JWT 검증 추가
- 테스트 26/26 pass
TASK-022 FE:
- AuthContext (AuthProvider + useAuth)
- AppShell (AuthProvider + AuthGate 라우트 보호)
- /login 페이지 (터미널 UI)
- /register 페이지 (초대 코드 필드)
- Sidebar: 로그인 사용자명 + 로그아웃 버튼
- FE 16 routes build 성공
190 lines
4.5 KiB
TypeScript
190 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import { useAuth } from '@/lib/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
const Page = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
background: var(--bg-main);
|
|
`;
|
|
|
|
const Box = styled.div`
|
|
width: 100%;
|
|
max-width: 360px;
|
|
padding: var(--space-xl);
|
|
`;
|
|
|
|
const Logo = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: var(--space-xxl);
|
|
`;
|
|
|
|
const CircleFull = styled.div`
|
|
width: 20px; height: 20px;
|
|
background: var(--text-primary);
|
|
border-radius: 50%;
|
|
`;
|
|
|
|
const CircleHalf = styled.div`
|
|
width: 10px; height: 20px;
|
|
background: var(--text-primary);
|
|
border-radius: 0 20px 20px 0;
|
|
`;
|
|
|
|
const Title = styled.h1`
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--space-xs);
|
|
letter-spacing: -0.02em;
|
|
`;
|
|
|
|
const Subtitle = styled.p`
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
margin-bottom: var(--space-xl);
|
|
`;
|
|
|
|
const Form = styled.form`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
`;
|
|
|
|
const FieldLabel = styled.label`
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-secondary);
|
|
margin-bottom: var(--space-xs);
|
|
display: block;
|
|
`;
|
|
|
|
const Input = styled.input`
|
|
width: 100%;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 10px 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
|
|
&:focus { border-color: var(--border-hover); }
|
|
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
|
`;
|
|
|
|
const SubmitBtn = styled.button`
|
|
background: var(--text-primary);
|
|
border: 1px solid var(--text-primary);
|
|
color: #000;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
margin-top: var(--space-sm);
|
|
|
|
&:hover { background: transparent; color: var(--text-primary); }
|
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
`;
|
|
|
|
const ErrorMsg = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: #ff5f5f;
|
|
padding: var(--space-sm);
|
|
border: 1px solid #ff5f5f44;
|
|
background: rgba(255,95,95,0.05);
|
|
`;
|
|
|
|
const FooterLink = styled.div`
|
|
margin-top: var(--space-lg);
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
text-align: center;
|
|
|
|
a { color: var(--text-primary); text-decoration: none;
|
|
&:hover { text-decoration: underline; } }
|
|
`;
|
|
|
|
export default function LoginPage() {
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const { login } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
try {
|
|
await login(username, password);
|
|
router.push('/');
|
|
} catch (err) {
|
|
setError((err as Error).message ?? '로그인 실패');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Page>
|
|
<Box>
|
|
<Logo><CircleFull /><CircleHalf /></Logo>
|
|
<Title>하나랑 대시보드</Title>
|
|
<Subtitle>SYSTEM_ACCESS // AUTHENTICATE</Subtitle>
|
|
|
|
<Form onSubmit={handleSubmit}>
|
|
<div>
|
|
<FieldLabel>USERNAME</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder="username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoComplete="username"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FieldLabel>PASSWORD</FieldLabel>
|
|
<Input
|
|
type="password"
|
|
placeholder="••••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
required
|
|
/>
|
|
</div>
|
|
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
|
<SubmitBtn type="submit" disabled={loading}>
|
|
{loading ? 'AUTHENTICATING...' : 'LOGIN'}
|
|
</SubmitBtn>
|
|
</Form>
|
|
|
|
<FooterLink>
|
|
계정 없음? <Link href="/register">REGISTER</Link>
|
|
</FooterLink>
|
|
</Box>
|
|
</Page>
|
|
);
|
|
}
|