약관 게시 — /settings/policies
SPEC #023 정합 · 버전 이력·예약 게시·마크다운은 SPEC #033·#038 확장. 시각 시안 교체 완료 — 베이스 ops-settings(#031-C), 추가분 ops-policies-history.
Overview
운영사(OPERATOR)가 이용약관·개인정보처리방침을 백오피스 UI 에서 게시하는 화면.
/settings/policies 에서 현재 유효본 표시 + 새 버전 게시 + (#033) 버전 이력 조회·예약 게시·
마크다운 작성/미리보기를 한다. SPEC #128 이후 이 화면은 설정 7서브 탭 셸(18-7 약관 탭)으로
편입되어 (protected)/settings/layout.tsx 의 <SettingsTabs/> 아래에 렌더된다(화면·다이얼로그
동작 불변). 사이드바 “설정” 항목(/settings → 첫 탭 redirect)에서 약관 탭으로 진입한다.
영역 개요는 설정 7서브 참고.
시안 출처: handoff 시안
design/screens/ops-settings.jsx(§PolicySection · §PublishDialog — 활성본 카드 + 게시 2-step 베이스) +design/screens/ops-policies-history.jsx(§OpsPoliciesHistory 이력 테이블·MarkdownView 상세 · §PublishWithScheduleDialog 작성/미리보기 탭·발효 시각 즉시/예약 · §CancelScheduleDialog 예약 취소 2-step). 기존@linkmusic/uiatom + admin 관례(hq-detail #031-B)에 매핑 — status 배지(예약 info·현재 유효 success·과거 muted)·예약 게시 step2 info 톤·MarkdownView 타이포(h1 22/h2 18/h3 15px·링크 primary) 정합. 새 색/아이콘 추측 없음([[feedback_design_only_from_handoff]]).
Spec
라우트·데이터 (server component)
(protected)/settings/policies/page.tsx— 보호 라우트 ((protected) layout me 가드 + page 의 refresh-aware 세션 체크). server component 가 활성본을 병렬 fetch:backendActiveTerms→GET /api/v1/terms/activebackendActivePrivacy→GET /api/v1/privacy-policy/active
- 활성 조회는 backend 에서 public(인증 불요)이라 토큰 없이 호출한다. settings 라우트 자체의 보호는 layout me 가드가 담당한다.
- 결과는 문서별
PolicyState로 client 에 prop 전달.
미게시 처리 (404 ≠ 에러)
활성 조회 endpoint(#033 의미변경 — “현재 유효본” = effective_at <= now 중 최신)는
현재 유효본 없음 시 404 를 던진다(TERMS_NOT_PUBLISHED·PRIVACY_POLICY_NOT_PUBLISHED).
이는 에러가 아니라 정상적인 “미게시” 상태다.
| 상태 | 분기 | UI |
|---|---|---|
| 유효본 존재 | kind: "ok" | 현재 유효본 — 활성본 DescCard(버전 success pill · 게시일 KST) + 내용 미리보기(<MarkdownView>) 2컬럼 |
| 404 미게시 | kind: "not-published" | 점선 박스 빈 상태 (“아직 게시된 …이 없습니다”) + [첫 버전 게시] |
| 5xx / 네트워크 | kind: "error" | 섹션 내 danger Banner (다른 섹션과 격리) |
한 문서 fetch 실패가 다른 문서 섹션 표시를 막지 않는다.
페이지 구성 (2섹션)
<PageHeader>제목 “설정 · 약관 / 개인정보” · 부제 “현재 유효본은 각 1개(발효 시각 기준) · 즉시/예약 게시 · 이전 버전 영구 보관”.- 본문은
max-w-[880px]중앙 정렬. 섹션 2개: 이용약관(FileText아이콘 칩) · 개인정보처리방침(ShieldCheck아이콘 칩). 각 섹션:- 헤더: 아이콘 칩(
--primary-soft/--primary) + 타이틀 + 활성 요약 + [이력 보기] ghost 버튼(History아이콘) + [새 버전 게시] primary 버튼. - 활성본 (
ok): 좌측 활성본 DescCard(버전StatusPill tone="success" dot· 게시일formatKstDateTime, Asia/Seoul 고정) + 우측 내용 미리보기(<MarkdownView>렌더,max-h-[180px]스크롤). 본문이 markdown 이므로 plainwhitespace-pre-wrap→ 마크다운 렌더로 교체(#033). - 미게시 (
not-published): 점선 박스 빈상태 + [첫 버전 게시].
- 헤더: 아이콘 칩(
- 게시일은
formatKstDateTime(@/lib/format, Asia/Seoul 고정) — 로컬 TZ 의존 미사용.
마크다운 렌더 (components/markdown-view.tsx, #033)
본문은 BE 에 markdown plain string 으로 저장(BE 무변경)되고 FE 가 렌더한다.
react-markdown+remark-gfm(표·리스트·링크·취소선) +rehype-sanitize(XSS allowlist).- sanitize 는 GitHub 기본 스키마(
defaultSchema) 기반 —script·iframe·on* 이벤트 핸들러· 위험 URL scheme(javascript:등)을 제거하고, 제목/문단/리스트/표/링크/코드/blockquote 만 허용. 운영자(신뢰 경계 내부) 입력이라도 저장 데이터가 그대로 렌더되므로 sanitize 를 필수 적용. - 활성본 미리보기·이력 상세·게시 다이얼로그 미리보기 탭에서 공용 사용.
버전 이력 다이얼로그 (policy-history-dialog.tsx, #033)
각 섹션 [이력 보기] → 약관/개인정보를 kind prop 으로 구분해 재사용하는 다이얼로그.
- 목록:
useListTermsHistory/useListPrivacyPolicyHistory(예약본 포함, effectiveAt DESC, 본문 제외). 각 행 =version· 발효 시각(KST) · 게시 시각(KST) + status 배지(StatusPill:SCHEDULED=info ·ACTIVE=success ·SUPERSEDED=muted). - 상세: 행 클릭 →
useGetTermsVersion/useGetPrivacyPolicyVersion(by-id lazy —enabled가 열림+선택 시에만 fetch) → 본문을<MarkdownView>로 렌더. [목록]으로 복귀. - 예약 취소 (SPEC #038): 상세 view 가
status === SCHEDULED일 때만 [예약 취소] danger 버튼 노출 (ACTIVE·SUPERSEDED 에는 미노출 — 영구 보존 불변식). 클릭 → 2-step 확인(#029 파괴적 액션 관용구: “이 예약본을 취소하면 발효되지 않습니다” danger Banner + danger 확인 버튼). 확인 →kind에 따라useCancelTermsSchedule/useCancelPrivacyPolicySchedule(pathParamid, DELETE 204) 호출 → 성공 시 history query invalidate + 상세 닫고 목록 view 복귀. 실패는 인라인 danger Banner 로 매핑:- 409
LEGAL_DOC_NOT_SCHEDULED→ “이미 발효되었거나 대체되어 취소할 수 없습니다.” (목록 stale race) - 404
LEGAL_DOC_NOT_FOUND→ “이미 삭제된 예약본입니다.” (다른 운영자가 먼저 취소) - 그 외 권한/일시 장애 fallback.
- 409
- 닫힌 상태에서는 다이얼로그를 마운트하지 않아 query 가 실행되지 않는다.
게시 다이얼로그 (policy-publish-dialog.tsx)
2-step (시안 ops-settings §PublishDialog) — 제목·부제가 단계별로 전환된다.
- 입력 단계:
Field(label + hint) 위에versionmono input(max 32) · 발효 시각datetime-localinput ·content작성/미리보기 탭(Tabs/Tab,role=tab/tabpanel·aria-selected) — 작성=textarea(max 200000), 미리보기=<MarkdownView>렌더(#033). - 발효 시각(effectiveAt) 입력 (#033):
datetime-local은 KST 벽시계 문자열(YYYY-MM-DDTHH:mm)을 받고, 제출 시kstLocalInputToUtcIso(KST=UTC+9 고정 오프셋)로 UTC ISO 로 변환해 보낸다. 직접toLocaleString·new Date(local)(머신 TZ 의존) 미사용 — SSR/CSR·머신 TZ 무관 결정적.- 빈 값이면 effectiveAt 미전송 = 즉시 게시. 미래 시각이면 예약 게시. 안내: “비우면 즉시 게시, 미래 시각이면 예약”.
- 검증 (zod, frontend.md #8 — backend
LegalDocumentPublishRequest미러):version:maxLength 32→max(32)+ trimmin(1)(NotBlank 미러).content: trimmin(1)+max(200000).effectiveAtLocal: 빈 값 허용(즉시) 또는 현재 시각 이후(과거 예약 거부). backend 가 과거 (>60s)를 400LEGAL_DOC_INVALID_EFFECTIVE_AT로 최종 거부하므로 클라는 보수적 사전 검증.- 제출 payload 는 trim 된 값(공백-only 거부). 미통과 시 [다음] 비활성.
- 확인 단계 (필수): [다음] → danger Banner(되돌릴 수 없음 경고) + 게시 요약 dl(대상·새 버전·발효 시각[KST, 즉시면 “즉시 발효”]) + 명시적 게시 실행 확인. 제목·부제는 즉시/예약에 따라 전환(“즉시 발효됩니다” / “지정한 시각에 발효됩니다”). [뒤로] 로 복귀.
- mutation: terms 는
usePublishTerms, privacy 는usePublishPrivacyPolicy(SPEC #028 operationId 유니크화 후 정상화 — 이전usePublish/usePublish1대체). mutatorapiFetch가 BFF catch-all 경유 — 토큰 서버 전용.- terms →
/api/backend/api/v1/admin/terms - privacy →
/api/backend/api/v1/admin/privacy-policy
- terms →
- 성공 시
router.refresh()(server component 가 활성본 재fetch) + 다이얼로그 닫기. - pending 중 닫기·중복 제출 차단.
에러 매핑 (extractCode)
| code / status | 메시지 |
|---|---|
409 LEGAL_DOC_DUPLICATE_VERSION | ”이미 존재하는 버전입니다. 다른 버전 번호를 입력해주세요.” |
400 LEGAL_DOC_INVALID_EFFECTIVE_AT | ”발효 시각은 현재 이후여야 합니다.” (#033 — 과거 예약 거부) |
404 LEGAL_DOC_NOT_FOUND | ”문서를 찾을 수 없습니다.” (#033 — by-id 상세 미존재) |
409 LEGAL_DOC_ACTIVATION_CONFLICT | ”다른 게시 작업과 충돌했습니다. 잠시 후 다시 시도해주세요.” (동시 게시 race) |
| 403 / FORBIDDEN | ”이 작업을 수행할 권한이 없습니다.” |
| 5xx | ”서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.” |
| 네트워크 | ”서버에 연결할 수 없습니다.” |
실패 시 입력 단계로 되돌려 값 수정 가능.
BE 계약 (#033 확장)
- 게시
POST /api/v1/admin/terms·/privacy-policy(OPERATOR) —LegalDocumentPublishRequest{ version, content, effectiveAt? }→LegalDocumentResponse{ version, content, publishedAt, effectiveAt, status }. effectiveAt 미지정·현재=즉시(ACTIVE), 미래=예약(SCHEDULED), 과거=400. - 활성 조회
GET /api/v1/terms/active·/privacy-policy/active(public) → 현재 유효본LegalDocumentResponse(effective_at <= now중 최신,status는ACTIVE보정). - 이력
GET /api/v1/admin/{terms,privacy-policy}(OPERATOR) →LegalDocumentHistoryResponse{ items[] }(본문 제외). - 단건
GET /api/v1/admin/{terms,privacy-policy}/{id}(OPERATOR) →LegalDocumentResponse(본문 포함) · 404LEGAL_DOC_NOT_FOUND. - 예약 취소
DELETE /api/v1/admin/{terms,privacy-policy}/{id}(OPERATOR, #038) → 204 No Content. 훅:useCancelTermsSchedule·useCancelPrivacyPolicySchedule(pathParamid). SCHEDULED·미발효 (effective_at > now)일 때만 hard-delete · 409LEGAL_DOC_NOT_SCHEDULED· 404LEGAL_DOC_NOT_FOUND.
Not in scope (후속)
- 공개(온보딩) 약관 화면 마크다운 렌더 — admin 한정.
- 재동의 흐름(버전 변경 시 재동의 배너).
- 예약본 수정 UI — 취소 후 재게시로 대체(취소는 #038 에서 지원).
- 본문 버전 diff 비교 뷰.
References
- SPEC #023 · SPEC #033 · SPEC #038 (예약 취소)
apps/admin/src/app/(protected)/settings/policies/page.tsx·policies-client.tsx·policy-publish-dialog.tsx·policy-history-dialog.tsxapps/admin/src/components/markdown-view.tsxapps/admin/src/lib/format.ts(kstLocalInputToUtcIso·utcIsoToKstLocalInput·formatKstDateTime)apps/admin/src/lib/backend.ts(BackendLegalDocumentResponse·BackendLegalDocumentHistoryItem·BackendLegalDocumentHistoryResponse·BackendLegalDocumentResponseStatus·BackendLegalDocumentPublishRequest)- 사이드바 진입점:
features/design-system/shells“설정” 항목