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 · #150 — apps/admin)

onSuccess 에서 AuthResponse.role 로 목적지를 정한다. 레이아웃 가드가 같은 규칙을 server-side 에서 한 번 더 강제(defense-in-depth)하므로 이 분기는 첫 진입 UX 용. 운영사 백오피스(apps/admin)는 OPERATOR 전용이다(SPEC #150) — OPERATOR 외 role 은 진입하지 못한다.

role목적지 (apps/admin)
OPERATORsafeNext(nextPath) (기본 /)
HQ_MANAGERredirect 없음. “본사 계정은 space.linkmusic.io 에서 로그인” 안내 + 절대 URL 링크(NEXT_PUBLIC_SPACE_ORIGIN) + POST /api/auth/logout(세션 destroy). 본사는 apps/space 단일 소스 (SPEC #150)
STORE_MANAGERredirect 없음. “운영사 직원 전용” 안내 + POST /api/auth/logout(세션 destroy) — 앱 접근 차단

(apps/space 의 login role 분기는 반대 — HQ_MANAGER /admin·STORE_MANAGER /store·OPERATOR 차단. Auth Flow §6 참조.)

역할 차단 안내는 상대 콘솔 링크로 대칭화됐다 (SPEC #164 C5). admin 이 HQ_MANAGER 차단 시 NEXT_PUBLIC_SPACE_ORIGIN 링크를 노출하듯, apps/space 도 OPERATOR 차단 안내(및 하단 정적 안내 문구)에 운영사 백오피스 링크를 노출한다 — NEXT_PUBLIC_ADMIN_ORIGIN ?? "https://admin.space.linkmusic.io" (.../login, rel="noreferrer"). env 미설정 시 기본 origin 으로 폴백하므로 배포 무영향 (impersonation-expired 화면이 쓰는 것과 동일한 env). testid: login-admin-link(차단 배너)· login-admin-link-info(정적 안내).

입력

  • 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 로 분기 (OPERATOR→router.replace(safeNext(nextPath)) / OPERATOR 외→세션 destroy + 차단 안내, redirect 없음)
  5. ops 셸 가드((protected)/layout.tsx)가 me 응답 확인 → passwordMustChange === true/onboarding/change-password 로 server-side redirect (role 분기보다 우선). OPERATOR 외 모든 role 은 /login 으로 fail-closed (SPEC #150 — admin 에 본사 셸 없음)

에러 처리 (SPEC #149 rate limit)

  • 429 RATE_LIMITED — 짧은 시간 반복 실패 시 backend 가 제한(+ Retry-After). BFF 가 자격증명 401 분기보다 먼저 잡아 code·Retry-After 를 그대로 surface. 로그인 폼은 전용 안내(“잠시 후 다시 시도”, Retry-After 있으면 “약 N초 후”)로 분기 — 401 자격증명 오류와 구분. 과거 “5회 실패 시 15분 잠금” 안내 문구는 실제 동작이 아니며 제거됨(계정 하드 락아웃 아님 · 시도 빈도 기반 일시 제한).
  • 401 — “이메일 또는 비밀번호 불일치”. 403 정지/탈퇴, 400 validation 은 코드별 매핑.

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 14일 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