FeaturesOperators (운영자 /users)Operator Management (/users)

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(전체/활성/정지/회수됨 — generated ListOperatorsStatus 단일 소스) · [적용] · [초기화]. 입력은 draft 로 두고 [적용]/[초기화]·페이지 이동 때만 query 에 반영. draft↔applied 가 다르면 [적용] primary 강조 + “미적용 변경” 힌트(dirty).
  • 목록 테이블 6컬럼(ops-users 시안 순서) — 이메일(<OrgAvatar> + mono email + isSelf 면 “나” 배지) / 이름(null → ) / 상태(StatusPill 배지 + passwordMustChange true 면 옆에 첫 변경 대기 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 톤 미러)

statusbadgedot의미
ACTIVEsuccess정상 (로그인 가능)
SUSPENDEDdanger정지 (로그인 차단)
WITHDRAWNmuted회수됨 (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)가 generated useListOperators(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(초대/관리) 성공 시 현재 필터 paramsgetListOperatorsQueryKey(params) 를 invalidate 해 목록을 즉시 갱신(점장 관리 #029 와 동일하게 server component 대신 client query 패턴 — 갱신 즉시성 때문). params 인자 없이 invalidate 하면 active query 와 키가 어긋나 필터 적용 상태에서 refetch 가 안 된다.
  • page.tsxOperatorListClient 만 렌더(인증은 (protected)/layout 보장).
  • 응답·파라미터 타입은 generated OperatorListResponse/OperatorListItem/ListOperatorsParams (@linkmusic/api-client) 단일 소스 — lib/backend.tsBackendOperator*·BackendListOperatorsParams alias 로 재노출(수기 중복 정의 없음). 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 런타임 거부 미러. 미통과 시 [초대] 비활성.
  • 제출: useIssueOperatorPOST /api/v1/admin/operators (OperatorIssueRequestOperatorIssueResponse { 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). useResetOperatorPasswordPOST .../{operatorId}/reset-password. 성공 시 닫지 않고 안내 노출(대상 강제 로그아웃 안내). passwordMustChange=true.
  • 정지 (ACTIVE 만): 사유 textarea(max 255, trim 후 필수) + 2-step 확인(danger) + 즉시 로그아웃 경고. useSuspendOperatorPOST .../{operatorId}/suspend ({ reason }). ACTIVE→SUSPENDED.
  • 복구 (SUSPENDED 만): 사유 없는 단순 확인. useReactivateOperatorPOST .../{operatorId}/reactivate (body 없음). SUSPENDED→ACTIVE.
  • 회수 (ACTIVE·SUSPENDED): 2-step “되돌릴 수 없음” 확인(danger, terminal 경고) + 회수 사유(UI 확인용). useRevokeOperatorDELETE .../{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)에 기록된다 — OperatorAuditActionOPERATOR_ISSUED · OPERATOR_PASSWORD_RESET · OPERATOR_SUSPENDED · OPERATOR_REACTIVATED · OPERATOR_REVOKED 5종이 추가되고, 대상 유형(targetType)에 OPERATOR 가 추가된다. 감사 화면의 액션 라벨/톤·대상 라벨 맵은 이 신규 enum 값을 포함하도록 갱신됐다.

States & Edge Cases

상태처리
목록 로딩”운영자 목록을 불러오는 중…”
운영자 0명”등록된 운영자가 없습니다” 빈 상태 + CTA 안내
401 AUTH_UNAUTHENTICATEDmutator 가 /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)