fix(page): 미발행 페이지를 권한 관리자가 사용자 화면에서 미리보도록 수정

미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없어
발행 전 미리보기가 불가능했다. 페이지 조회 권한(sirsoft-page.pages.read)
보유 관리자는 미발행 페이지를 사용자 화면에서 미리볼 수 있게 하고, 미리보기
화면에 안내 배너를 노출한다. 비로그인·일반 회원은 기존대로 404를 유지한다.

- getPublishedPageBySlug 에 미리보기 허용 플래그 추가 (권한 판정은 컨트롤러)
- 미발행 미리보기 시 첨부 URL 을 admin 컨텍스트로 전환해 첨부까지 미리보기
- page/show.json 에 미발행 미리보기 안내 배너 + ko/en/ja 다국어
- E2E 시드 커맨드 구현 + 커맨드 등록 + 사용자 화면 미리보기 spec
This commit is contained in:
chym1217
2026-07-06 09:37:05 +09:00
parent 8cf55ad88f
commit ba2adcf748
22 changed files with 626 additions and 54 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-01
### Added
- 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때, 미발행 상태임을 알리는 안내 표시를 추가했습니다.
## [1.0.0] - 2026-07-01
### Added
@@ -0,0 +1,79 @@
/**
* @file page-show-preview-banner.test.tsx
* @description 미발행 페이지 관리자 미리보기 배너 (이슈 #424-15)
*
* 정책: 페이지 조회 권한 관리자가 사용자 화면에서 미발행 페이지를 미리볼 때,
* 상단에 "미발행 페이지 미리보기 중" 안내 배너를 노출한다. 백엔드(PublicPageResource)가
* 미발행+권한자 응답에만 is_preview=true 를 내려주므로, 배너의 if 는 그 값에 의존한다.
* 발행 페이지·게스트 응답은 is_preview=false → 배너 미노출.
*
* 검증 방식: 실제 page/show.json 레이아웃 트리 직접 분석 (DOM 비의존).
* - 배너 블록이 존재하고 if 가 page.data.is_preview 에 의존하는지 고정
* - 배너 문구가 다국어 키($t:user.page.preview_banner)로 렌더되는지 고정
*/
import { describe, it, expect } from 'vitest';
import pageShow from '../../layouts/page/show.json';
type Node = Record<string, unknown> & { children?: unknown };
/**
* 트리를 재귀 순회하며 조건을 만족하는 모든 노드를 수집한다.
*/
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
/**
* id 로 노드를 찾는다.
*/
function findById(root: unknown, id: string): Node | undefined {
return collectNodes(root, (n) => n.id === id)[0];
}
describe('이슈 #424-15 — 미발행 페이지 미리보기 배너 (page/show.json)', () => {
it('미리보기 배너 블록(page_preview_banner)이 존재한다', () => {
const banner = findById(pageShow, 'page_preview_banner');
expect(banner).toBeDefined();
});
it('배너의 if 가 page.data.is_preview 에 의존한다 (발행/게스트는 미노출)', () => {
const banner = findById(pageShow, 'page_preview_banner');
expect(typeof banner?.if).toBe('string');
expect(banner?.if as string).toContain('is_preview');
// 단일 바인딩 형태 {{...}} 여야 엔진이 식으로 평가 (CLAUDE.md if 규칙)
expect((banner?.if as string).trim()).toMatch(/^\{\{.*\}\}$/);
});
it('배너 문구가 다국어 키로 렌더된다 (하드코딩 금지)', () => {
const banner = findById(pageShow, 'page_preview_banner');
const spans = collectNodes(banner, (n) => n.name === 'Span' && typeof n.text === 'string');
const texts = spans.map((s) => s.text as string);
expect(texts).toContain('$t:user.page.preview_banner');
});
it('배너가 본문 카드(page_content_card) 내부, 제목 영역보다 앞에 위치한다', () => {
const card = findById(pageShow, 'page_content_card');
expect(card).toBeDefined();
const children = card?.children as Node[];
expect(Array.isArray(children)).toBe(true);
// 첫 자식이 미리보기 배너여야 함 (제목/발행일보다 위)
expect(children[0]?.id).toBe('page_preview_banner');
});
});
@@ -51,7 +51,8 @@
"published_at": "Published",
"create_page": "Create Page",
"attachments": "Attachments",
"download": "Download"
"download": "Download",
"preview_banner": "Previewing an unpublished page"
},
"mypage": {
"page_title": "My Page",
@@ -51,7 +51,8 @@
"published_at": "발행일",
"create_page": "페이지 생성",
"attachments": "첨부파일",
"download": "다운로드"
"download": "다운로드",
"preview_banner": "미발행 페이지 미리보기 중입니다"
},
"mypage": {
"page_title": "마이페이지",
@@ -80,6 +80,30 @@
}
},
"children": [
{
"id": "page_preview_banner",
"type": "basic",
"name": "Div",
"if": "{{page?.data?.is_preview}}",
"props": {
"className": "flex items-center gap-2 mb-4 px-4 py-3 rounded-md bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400 text-sm"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "eye",
"className": "text-amber-600 dark:text-amber-400 text-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:user.page.preview_banner"
}
]
},
{
"type": "basic",
"name": "Div",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",