feat: 로그인 화면 및 로그인 Server Action 추가
Co-Authored-By: Claude Opus 5
@e74c12afd5705cc4c1a42ba8b06e8ba946501a9b
+++ app/(public)/login/_actions.ts
... | ... | @@ -0,0 +1,55 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { redirect } from 'next/navigation'; | |
| 4 | +import { revalidatePath } from 'next/cache'; | |
| 5 | +import { verifyAdminCredentials } from '@/lib/data/repositories/auth-repository'; | |
| 6 | +import { createSession } from '@/lib/auth/session'; | |
| 7 | + | |
| 8 | +export type LoginFormState = { | |
| 9 | + error?: string; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.'; | |
| 13 | +const MAX_LOGIN_ID_LENGTH = 100; | |
| 14 | +const MAX_PASSWORD_LENGTH = 200; | |
| 15 | + | |
| 16 | +function readTrimmedField(formData: FormData, key: string): string | null { | |
| 17 | + const value = formData.get(key); | |
| 18 | + if (typeof value !== 'string') { | |
| 19 | + return null; | |
| 20 | + } | |
| 21 | + | |
| 22 | + const trimmed = value.trim(); | |
| 23 | + return trimmed.length > 0 ? trimmed : null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * 로그인 Server Action — 인증의 진입점이라 사전 인증 확인은 성립하지 않는다(예외로 명시). | |
| 28 | + * 입력 검증 → 자격 검증 → 실패 시 계정 존재 여부를 드러내지 않는 일반화 메시지 → | |
| 29 | + * 성공 시 세션 생성 → 클라이언트 라우터 캐시 무효화 → 리다이렉트. | |
| 30 | + */ | |
| 31 | +export async function login( | |
| 32 | + _prevState: LoginFormState, | |
| 33 | + formData: FormData | |
| 34 | +): Promise<LoginFormState> { | |
| 35 | + const loginId = readTrimmedField(formData, 'loginId'); | |
| 36 | + const password = readTrimmedField(formData, 'password'); | |
| 37 | + | |
| 38 | + if ( | |
| 39 | + !loginId || | |
| 40 | + !password || | |
| 41 | + loginId.length > MAX_LOGIN_ID_LENGTH || | |
| 42 | + password.length > MAX_PASSWORD_LENGTH | |
| 43 | + ) { | |
| 44 | + return { error: GENERIC_LOGIN_ERROR }; | |
| 45 | + } | |
| 46 | + | |
| 47 | + const admin = await verifyAdminCredentials({ loginId, password }); | |
| 48 | + if (!admin) { | |
| 49 | + return { error: GENERIC_LOGIN_ERROR }; | |
| 50 | + } | |
| 51 | + | |
| 52 | + await createSession(admin.id); | |
| 53 | + revalidatePath('/', 'layout'); | |
| 54 | + redirect('/'); | |
| 55 | +} |
+++ app/(public)/login/_components/login-form.tsx
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState } from 'react'; | |
| 4 | +import { login, type LoginFormState } from '../_actions'; | |
| 5 | + | |
| 6 | +const initialState: LoginFormState = {}; | |
| 7 | + | |
| 8 | +export function LoginForm() { | |
| 9 | + const [state, formAction, isPending] = useActionState(login, initialState); | |
| 10 | + | |
| 11 | + return ( | |
| 12 | + <form | |
| 13 | + action={formAction} | |
| 14 | + className="flex w-full max-w-sm flex-col gap-4 border border-foreground/15 p-8" | |
| 15 | + > | |
| 16 | + <h1 className="text-xl font-semibold text-foreground"> | |
| 17 | + 에듀페이 관리자 로그인 | |
| 18 | + </h1> | |
| 19 | + | |
| 20 | + <div className="flex flex-col gap-1"> | |
| 21 | + <label htmlFor="loginId" className="text-sm text-foreground/70"> | |
| 22 | + 아이디 | |
| 23 | + </label> | |
| 24 | + <input | |
| 25 | + id="loginId" | |
| 26 | + name="loginId" | |
| 27 | + type="text" | |
| 28 | + autoComplete="username" | |
| 29 | + required | |
| 30 | + className="border border-foreground/25 bg-background px-3 py-2 text-foreground" | |
| 31 | + /> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <div className="flex flex-col gap-1"> | |
| 35 | + <label htmlFor="password" className="text-sm text-foreground/70"> | |
| 36 | + 비밀번호 | |
| 37 | + </label> | |
| 38 | + <input | |
| 39 | + id="password" | |
| 40 | + name="password" | |
| 41 | + type="password" | |
| 42 | + autoComplete="current-password" | |
| 43 | + required | |
| 44 | + className="border border-foreground/25 bg-background px-3 py-2 text-foreground" | |
| 45 | + /> | |
| 46 | + </div> | |
| 47 | + | |
| 48 | + {state.error && <p className="text-sm text-danger">{state.error}</p>} | |
| 49 | + | |
| 50 | + <button | |
| 51 | + type="submit" | |
| 52 | + disabled={isPending} | |
| 53 | + className="bg-foreground px-4 py-2 text-background disabled:opacity-50" | |
| 54 | + > | |
| 55 | + {isPending ? '로그인 중...' : '로그인'} | |
| 56 | + </button> | |
| 57 | + </form> | |
| 58 | + ); | |
| 59 | +} |
+++ app/(public)/login/page.tsx
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +import { redirect } from 'next/navigation'; | |
| 2 | +import { getSessionAdmin } from '@/lib/auth/dal'; | |
| 3 | +import { LoginForm } from './_components/login-form'; | |
| 4 | + | |
| 5 | +// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 | |
| 6 | +// 원천 차단하기 위해 명시적으로 강제한다. | |
| 7 | +export const dynamic = 'force-dynamic'; | |
| 8 | + | |
| 9 | +export default async function Page() { | |
| 10 | + const admin = await getSessionAdmin(); | |
| 11 | + if (admin) { | |
| 12 | + redirect('/'); | |
| 13 | + } | |
| 14 | + | |
| 15 | + return ( | |
| 16 | + <main className="flex min-h-screen flex-1 items-center justify-center bg-background p-6"> | |
| 17 | + <LoginForm /> | |
| 18 | + </main> | |
| 19 | + ); | |
| 20 | +} |
--- app/globals.css
+++ app/globals.css
... | ... | @@ -3,11 +3,14 @@ |
| 3 | 3 |
:root {
|
| 4 | 4 |
--background: #ffffff; |
| 5 | 5 |
--foreground: #171717; |
| 6 |
+ /* 임시 값, 디자인 확정 전 — 에러/위험 상태 표시용 (initial-security-setup, 로그인 실패 메시지) */ |
|
| 7 |
+ --danger: #dc2626; |
|
| 6 | 8 |
} |
| 7 | 9 |
|
| 8 | 10 |
@theme inline {
|
| 9 | 11 |
--color-background: var(--background); |
| 10 | 12 |
--color-foreground: var(--foreground); |
| 13 |
+ --color-danger: var(--danger); |
|
| 11 | 14 |
--font-sans: var(--font-geist-sans); |
| 12 | 15 |
--font-mono: var(--font-geist-mono); |
| 13 | 16 |
} |
... | ... | @@ -16,6 +19,8 @@ |
| 16 | 19 |
:root {
|
| 17 | 20 |
--background: #0a0a0a; |
| 18 | 21 |
--foreground: #ededed; |
| 22 |
+ /* 임시 값, 디자인 확정 전 */ |
|
| 23 |
+ --danger: #f87171; |
|
| 19 | 24 |
} |
| 20 | 25 |
} |
| 21 | 26 |
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?