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 | 목적지 |
|---|---|
| OPERATOR | safeNext(nextPath) (기본 /) |
| HQ_MANAGER | /admin (본사 모드 셸) |
| STORE_MANAGER | redirect 없음. “지원되지 않는 역할입니다” 안내 + POST /api/auth/logout(세션 destroy) — 앱 접근 차단 |
입력
- 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로 분기해router.replace(OPERATOR→safeNext(nextPath)/ HQ_MANAGER→/admin/ STORE_MANAGER→안내+logout) - 레이아웃 가드(
(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):
- 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 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