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) |
|---|---|
| OPERATOR | safeNext(nextPath) (기본 /) |
| HQ_MANAGER | redirect 없음. “본사 계정은 space.linkmusic.io 에서 로그인” 안내 + 절대 URL 링크(NEXT_PUBLIC_SPACE_ORIGIN) + POST /api/auth/logout(세션 destroy). 본사는 apps/space 단일 소스 (SPEC #150) |
| STORE_MANAGER | redirect 없음. “운영사 직원 전용” 안내 + 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 정합)
동작
- submit → POST
/api/auth/login(BFF) - BFF → POST
/api/v1/auth/login(backend) - 200 →
ironSession.save()+Set-Cookie lm_session - FE 가
AuthResponse.role로 분기 (OPERATOR→router.replace(safeNext(nextPath))/ OPERATOR 외→세션 destroy + 차단 안내, redirect 없음) - 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):
- body 파싱 + zod 검증
- backend POST
- 200 → session.save + 200 응답
- 401 → 그대로 surface
- 5xx → 502 BACKEND_ERROR (session 미저장)
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 로딩 | button spinner + form disabled |
| 401 INVALID_CREDENTIALS | ”이메일 또는 비밀번호가 일치하지 않습니다” Toast |
| 5xx | ”잠시 후 다시 시도해 주세요” Toast (BACKEND_ERROR) |
| 네트워크 실패 | ”네트워크 연결 확인” Toast (BACKEND_UNREACHABLE) |
| 빈 input | submit 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