FeaturesAuthLogin

Login (/login)

SPEC #006 정합. 도입 v0.2.0.

Overview

운영사 / 본사 / 점장 모두 단일 login 화면. 이메일 + 비밀번호 → backend /api/v1/auth/login → BFF 가 sealed cookie 봉인.

Spec

UI

  • AuthShell (좌측 brand 영역 + 우측 form)
  • <form>: email input + password input + 로그인 button
  • 임시 비번 사용자 (passwordMustChange=true) — 로그인 폼은 분기하지 않음. role home 이동 후 레이아웃 가드가 server-side redirect 로 /onboarding/change-password 강제 이동 (role 분기보다 우선)
  • 로딩 — submit button spinner + form disabled
  • 에러 → Toast (extractCode(payload) 기반 — 응답 JSON body)

로그인 후 role 분기 (SPEC #016)

onSuccess 에서 AuthResponse.role 로 목적지를 정한다. 레이아웃 가드가 같은 규칙을 server-side 에서 한 번 더 강제(defense-in-depth)하므로 이 분기는 첫 진입 UX 용.

role목적지
OPERATORsafeNext(nextPath) (기본 /)
HQ_MANAGER/admin (본사 모드 셸)
STORE_MANAGERredirect 없음. “지원되지 않는 역할입니다” 안내 + POST /api/auth/logout(세션 destroy) — 앱 접근 차단

입력

  • email: 이메일 형식 (zod)
  • password: 8자 이상 (zod, backend OpenAPI 정합)

동작

  1. submit → POST /api/auth/login (BFF)
  2. BFF → POST /api/v1/auth/login (backend)
  3. 200 → ironSession.save() + Set-Cookie lm_session
  4. FE 가 AuthResponse.role 로 분기해 router.replace (OPERATOR→safeNext(nextPath) / HQ_MANAGER→/admin / STORE_MANAGER→안내+logout)
  5. 레이아웃 가드((protected)/layout.tsx 또는 app/admin/layout.tsx)가 me 응답 확인 → passwordMustChange === true/onboarding/change-password 로 server-side redirect (role 분기보다 우선). 이어서 role 교차 리디렉션도 server-side 에서 한 번 더 강제

Implementation

// apps/admin/src/app/(auth)/login/login-form.tsx
'use client';
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
 
export const LoginForm = () => {
  const router = useRouter();
  const login = useMutation({
    mutationFn: async ({ email, password }: LoginInput) => {
      const res = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
        credentials: "same-origin",
      });
      if (!res.ok) throw new ApiError(res.status, await res.json());
      return res.json() as Promise<AuthResponse>;
    },
    onSuccess: async (data) => {
      // role 분기 (SPEC #016). passwordMustChange 분기는 form 책임 X — 레이아웃 가드의
      // server-side redirect 가 `/onboarding/change-password` 로 강제.
      if (data.role === "STORE_MANAGER") {
        // 미지원 역할 — 세션 정리(logout) 후 안내, redirect 안 함 (후속 Surface 12).
        await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
        setBlocked("지원되지 않는 역할입니다.");
        return;
      }
      router.replace(data.role === "HQ_MANAGER" ? "/admin" : safeNext(nextPath));
    },
    onError: (err) => {
      const code = extractCode((err as ApiError).body);  // payload(JSON) 를 넘긴다
      // ...
    },
  });
  // form render
};

BFF (apps/admin/src/app/api/auth/login/route.ts):

  1. body 파싱 + zod 검증
  2. backend POST
  3. 200 → session.save + 200 응답
  4. 401 → 그대로 surface
  5. 5xx → 502 BACKEND_ERROR (session 미저장)

States & Edge Cases

상태처리
로딩button spinner + form disabled
401 INVALID_CREDENTIALS”이메일 또는 비밀번호가 일치하지 않습니다” Toast
5xx”잠시 후 다시 시도해 주세요” Toast (BACKEND_ERROR)
네트워크 실패”네트워크 연결 확인” Toast (BACKEND_UNREACHABLE)
빈 inputsubmit button disabled (실패 사전 차단)
passwordMustChange=true레이아웃 가드가 /onboarding/change-password 로 강제 이동 (role 분기보다 우선)
role=HQ_MANAGER/admin 본사 모드 셸로 이동
role=STORE_MANAGER”지원되지 않는 역할” 안내 + 세션 destroy (앱 접근 차단)
이미 로그인 상태/login 은 항상 public(middleware 통과, 자동 redirect 없음). 보호 라우트 접근 시 middleware + 레이아웃 가드가 role home(/ · /admin)으로 라우팅

Constraints

  • JWT cookie HttpOnly. UI 가 토큰 직접 접근 X.
  • BFF 가 5xx 일 때 session 봉인 X.
  • email · password zod 검증은 backend OpenAPI 와 일치.

Roadmap

  • 비밀번호 표시 토글 (eye icon)
  • “다음 로그인 유지” — 현재 미구현 (refresh TTL 7일 default)
  • SSO (v1.1+)

References

  • SPEC #006 §2-5 · §2-7 · SPEC #016 (role 라우팅·STORE_MANAGER 차단)
  • linkmusic-frontend-space/apps/admin/src/app/(auth)/login/
  • linkmusic-frontend-space/apps/admin/src/app/api/auth/login/route.ts