HQ Onboarding — /hq/new (4-step stepper)
SPEC #010 정합 · SPEC #147 약관 동의 시점 이전. v0.3.0 도입. handoff 10-surface §3 Page 3.
Overview
운영사 (OPERATOR) 가 신규 FRANCHISE 본사를 등록. backend POST /api/v1/admin/hq 단일 transaction 을
4-step 클라이언트 stepper 로 감싼다.
SPEC #147: 약관 동의 step 이 발급 마법사에서 제거됐다(5-step → 4-step). 약관 동의는 본사 관리자 본인이 계정 설정(첫 로그인) 시 진행한다(setup 페이지 —
features/auth/account-emails). 발급 request 에서consent가 빠졌고, 발급 page 의 활성 약관 server fetch·미게시 차단 Banner 도 제거됐다.
Spec
Step 구조
| Step | 내용 |
|---|---|
| 1. 기본 정보 | 본사명 · 사업자번호(SPEC #013) · 플랜(AI/TRUST) · 결제 anchor day · LLM keywords / max per hour |
| 2. 계약 · 결제 | plan/billingAnchorDay (step1 과 동일 state 참조 — IA 분리) |
| 3. 계정 발급 | manager.email · tempPassword · name |
| 4. 확인 | 요약 + “약관 동의는 본사 관리자 본인이 계정 설정 시 진행” 안내 + [등록] button → 단일 POST |
Stepper 제출 전략
4 step 모두 클라이언트 state (useReducer) 로 수집 → 마지막 step 클릭 → POST 한 번. 새로고침 / 탭 닫기 → state 소실 (의도적 — 베타 단순). sessionStorage 보존은 후속 chore.
State shape
type OnboardingState = {
step: 0 | 1 | 2 | 3;
hq: {
name: string;
businessNumber?: string; // SPEC #013 신설
plan: "AI" | "TRUST";
billingAnchorDay: number;
llmKeywords: string;
llmMaxPerHour: number;
};
manager: { email: string; tempPassword: string; name: string };
// SPEC #147 — consent state 제거. 동의는 계정 설정(setup) 단계로 이전.
};Implementation
진입 페이지 (server component)
apps/admin/src/app/(protected)/hq/new/page.tsx — SPEC #147 이후 약관 server fetch 가 없으므로 단순히
stepper 만 렌더한다(발급은 약관과 무관).
const HqNewPage = () => <HqOnboardingStepper />;제출
react-query useMutation 이 아니라 catch-all BFF proxy (/api/backend/...) 로 직접 fetch 한다.
이 proxy 는 backend response 를 status·body 그대로 passthrough 하므로 status·code 로 직접 분기·매핑한다
(apps/admin/src/app/(protected)/hq/new/hq-onboarding-stepper.tsx):
const handleSubmit = async () => {
if (pending) return;
setError(null);
setPending(true);
try {
const body = buildHqOnboardingRequest(state); // SPEC #147 — consent 미포함
const res = await fetch("/api/backend/api/v1/admin/hq", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "same-origin",
});
if (!res.ok) {
const payload: unknown = await res.json().catch(() => null);
const mapping = mapSubmitError(res.status, extractCode(payload));
if (mapping.redirectLogin) {
router.replace("/login");
return;
}
setError(mapping.message);
if (mapping.jumpToStep !== undefined) {
dispatch({ type: "SET_STEP", step: mapping.jumpToStep });
setErroredSteps([mapping.jumpToStep]);
}
return;
}
const payload = (await res.json().catch(() => null)) as
| { hqId: string; managerAccountId: string }
| null;
toast.show({
title: `본사 '${state.hq.name}'가 등록되었습니다`,
tone: "success",
});
// SPEC #155 H8 — 홈으로 즉시 이탈하지 않고 자격증명 전달 단계로 전환.
setIssued({
email: state.manager.email,
tempPassword: state.manager.tempPassword,
managerAccountId: payload?.managerAccountId ?? "",
hqId: payload?.hqId ?? "",
});
} catch {
setError("서버에 연결할 수 없습니다.");
} finally {
setPending(false);
}
};자격증명 전달 (SPEC #155 H8)
발급 성공 시 홈(/)으로 즉시 router.replace 하지 않고 자격증명 전달 단계로 전환한다
(issued state). 점장(store-manager-issue-dialog)·운영자(operator-issue-dialog) 발급과
동일한 AccountShareSection 을 재사용한다:
- [설정 이메일 보내기] — 응답
managerAccountId로useResendAccountSetup호출(권장 경로). - 임시 비밀번호 안내문 복사 — 메일 미수신 대비 오프라인 폴백(입력값 스냅샷, 재노출·저장 없음).
- [본사 상세 보기] — 응답
hqId로/hq/:id이동. [완료 · 목록으로] —/이동.
본사 관리자는 매장 클라이언트(space app, 별도 origin)로 로그인하므로 안내문에는 경로(/login)만
표기한다. 메일은 등록과 동시에 자동 발송되지 않으며, 운영자가 이 화면에서 설정 메일을 보내거나
안내문을 복사해 수동 전달한다(step 3/4 문구도 이에 맞춰 정정됨).
임시 비밀번호 분실·재발급은 이제 실재하는 경로를 안내한다(SPEC #158) — 본사 상세 > 계정 탭 >
[관리] > [비밀번호 재설정](새 임시 비밀번호 발행) 또는 [설정메일 재발송](설정 링크 재전송).
step 3(step3-manager) 안내 문구도 이 경로를 가리키도록 정정됐다(이전에는 존재하지 않는 액션을
가리키던 stale 문구).
에러 매핑(mapSubmitError) — DUPLICATE_EMAIL(409, 계정 발급 step 으로 이동) ·
VALIDATION_FAILED/VALIDATION_ERROR(generic) · AUTH_ACCOUNT_SUSPENDED/AUTH_ACCOUNT_WITHDRAWN
(/login) · BACKEND_UNREACHABLE(연결 실패) · status >= 500(서버 일시 장애 fallback). SPEC #147 로
발급 request 에 약관이 없으므로 INVALID_LEGAL_VERSION 매핑은 제거됐다(약관 검증은 setup 단계).
진행 인디케이터는 admin 셸의 OpsStepper (@/components/shell/ops-stepper) 를 쓴다 —
steps · current · completed · errored props (@linkmusic/ui 컴포넌트 아님).
이탈 경로 (SPEC #164 C4)
마법사 footer 왼쪽에 [목록으로](onboarding-cancel) 이탈 액션이 있어 진행 중에도 본사
목록(/hq)으로 빠져나갈 수 있다(기존엔 [이전]/[다음]뿐이라 브라우저 뒤로/URL 수정 외 이탈
불가). 입력이 있는 단계(isOnboardingDirty — hq·manager 가 초기값에서 하나라도 달라짐)에서는
OnboardingLeaveConfirmDialog(공용 apps/admin/src/app/(protected)/onboarding-leave-confirm-dialog.tsx)
로 “작성 중인 내용이 사라집니다” 확인을 먼저 받고, [나가기] 시 router.push("/hq") 한다.
step0 빈 입력에서는 확인 없이 곧장 이동한다. 발급 성공 후 자격증명 전달 화면(AccountShareSection)은
이미 [완료 · 목록]/[상세]가 있으므로 이 이탈 액션 대상이 아니다(진행 중 단계 한정).
Endpoint
POST /api/v1/admin/hq (OPERATOR-only):
- request:
{ hq: {...}, manager: {...} }(SPEC #147 — consent 제거) - response 200:
{ hqId, managerAccountId } - 단일 transaction:
- Hq INSERT (type=FRANCHISE, businessNumber 검증)
- OperatorAccount INSERT (role=HQ_MANAGER, passwordHash=BCrypt,
passwordMustChange=true)
- 약관 consent INSERT 는 발급이 아니라 본사 관리자 본인의 계정 설정(
setup/complete) 트랜잭션에서 수행된다(SPEC #147).
Validation
zod schema (apps/admin/src/app/(protected)/hq/new/use-onboarding-state.ts):
- name: required, 1~100 chars
- businessNumber:
^[0-9]{3}-[0-9]{2}-[0-9]{5}$(SPEC #013), optional - plan: enum AI/TRUST
- billingAnchorDay: 1~31
- llmMaxPerHour: 1~12
- email: 이메일 형식 (backend 와 일치)
- tempPassword: 8자 이상
States & Edge Cases
| 상태 | 처리 |
|---|---|
| step navigation: 미입력 필드 | 다음 step button disabled |
| [목록으로] · 입력 있음 (SPEC #164 C4) | 확인 다이얼로그 → [나가기] 시 /hq 이동 |
| [목록으로] · step0 빈 입력 | 확인 없이 곧장 /hq 이동 |
| email 중복 (409) | “이미 사용 중인 이메일입니다” — 계정 발급 step inline |
| 5xx | ”잠시 후 다시 시도” |
| 새로고침 | state 소실 (의도) |
| backend 단일 transaction 실패 | 부분 INSERT 안 됨 (transaction rollback 보장) |
활성 약관 부재·약관 version 검증은 발급이 아니라 계정 설정(setup) 단계에서 다룬다(SPEC #147).
Constraints
- 단일 POST — 부분 저장 안 함 (transaction)
- 약관 동의는 발급이 아니라 본사 관리자 본인의 계정 설정(setup) 단계에서 수집 (SPEC #147)
- HQ_MANAGER 계정의
passwordMustChange=true— 첫 로그인 시 강제 변경 (#007) - handoff 시안 부재 — stepper UI 는 영역 분할 + label 만, 시각 디자인 후속 SPEC
Roadmap
- step 3 계약 · 결제 — backend column 추가 후 폼 활성화
- CSV 매장 일괄 등록 — 후속
- 등록과 동시 자동 메일 발송 — 현재는 수동 전달(설정 메일 버튼 + 안내문 복사, SPEC #155). 자동 발송은 후속
- sessionStorage 임시 저장 — 후속 chore
- 본사 상세 (
/hq/:id) — backend GET 추가 후
References
- SPEC #010 · #013 · #147 · #155
- handoff
10-surface-ops-backoffice.md§3 (PRD Page 3) linkmusic-frontend-space/apps/admin/src/app/(protected)/hq/new/linkmusic-msa-space-was/.../api/admin/HqController.kt