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