Operator Management — /users (운영자 계정 관리)
SPEC #032 정합. 직전까지 ComingSoon placeholder(#030)였다.
시안 출처: 배치6 핸드오프
design_handoff_linkmusic/design/screens/ops-users.jsx(Phase 4 · 8C-①). 공용 목록 툴바(<ListToolbar>·<ListPagination>·<ListNoResults>,@linkmusic/ui, 시안ops-list-toolbar)와 기존 인벤토리 atom(PageHeader·StatusPill·OrgAvatar·Banner· 2-step 확인 다이얼로그)을 조합한다. (이전 베타의 “임시 레이아웃”은 본 PR에서 시안대로 교체 완료.)
Overview
운영사(OPERATOR)가 자신과 동료 운영자 계정을 관리하는 백오피스 화면. 운영자 초대(생성)·
비밀번호 재설정·정지/복구/회수(soft-delete)를 제공한다. 사이드바 “계정”(/users) 항목이
연결된다.
점장(STORE_MANAGER, #021·#029)과 동일한 AccountStatus 라이프사이클(ACTIVE ↔ SUSPENDED,
→ WITHDRAWN terminal)을 따르되, 본인 계정 보호(self-guard)와 마지막 활성 운영자
보호가 추가된다.
Spec
UI
<PageHeader>제목 “운영자 계정” + 부제(로딩 중 ”…”, 이후 운영 계정 안내 문구) + 우측 [운영자 초대] primary 버튼.- 공용 툴바
<ListToolbar>(#043 · 시안ops-list-toolbar): 검색 input(이메일·이름 부분일치,maxLength 100,aria-label) · 상태 select(전체/활성/정지/회수됨 — generatedListOperatorsStatus단일 소스) · [적용] · [초기화]. 입력은 draft 로 두고 [적용]/[초기화]·페이지 이동 때만 query 에 반영. draft↔applied 가 다르면 [적용] primary 강조 + “미적용 변경” 힌트(dirty). - 목록 테이블 6컬럼(ops-users 시안 순서) — 이메일(
<OrgAvatar>+ mono email + isSelf 면 “나” 배지) / 이름(null →—) / 상태(StatusPill 배지 +passwordMustChangetrue 면 옆에첫 변경 대기warn 배지) / 마지막 로그인(KST, null →—) / 가입(KST) / 관리. 본인 행은 primary-soft 배경, WITHDRAWN 행은 흐림(opacity). - 공용 페이지네이션 footer
<ListPagination>(#043):n / N 페이지 · 총 M건+ [이전]/[다음] (1페이지 [이전] disabled, 마지막 페이지 [다음] disabled). 0건이면 “총 0명” summary.total·size(20)로 totalPages 계산. - 정렬은 서버 고정(createdAt asc → id asc). 검색·상태필터·페이지네이션은 #043에서 추가 (이전 베타 단계는 전체 목록·필터 없음).
- 빈 상태 구분: 필터 없이 0건 → “등록된 운영자가 없습니다”(empty). 필터 적용 결과 0건 → “조건에 맞는 운영자가 없습니다” + 필터 초기화.
OperatorStatus 3종 (점장 #029 톤 미러)
| status | badge | dot | 의미 |
|---|---|---|---|
ACTIVE | success | ● | 정상 (로그인 가능) |
SUSPENDED | danger | ● | 정지 (로그인 차단) |
WITHDRAWN | muted | — | 회수됨 (terminal) |
isSelf 행 (self-guard)
isSelf === true인 행은 primary-soft 배경 + 이메일 옆 “나” 배지.- 관리 열은 [관리] 버튼 대신 “본인 계정 — 관리 불가” 안내(ops-users 시안 self-guard).
- backend 도 self-action 을 403
OPERATOR_SELF_ACTION_FORBIDDEN으로 막지만 FE 가 사전 가드한다. WITHDRAWN(terminal) 행은 흐림 처리 + 관리 열에 “회수됨” 안내([관리] 버튼 없음).
Implementation
데이터 로드 (client query)
- 목록은 client(
OperatorListClient)가 generateduseListOperators(params)(react-query)로 fetch — 보호 endpoint 라 mutator(apiFetch)가 BFF catch-all/api/backend/...경유, 토큰은 서버 전용. - 검색·필터·페이지 상태는 client state(#043 — D7):
q·status·page(0-base)·size(20)를useMemo로 묶어useListOperators(params)에 전달. 감사(#034)는 URL-driven 이지만, 이 화면은 mutation invalidate 의존이라 client-query 가 정합 — URL-driven 전면 리팩터는 지양. - mutation(초대/관리) 성공 시 현재 필터 params 로
getListOperatorsQueryKey(params)를 invalidate 해 목록을 즉시 갱신(점장 관리 #029 와 동일하게 server component 대신 client query 패턴 — 갱신 즉시성 때문). params 인자 없이 invalidate 하면 active query 와 키가 어긋나 필터 적용 상태에서 refetch 가 안 된다. page.tsx는OperatorListClient만 렌더(인증은 (protected)/layout 보장).- 응답·파라미터 타입은 generated
OperatorListResponse/OperatorListItem/ListOperatorsParams(@linkmusic/api-client) 단일 소스 —lib/backend.ts가BackendOperator*·BackendListOperatorsParamsalias 로 재노출(수기 중복 정의 없음).backendListOperators(token, params?)는 서버측(감사 행위자 select)에서{ size: 100 }으로 전체를 확보한다(#043 — D5, 베타 운영자<100가정).
운영자 초대 (OperatorIssueDialog)
- 트리거: 헤더 [운영자 초대] →
operator-issue-dialog.tsx오픈. - 폼: email(로그인 ID) · 담당자 이름 · 임시 비밀번호(
type="password"+autoComplete="new-password", mono, 8~100자). 모두 필수*. - 사전 검증(frontend.md §8): zod — tempPassword min 8 / max 100(OpenAPI 길이 제약), email 형식 ·
name 필수(trim 후 min 1, max 255)는 backend
@Email/@NotBlank런타임 거부 미러. 미통과 시 [초대] 비활성. - 제출:
useIssueOperator→POST /api/v1/admin/operators(OperatorIssueRequest→OperatorIssueResponse { id }, 201, passwordMustChange=true). 성공 시 목록 invalidate. pending 중 닫기·중복 제출 차단. - 계정 전달 안내문 복사 (#120): 성공 시 즉시 닫지 않고 안내문 복사 단계로 전환한다. 점장 발급과 동일한
AccountShareSection을 재사용 — 입력했던 email·임시 비밀번호 + 로그인 안내(운영자는 이 백오피스 admin 으로 로그인하므로 발급 origin = 로그인 origin,${window.location.origin}/login절대 URL) + “첫 로그인 시 비밀번호 변경” 안내를 한 묶음 평문으로 조합해 미리보기 + [안내문 복사] 버튼을 띄운다(navigator.clipboard.writeText, https 전제, 실패 시 toast + 평문 직접 복사 폴백). 임시 비밀번호 재노출·저장·로깅 0. 운영자는 [완료]로 닫는다.
계정별 액션 (OperatorManageDialog — 상태머신 가드)
[관리] → operator-manage-dialog.tsx 가 단건 대상(target)의 가능한 액션만 노출:
| 상태 | 노출 액션 |
|---|---|
| ACTIVE | 비밀번호 재설정 · 정지 · 회수 |
| SUSPENDED | 복구 · 회수 |
| WITHDRAWN | 없음 (진입점 자체 비활성) |
- 비밀번호 재설정 (ACTIVE 만): 임시 비밀번호 입력(zod min 8 / max 100).
useResetOperatorPassword→POST .../{operatorId}/reset-password. 성공 시 닫지 않고 안내 노출(대상 강제 로그아웃 안내). passwordMustChange=true. - 정지 (ACTIVE 만): 사유 textarea(max 255, trim 후 필수) + 2-step 확인(danger) + 즉시 로그아웃 경고.
useSuspendOperator→POST .../{operatorId}/suspend({ reason }). ACTIVE→SUSPENDED. - 복구 (SUSPENDED 만): 사유 없는 단순 확인.
useReactivateOperator→POST .../{operatorId}/reactivate(body 없음). SUSPENDED→ACTIVE. - 회수 (ACTIVE·SUSPENDED): 2-step “되돌릴 수 없음” 확인(danger, terminal 경고) + 회수 사유(UI 확인용).
useRevokeOperator→DELETE .../{operatorId}. → WITHDRAWN(terminal). 같은 이메일 재사용 불가. - 정지·재설정·회수는 대상 운영자의 활성 세션을 즉시 무효화(revoke) 한다(backend).
- mutation 성공 시 부모가 목록 invalidate.
에러 매핑 (backend ErrorResponse code → 메시지)
| code / status | 메시지 |
|---|---|
409 DUPLICATE_EMAIL (초대) | 이미 등록된 이메일입니다. 다른 이메일을 입력해주세요. |
409 ACCOUNT_INVALID_STATUS_TRANSITION | 현재 상태에서는 할 수 없는 작업입니다. 목록을 새로고침해주세요. |
403 OPERATOR_SELF_ACTION_FORBIDDEN | 본인 계정은 여기서 관리할 수 없습니다. |
403 OPERATOR_LAST_ACTIVE_FORBIDDEN | 마지막 활성 운영자는 정지·회수할 수 없습니다. |
404 OPERATOR_NOT_FOUND | 해당 운영자 계정을 찾을 수 없습니다. 목록을 새로고침해주세요. |
| 403 (권한) | 이 작업을 수행할 권한이 없습니다. |
| 5xx | 서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요. |
| 그 외 / 네트워크 | 작업/초대에 실패했습니다 / 서버에 연결할 수 없습니다. |
- 매핑 외 code 는 status 기반 일반 메시지로 fallback. error 객체는 mutator
ApiError(status·body), code 추출은extractCode(표준 envelope{ success:false, error:{ code } }).
Audit
운영자 계정 라이프사이클 액션은 공통 감사 로그(/audit/actions, #026)에 기록된다 —
OperatorAuditAction 에 OPERATOR_ISSUED · OPERATOR_PASSWORD_RESET · OPERATOR_SUSPENDED ·
OPERATOR_REACTIVATED · OPERATOR_REVOKED 5종이 추가되고, 대상 유형(targetType)에 OPERATOR
가 추가된다. 감사 화면의 액션 라벨/톤·대상 라벨 맵은 이 신규 enum 값을 포함하도록 갱신됐다.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 목록 로딩 | ”운영자 목록을 불러오는 중…” |
| 운영자 0명 | ”등록된 운영자가 없습니다” 빈 상태 + CTA 안내 |
401 AUTH_UNAUTHENTICATED | mutator 가 /login 풀 네비게이션(전역) |
| 5xx / 네트워크 / 403 | 인라인 danger Banner (세션 유지) |
| name / lastLoginAt null | 해당 셀 — (formatKstDateTime/formatKstDate 가 — 반환) |
| isSelf 행 | ”나” 배지 + 관리 비활성(툴팁) |
| WITHDRAWN 행 | 관리 비활성 (terminal) |
| 처리 중 다이얼로그 닫기 시도 | 차단 (중복 제출/race 회피) |
References
- SPEC #032 · SPEC #029 (STORE_MANAGER 관리 — 미러 원본) · SPEC #021 (계정 발급 관용구) · SPEC #026 (감사 로그)
linkmusic-frontend-space/apps/admin/src/app/(protected)/users/(page.tsx·operator-list-client.tsx·operator-issue-dialog.tsx·operator-manage-dialog.tsx)- KST 포맷:
apps/admin/src/lib/format.ts(formatKstDateTime/formatKstDate)