v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

@@ -4,7 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [0.1.0-beta.1] - 2026-05-11
## [0.1.0] - 2026-07-01
### Changed
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
- 플러그인 표준 베이스를 따르도록 내부 구조 정비. 동작 변화 없음.
- 코어 최소 요구 버전을 7.0.0-beta.8 로 상향.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
### Added
@@ -13,3 +20,4 @@
- Filter 훅 구독 패턴(`type: 'filter'`) 시연용 `FilterMemoTitleListener`
- 플러그인 자체 훅 발행 데모: `gnuboard7-hello_plugin.log.written`
- 최소 설정 UI (`log_enabled` 토글) + 다국어(ko/en)
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
@@ -2,7 +2,7 @@
"name": "plugins/gnuboard7-hello_plugin",
"description": "Learning minimal sample plugin for Gnuboard7 platform (consumes Hello module hooks)",
"type": "library",
"version": "0.1.0-beta.1",
"version": "0.1.0",
"autoload": {
"psr-4": {
"Plugins\\Gnuboard7\\HelloPlugin\\": ["src/", "./"]
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cb11c22109cd1b70301cfffe7f82b973",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -5,13 +5,13 @@
"ko": "Hello 플러그인",
"en": "Hello Plugin"
},
"version": "0.1.0-beta.1",
"version": "0.1.0",
"license": "MIT",
"description": {
"ko": "학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)",
"en": "Learning minimal sample plugin (consumes Hello module hooks)"
},
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0",
"hidden": true,
"dependencies": {
"modules": {
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "Settings"
}
},
"name": "Hello Plugin",
"description": "Learning minimal sample plugin (consumes Hello module hooks)",
"settings": {
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "설정"
}
},
"name": "Hello 플러그인",
"description": "학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)",
"settings": {
@@ -12,6 +12,7 @@
"data_sources": [
{
"id": "settings",
"label_key": "$t:gnuboard7-hello_plugin.editor.data_source.settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
@@ -27,11 +28,10 @@
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
"className": "admin-page-content-responsive"
},
"children": [
{
@@ -66,7 +66,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
"className": "flex-start gap-3"
},
"children": [
{
@@ -75,7 +75,7 @@
"props": {
"type": "checkbox",
"name": "log_enabled",
"className": "mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
"className": "mt-1 h-4 w-4 rounded text-blue-600"
}
},
{
@@ -86,7 +86,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "form-label"
},
"text": "$t:gnuboard7-hello_plugin.settings.fields.log_enabled.label"
},
@@ -94,7 +94,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:gnuboard7-hello_plugin.settings.fields.log_enabled.hint"
}
@@ -111,7 +111,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "sticky-footer-buttons"
},
"children": [
{
@@ -120,7 +120,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "btn btn-secondary"
},
"actions": [
{
@@ -146,7 +146,7 @@
"props": {
"type": "button",
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
{
@@ -2,36 +2,24 @@
namespace Plugins\Gnuboard7\HelloPlugin\Providers;
use Illuminate\Support\ServiceProvider;
use App\Extension\BasePluginServiceProvider;
use Plugins\Gnuboard7\HelloPlugin\Services\HelloLogService;
/**
* Hello 플러그인 서비스 프로바이더
* Hello 플러그인 서비스 프로바이더.
*
* HelloLogService 를 싱글톤으로 바인딩합니다. 플러그인에서 DI 가 필요할 때의
* 구조를 시연하기 위한 최소 프로바이더입니다.
* BasePluginServiceProvider 를 상속해 표준 자동 바인딩 표면을 제공하며,
* 추가로 HelloLogService 를 싱글톤으로 바인딩합니다. 학습용 샘플 플러그인
* 의 최소 프로바이더 구조를 보여줍니다.
*/
class HelloPluginServiceProvider extends ServiceProvider
class HelloPluginServiceProvider extends BasePluginServiceProvider
{
/**
* 서비스 컨테이너 바인딩을 등록합니다.
*
* @return void
*/
protected string $pluginIdentifier = 'gnuboard7-hello_plugin';
public function register(): void
{
$this->app->singleton(HelloLogService::class, function () {
return new HelloLogService();
});
}
parent::register();
/**
* 부트 로직을 실행합니다.
*
* @return void
*/
public function boot(): void
{
// 학습용 샘플은 부트 단계 로직이 없습니다.
$this->app->singleton(HelloLogService::class, fn () => new HelloLogService());
}
}
@@ -4,6 +4,22 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-01
### Added
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
### Changed
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
- 플러그인 표준 베이스를 따르도록 내부 구조 정비. 이미지 업로드·서빙 동작은 동일합니다.
- 코어 최소 요구 버전을 7.0.0-beta.8 로 상향.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.stack / .input) 과 정합 — 표준 간격과 외형이 일관 표시되도록 정리.
## [1.0.0-beta.3] - 2026-05-11
### Added
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-ckeditor5",
"version": "1.0.0-beta.4",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-ckeditor5",
"description": "CKEditor 5 WYSIWYG Editor Plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.3",
"version": "1.0.0",
"authors": [
{
"name": "sirsoft",
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "dd62f80e956b1dd28c3f4f4e94c48774",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+2 -2
View File
@@ -1,4 +1,4 @@
var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
/* 다국어 탭 컨테이너 */
.ckeditor5-locale-tabs {
display: flex;
@@ -369,5 +369,5 @@ var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.cke
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G})({});
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/editorInstances.ts","../../resources/js/handlers/initEditor.ts","../../resources/js/handlers/destroyEditor.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * CKEditor5 인스턴스 공유 저장소\n *\n * containerId → locale별 에디터 인스턴스 맵을 관리합니다.\n * initEditor / destroyEditor 핸들러가 공유합니다.\n */\n\n// containerId → Map<locale, editorInstance>\nexport const editorInstances = new Map<string, Map<string, unknown>>();\n\n// setData() 중 change:data → syncToForm 재진입 방지 플래그\nlet _syncSuppressed = false;\nexport function isSyncSuppressed(): boolean {\n return _syncSuppressed;\n}\nexport function setSyncSuppressed(value: boolean): void {\n _syncSuppressed = value;\n}\n","/**\n * CKEditor5 초기화 핸들러\n *\n * 컴포넌트 마운트 시 CKEditor5 인스턴스를 생성합니다.\n * 다국어(multilingual) 모드: locale별 탭 + 인스턴스 생성\n * 단일 모드: 단일 인스턴스 생성\n */\n\nimport { editorInstances, isSyncSuppressed } from './editorInstances';\n\n/** CKEditor5 CDN CSS URL */\nconst CKEDITOR5_CSS_URL = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css';\n/** CKEditor5 CDN 번역 파일 base URL */\nconst CKEDITOR5_TRANSLATIONS_BASE = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/translations';\n/** CKEditor5 CSS 로드 상태 추적용 엘리먼트 ID */\nconst CKEDITOR5_CSS_ID = 'ckeditor5-cdn-css';\n/** CKEditor5 다크 모드 CSS 엘리먼트 ID */\nconst CKEDITOR5_DARK_CSS_ID = 'ckeditor5-dark-override';\n/** CKEditor5 다국어 탭 기본 CSS 엘리먼트 ID */\nconst CKEDITOR5_TAB_CSS_ID = 'ckeditor5-tab-style';\n\n/** CKEditor 전역 객체 타입 (CDN UMD 빌드 — window.CKEDITOR) */\ninterface CKEditorGlobal {\n ClassicEditor: any;\n Essentials: any;\n Bold: any;\n Italic: any;\n Underline: any;\n Strikethrough: any;\n Link: any;\n Paragraph: any;\n Heading: any;\n BlockQuote: any;\n List: any;\n Alignment: any;\n Table: any;\n TableToolbar: any;\n TableProperties: any;\n TableCellProperties: any;\n Image: any;\n ImageUpload: any;\n ImageResize: any;\n ImageStyle: any;\n ImageCaption: any;\n ImageToolbar: any;\n SimpleUploadAdapter: any;\n MediaEmbed: any;\n HorizontalLine: any;\n Indent: any;\n IndentBlock: any;\n CodeBlock: any;\n SourceEditing: any;\n FontSize: any;\n FontColor: any;\n FontBackgroundColor: any;\n PasteFromOffice: any;\n GeneralHtmlSupport: any;\n}\n\n/** initEditor 파라미터 타입 */\ninterface InitEditorParams {\n name?: string;\n content?: string;\n multilingual?: boolean | string;\n placeholder?: string;\n readOnly?: boolean | string;\n imageUpload?: boolean | string;\n uploadPermission?: string;\n height?: number | string;\n toolbar?: string;\n}\n\n/** 툴바 프리셋 */\nconst TOOLBAR_PRESETS: Record<string, string[]> = {\n minimal: [\n 'bold', 'italic', 'underline', '|',\n 'link', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n standard: [\n 'heading', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'insertTable', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n full: [\n 'heading', '|',\n 'fontSize', 'fontColor', 'fontBackgroundColor', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', 'insertTable', 'uploadImage', 'mediaEmbed', 'horizontalLine', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'codeBlock', 'sourceEditing', '|',\n 'undo', 'redo',\n ],\n};\n\n/**\n * CKEditor5 번역 스크립트를 동적으로 로드합니다.\n * 영어(en)는 기본 내장이므로 로드하지 않습니다.\n */\nfunction loadCkeditorTranslations(locale: string): Promise<void> {\n if (locale === 'en') return Promise.resolve();\n const id = `ckeditor5-translations-${locale}`;\n if (document.getElementById(id)) return Promise.resolve();\n return new Promise((resolve) => {\n const script = document.createElement('script');\n script.id = id;\n script.src = `${CKEDITOR5_TRANSLATIONS_BASE}/${locale}.umd.js`;\n script.onload = () => resolve();\n script.onerror = () => resolve(); // 번역 실패해도 에디터는 영어로 동작\n document.head.appendChild(script);\n });\n}\n\n/**\n * CKEditor5 CSS를 동적으로 로드합니다.\n * 이미 로드된 경우 중복 삽입하지 않습니다.\n */\nfunction loadCkeditorCss(): void {\n if (document.getElementById(CKEDITOR5_CSS_ID)) {\n return;\n }\n const link = document.createElement('link');\n link.id = CKEDITOR5_CSSLine truncated
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/editorInstances.ts","../../resources/js/handlers/initEditor.ts","../../resources/js/handlers/destroyEditor.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * CKEditor5 인스턴스 공유 저장소\n *\n * containerId → locale별 에디터 인스턴스 맵을 관리합니다.\n * initEditor / destroyEditor 핸들러가 공유합니다.\n */\n\n// containerId → Map<locale, editorInstance>\nexport const editorInstances = new Map<string, Map<string, unknown>>();\n\n// setData() 중 change:data → syncToForm 재진입 방지 플래그\nlet _syncSuppressed = false;\nexport function isSyncSuppressed(): boolean {\n return _syncSuppressed;\n}\nexport function setSyncSuppressed(value: boolean): void {\n _syncSuppressed = value;\n}\n","/**\n * CKEditor5 초기화 핸들러\n *\n * 컴포넌트 마운트 시 CKEditor5 인스턴스를 생성합니다.\n * 다국어(multilingual) 모드: locale별 탭 + 인스턴스 생성\n * 단일 모드: 단일 인스턴스 생성\n */\n\nimport { editorInstances, isSyncSuppressed } from './editorInstances';\n\n/** CKEditor5 CDN CSS URL */\nconst CKEDITOR5_CSS_URL = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css';\n/** CKEditor5 CDN 번역 파일 base URL */\nconst CKEDITOR5_TRANSLATIONS_BASE = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/translations';\n/** CKEditor5 CSS 로드 상태 추적용 엘리먼트 ID */\nconst CKEDITOR5_CSS_ID = 'ckeditor5-cdn-css';\n/** CKEditor5 다크 모드 CSS 엘리먼트 ID */\nconst CKEDITOR5_DARK_CSS_ID = 'ckeditor5-dark-override';\n/** CKEditor5 다국어 탭 기본 CSS 엘리먼트 ID */\nconst CKEDITOR5_TAB_CSS_ID = 'ckeditor5-tab-style';\n\n/** CKEditor 전역 객체 타입 (CDN UMD 빌드 — window.CKEDITOR) */\ninterface CKEditorGlobal {\n ClassicEditor: any;\n Essentials: any;\n Bold: any;\n Italic: any;\n Underline: any;\n Strikethrough: any;\n Link: any;\n Paragraph: any;\n Heading: any;\n BlockQuote: any;\n List: any;\n Alignment: any;\n Table: any;\n TableToolbar: any;\n TableProperties: any;\n TableCellProperties: any;\n Image: any;\n ImageUpload: any;\n ImageResize: any;\n ImageStyle: any;\n ImageCaption: any;\n ImageToolbar: any;\n SimpleUploadAdapter: any;\n MediaEmbed: any;\n HorizontalLine: any;\n Indent: any;\n IndentBlock: any;\n CodeBlock: any;\n SourceEditing: any;\n FontSize: any;\n FontColor: any;\n FontBackgroundColor: any;\n PasteFromOffice: any;\n GeneralHtmlSupport: any;\n}\n\n/** initEditor 파라미터 타입 */\ninterface InitEditorParams {\n name?: string;\n content?: string;\n multilingual?: boolean | string;\n placeholder?: string;\n readOnly?: boolean | string;\n imageUpload?: boolean | string;\n uploadPermission?: string;\n height?: number | string;\n toolbar?: string;\n}\n\n/** 툴바 프리셋 */\nconst TOOLBAR_PRESETS: Record<string, string[]> = {\n minimal: [\n 'bold', 'italic', 'underline', '|',\n 'link', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n standard: [\n 'heading', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'insertTable', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n full: [\n 'heading', '|',\n 'fontSize', 'fontColor', 'fontBackgroundColor', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', 'insertTable', 'uploadImage', 'mediaEmbed', 'horizontalLine', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'codeBlock', 'sourceEditing', '|',\n 'undo', 'redo',\n ],\n};\n\n/**\n * CKEditor5 번역 스크립트를 동적으로 로드합니다.\n * 영어(en)는 기본 내장이므로 로드하지 않습니다.\n */\nfunction loadCkeditorTranslations(locale: string): Promise<void> {\n if (locale === 'en') return Promise.resolve();\n const id = `ckeditor5-translations-${locale}`;\n if (document.getElementById(id)) return Promise.resolve();\n return new Promise((resolve) => {\n const script = document.createElement('script');\n script.id = id;\n script.src = `${CKEDITOR5_TRANSLATIONS_BASE}/${locale}.umd.js`;\n script.onload = () => resolve();\n script.onerror = () => resolve(); // 번역 실패해도 에디터는 영어로 동작\n document.head.appendChild(script);\n });\n}\n\n/**\n * CKEditor5 CSS를 동적으로 로드합니다.\n * 이미 로드된 경우 중복 삽입하지 않습니다.\n */\nfunction loadCkeditorCss(): void {\n if (document.getElementById(CKEDITOR5_CSS_ID)) {\n return;\n }\n const link = document.createElement('link');\n link.id = CKEDITOR5_CSSLine truncated
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ckeditor5",
"version": "1.0.0-beta.1",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ckeditor5",
"version": "1.0.0-beta.1",
"version": "1.0.0",
"devDependencies": {
"typescript": "^5.3.3",
"vite": "^5.4.14"
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ckeditor5",
"version": "1.0.0-beta.3",
"version": "1.0.0",
"description": "그누보드7 CKEditor 5 WYSIWYG 에디터 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -5,13 +5,13 @@
"ko": "CKEditor 5 WYSIWYG 에디터",
"en": "CKEditor 5 WYSIWYG Editor"
},
"version": "1.0.0-beta.3",
"version": "1.0.0",
"license": "MIT",
"description": {
"ko": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.",
"en": "WYSIWYG editor plugin using CKEditor 5. Installing this plugin replaces the default HtmlEditor."
},
"g7_version": ">=7.0.0-beta.3",
"g7_version": ">=7.0.0",
"dependencies": {
"modules": {},
"plugins": {}
@@ -0,0 +1,46 @@
/**
* @file pluginSettingsSticky.test.tsx
* @description sirsoft-ckeditor5 플러그인 환경설정 화면 하단 저장 버튼 sticky 고정 테스트
*
* 플러그인 환경설정(plugin_settings.json)의 하단 저장/취소 버튼 영역이
* 긴 콘텐츠를 스크롤하는 동안에도 화면 하단에 고정되도록 sticky 클래스가
* 적용되어 있는지 검증한다.
*/
import { describe, it, expect } from 'vitest';
import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json';
/** 레이아웃 트리에서 주어진 id 의 노드를 찾는다. */
function findById(node: unknown, id: string): Record<string, unknown> | undefined {
if (!node || typeof node !== 'object') {
return undefined;
}
const value = node as Record<string, unknown>;
if (value.id === id) {
return value;
}
for (const child of Object.values(value)) {
const found = findById(child, id);
if (found) {
return found;
}
}
return undefined;
}
function classNameOf(node: Record<string, unknown> | undefined): string {
const props = (node?.props ?? {}) as Record<string, unknown>;
return typeof props.className === 'string' ? props.className : '';
}
describe('plugin_settings 하단 버튼 sticky 고정', () => {
it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => {
const footer = findById(pluginSettingsLayout, 'footer_buttons');
expect(footer).toBeDefined();
const className = classNameOf(footer);
expect(className).toContain('sticky');
expect(className).toContain('bottom-0');
expect(className).toContain('z-10');
});
});
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "Editor Settings"
}
},
"settings": {
"title": "CKEditor5 Settings",
"description": "CKEditor5 WYSIWYG editor plugin configuration",
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "에디터 설정"
}
},
"settings": {
"title": "CKEditor5 설정",
"description": "CKEditor5 WYSIWYG 에디터 플러그인 환경설정",
@@ -12,6 +12,7 @@
"data_sources": [
{
"id": "settings",
"label_key": "$t:sirsoft-ckeditor5.editor.data_source.settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
@@ -27,11 +28,10 @@
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
"className": "admin-page-content-responsive"
},
"children": [
{
@@ -50,13 +50,13 @@
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
"className": "alert-info"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3" },
"props": { "className": "flex-center gap-3" },
"children": [
{
"type": "basic",
@@ -70,7 +70,7 @@
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
"props": { "className": "text-info-soft" },
"text": "$t:sirsoft-ckeditor5.settings.notice"
}
]
@@ -83,13 +83,13 @@
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6"
"className": "alert-danger"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3" },
"props": { "className": "flex-center gap-3" },
"children": [
{
"type": "basic",
@@ -107,7 +107,7 @@
{
"type": "basic",
"name": "H3",
"props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" },
"props": { "className": "text-error-soft" },
"text": "$t:admin.plugins.settings.validation_error"
},
{
@@ -123,7 +123,7 @@
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-red-700 dark:text-red-300" },
"props": { "className": "grid-3col-responsive" },
"text": "{{message}}"
}
]
@@ -146,12 +146,12 @@
"id": "section_toolbar_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" },
"props": { "className": "panel-header-row" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"props": { "className": "text-error-strong" },
"text": "$t:sirsoft-ckeditor5.settings.section_toolbar"
}
]
@@ -166,7 +166,7 @@
"id": "field_toolbar",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"props": { "className": "section-heading-md" },
"children": [
{
"type": "basic",
@@ -176,13 +176,13 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"props": { "className": "form-label" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"props": { "className": "form-hint" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.hint"
}
]
@@ -197,7 +197,7 @@
"name": "Select",
"props": {
"name": "toolbar",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
},
"children": [
{
@@ -228,7 +228,7 @@
"id": "field_editor_height",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"props": { "className": "section-heading-md" },
"children": [
{
"type": "basic",
@@ -238,13 +238,13 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"props": { "className": "form-label" },
"text": "$t:sirsoft-ckeditor5.settings.fields.editor_height.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"props": { "className": "form-hint" },
"text": "$t:sirsoft-ckeditor5.settings.fields.editor_height.hint"
}
]
@@ -260,7 +260,7 @@
"props": {
"type": "number",
"name": "editorHeight",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
}
}
]
@@ -283,12 +283,12 @@
"id": "section_image_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" },
"props": { "className": "panel-header-row" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"props": { "className": "text-error-strong" },
"text": "$t:sirsoft-ckeditor5.settings.section_image"
}
]
@@ -303,7 +303,7 @@
"id": "field_image_upload",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"props": { "className": "section-heading-md" },
"children": [
{
"type": "basic",
@@ -313,13 +313,13 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"props": { "className": "form-label" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_upload.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"props": { "className": "form-hint" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_upload.hint"
}
]
@@ -342,7 +342,7 @@
"id": "field_image_max_size",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"props": { "className": "section-heading-md" },
"children": [
{
"type": "basic",
@@ -352,13 +352,13 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"props": { "className": "form-label" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_max_size.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"props": { "className": "form-hint" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_max_size.hint"
}
]
@@ -374,7 +374,7 @@
"props": {
"type": "number",
"name": "imageMaxSizeMb",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
}
}
]
@@ -396,7 +396,7 @@
{
"type": "basic",
"name": "H3",
"props": { "className": "text-lg font-semibold text-gray-900 dark:text-white mb-4" },
"props": { "className": "section-heading mb-4" },
"text": "$t:sirsoft-ckeditor5.settings.guide.title"
},
{
@@ -407,31 +407,31 @@
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"props": { "className": "text-label" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step1"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"props": { "className": "text-label" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step2"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"props": { "className": "text-label" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step3"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"props": { "className": "text-label" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step4"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"props": { "className": "text-label" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step5"
}
]
@@ -450,7 +450,7 @@
"id": "section_preview_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" },
"props": { "className": "px-6 py-4 flex-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" },
"actions": [
{
"type": "click",
@@ -469,13 +469,13 @@
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"props": { "className": "text-error-strong" },
"text": "$t:sirsoft-ckeditor5.settings.section_preview"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"props": { "className": "form-hint" },
"text": "$t:sirsoft-ckeditor5.settings.section_preview_hint"
}
]
@@ -531,7 +531,7 @@
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-3 mt-6" },
"props": { "className": "sticky-footer-buttons" },
"children": [
{
"id": "cancel_button",
@@ -539,7 +539,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "btn btn-secondary"
},
"actions": [
{
@@ -563,7 +563,7 @@
"props": {
"type": "button",
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
{
@@ -2,45 +2,28 @@
namespace Plugins\Sirsoft\Ckeditor5\Providers;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\PluginStorageDriver;
use Illuminate\Support\ServiceProvider;
use App\Extension\BasePluginServiceProvider;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
use Plugins\Sirsoft\Ckeditor5\Repositories\ImageUploadRepository;
use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService;
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService;
/**
* CKEditor5 플러그인 서비스 프로바이더
* CKEditor5 플러그인 서비스 프로바이더.
*
* Repository 인터페이스와 구현체 바인딩,
* StorageInterface 주입을 담당합니다.
* Repository 인터페이스/구현체 바인딩과 ImageUpload/ImageServe 서비스의
* StorageInterface 자동 주입을 BasePluginServiceProvider 표준에 위임합니다.
*/
class Ckeditor5ServiceProvider extends ServiceProvider
class Ckeditor5ServiceProvider extends BasePluginServiceProvider
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-ckeditor5';
protected string $pluginIdentifier = 'sirsoft-ckeditor5';
/**
* 서비스 컨테이너 바인딩 등록
*
* @return void
*/
public function register(): void
{
// Repository 바인딩
$this->app->bind(
ImageUploadRepositoryInterface::class,
ImageUploadRepository::class
);
protected array $repositories = [
ImageUploadRepositoryInterface::class => ImageUploadRepository::class,
];
// StorageInterface 바인딩 — plugins 디스크 사용
$this->app->when([ImageUploadService::class, ImageServeService::class])
->needs(StorageInterface::class)
->give(function () {
return new PluginStorageDriver(self::PLUGIN_ID, 'plugins');
});
}
protected array $storageServices = [
ImageUploadService::class,
ImageServeService::class,
];
}
@@ -4,12 +4,27 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-01
### Added
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
### Changed
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
## [1.0.0-beta.2] - 2026-04-20
### Changed
- 주소 검색 영역의 콘텐츠 카드 / 세로 정렬 컨테이너 외형을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 화면과 같은 결로 통일.
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
- extension JSON: `extensionPointProps.onAddressSelect` → `extensionPointCallbacks.onAddressSelect` 참조 변경 (extension_point props/callbacks 분리)
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.stack / .input) 과 정합 — 표준 간격과 외형이 일관 표시되도록 정리.
## [1.0.0-beta.1] - 2026-04-01
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-daum_postcode",
"description": "Daum Postcode Service Plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.2",
"version": "1.0.0",
"authors": [
{
"name": "sirsoft",
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2cf3389f6c00f3e542e3f7f4e7d556bc",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-daum_postcode",
"version": "1.0.0-beta.2",
"version": "1.0.0",
"description": "그누보드7 Daum 우편번호 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -5,13 +5,13 @@
"ko": "Daum 우편번호",
"en": "Daum Postcode"
},
"version": "1.0.0-beta.2",
"version": "1.0.0",
"license": "MIT",
"description": {
"ko": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.",
"en": "Plugin that provides address search functionality through Daum Postcode service. Free to use without API key."
},
"g7_version": ">=7.0.0-beta.2",
"g7_version": ">=7.0.0",
"dependencies": {
"modules": {},
"plugins": {}
@@ -33,7 +33,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
"className": "px-3 py-2 bg-gray-100 dark:bg-gray-700 text-body border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium transition-colors"
},
"text": "$t:sirsoft-daum_postcode.search_address",
"actions": [
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "Address Search Settings"
}
},
"search_address": "Search Address",
"postcode": {
"search": "Search Address",
@@ -1,4 +1,9 @@
{
"editor": {
"data_source": {
"settings": "주소 검색 설정"
}
},
"search_address": "주소 검색",
"postcode": {
"search": "주소 검색",
@@ -12,6 +12,7 @@
"data_sources": [
{
"id": "settings",
"label_key": "$t:sirsoft-daum_postcode.editor.data_source.settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
@@ -27,11 +28,10 @@
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
"className": "admin-page-content-responsive"
},
"children": [
{
@@ -50,7 +50,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
"className": "alert-info"
},
"children": [
{
@@ -72,7 +72,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
"className": "text-info-soft"
},
"text": "$t:sirsoft-daum_postcode.settings.notice"
}
@@ -86,7 +86,7 @@
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6"
"className": "alert-danger"
},
"children": [
{
@@ -112,7 +112,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "$t:admin.plugins.settings.validation_error"
},
@@ -132,7 +132,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
"className": "grid-3col-responsive"
},
"text": "{{message}}"
}
@@ -165,7 +165,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
"className": "text-error-soft"
},
"children": [
{
@@ -179,7 +179,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "form-label"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.display_mode.label"
},
@@ -187,7 +187,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.display_mode.hint"
}
@@ -205,7 +205,7 @@
"name": "Select",
"props": {
"name": "display_mode",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
},
"children": [
{
@@ -235,7 +235,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
"className": "text-error-soft"
},
"children": [
{
@@ -249,7 +249,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "form-label"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_width.label"
},
@@ -257,7 +257,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_width.hint"
}
@@ -276,7 +276,7 @@
"props": {
"type": "number",
"name": "popup_width",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
}
}
]
@@ -288,7 +288,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
"className": "text-error-soft"
},
"children": [
{
@@ -302,7 +302,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "form-label"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_height.label"
},
@@ -310,7 +310,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.popup_height.hint"
}
@@ -329,7 +329,7 @@
"props": {
"type": "number",
"name": "popup_height",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
}
}
]
@@ -341,7 +341,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start"
"className": "text-error-soft"
},
"children": [
{
@@ -355,7 +355,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "form-label"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.theme_color.label"
},
@@ -363,7 +363,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-daum_postcode.settings.fields.theme_color.hint"
}
@@ -383,7 +383,7 @@
"type": "text",
"name": "theme_color",
"placeholder": "#1D4ED8",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
"className": "focus:border-transparent"
}
}
]
@@ -406,7 +406,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white mb-4"
"className": "section-heading mb-4"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.title"
},
@@ -421,7 +421,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-label"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step1"
},
@@ -429,7 +429,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-label"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step2"
},
@@ -437,7 +437,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-label"
},
"text": "$t:sirsoft-daum_postcode.settings.guide.step3"
}
@@ -450,7 +450,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "sticky-footer-buttons"
},
"children": [
{
@@ -458,7 +458,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "btn btn-secondary"
},
"actions": [
{
@@ -483,7 +483,7 @@
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
{
@@ -557,7 +557,7 @@
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -0,0 +1,36 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-01
### Changed
- 동의 기록 관리 / 마이페이지 개인정보 탭의 콘텐츠 카드 / 세로 정렬 컨테이너 외형을 sirsoft-admin_basic 표준 시맨틱 (.admin-card / .row-stack 등) 과 정합 — 다른 화면과 같은 결로 통일.
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
- 플러그인 표준 베이스를 따르도록 내부 구조 정비. 동의 처리 동작은 동일합니다.
- 코어 최소 요구 버전을 7.0.0-beta.8 로 상향.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.row-stack / .input) 과 정합 — 표준 간격과 외형이 일관 표시되도록 정리.
### Added
- 레이아웃 편집기에서 쿠키 배너를 미리보고 편집할 수 있도록 "쿠키 배너 표시" 화면 상태를 추가했습니다 (배너는 미동의 방문자에게만 나타나 편집기 기본 화면에서는 보이지 않던 영역).
- 오픈 베타 릴리즈
- 쿠키 동의 배너 추가 — 필수/기능/분석/마케팅 4분류, 동의 전 외부 추적 스크립트·iframe 자동 차단 + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·1st-party 쿠키) 게이팅
- 마이페이지 「쿠키 동의 관리」 카드 추가 — 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의를 한 화면에서 처리
- 동의 이력 영구 저장 — 정책 버전·출처·카테고리 스냅샷을 함께 기록 (GDPR Art.7(1) 입증 자료)
- 관리자 「GDPR 동의 이력」 메뉴 추가 — 회원/게스트 동의 변경 이력 조회 (이메일·세션 검색, 필수/기능/분석/마케팅 카테고리·출처 다중 필터)
- 관리자 정책 버전 수동 발행 — 정책 본문 변경 시 「+ 새 버전 발행」 클릭으로 모든 회원이 다음 방문 시 재동의 진행
- 사용자 노출 영역(쿠키 배너 푸터·마이페이지 동의 카드)에 운영 주체·데이터 저장 위치 한 줄 안내 노출
- 레이아웃 편집기에서 동의 이력 화면을 데이터 있음/없음 상태로 전환해 미리볼 수 있도록 했습니다.
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
### Security
- 동의 전 외부 추적 사전 차단 — 플러그인 로드 직후 즉시 가로채기를 등록하여 동의 정책 응답이 도착하기 전에 추적 스크립트가 외부 로드되는 회귀 방지
- 사용자 완전 삭제 시 동의 이력의 user_id·IP·UA 만 NULL 익명화하여 행 보존 (GDPR Art.17 + Art.7(1) 양립)
- 데이터 저장 위치 입력 시 보안 식별자(IP·CIDR·클라우드 리전 코드) 자동 거부
+47
View File
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 GDPR (일반 데이터 보호 규정) 플러그인 (sirsoft-gdpr)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+151
View File
@@ -0,0 +1,151 @@
# GDPR Plugin for G7
GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 G7 플러그인입니다. 쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 저장·마이페이지 동의 철회를 한 패키지로 제공합니다.
## 핵심 기능
| 기능 | 설명 |
|------|------|
| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류 (ICO·CNIL 권장 표준), 다크 모드, 4 위치(하단 바·좌하단·우하단·중앙 모달) |
| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·1st-party 쿠키) 게이팅 |
| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존 (GDPR Art.7(1) 입증 자료) |
| 마이페이지 동의 관리 | 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의 |
| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력 (이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) |
| 정책 버전 수동 발행 | 정책 본문 변경 시 「+ 새 버전 발행」 클릭으로 모든 회원 재동의 트리거 |
## 설치
```bash
php artisan plugin:install sirsoft-gdpr
php artisan plugin:activate sirsoft-gdpr
```
설치 직후 쿠키 배너는 **비활성** 상태로 제공됩니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지 슬러그를 입력한 뒤 「쿠키 배너 노출」 토글을 켜야 사이트에 노출됩니다.
## 설정
`관리자 → 플러그인 → GDPR 설정` 에서 구성합니다.
### 운영 정보
| 항목 | 설명 |
|------|------|
| 운영 주체명 (`legal_entity_name`) | 쿠키 배너 푸터·마이페이지 동의 카드에 노출되는 사이트 운영 주체 (예: "(주)홍길동컴퍼니") |
| 데이터 저장 위치 (`data_storage_location`) | 사용자에게 안내할 데이터 저장 국가 (예: "대한민국", "미국 (AWS)"). IP 주소·CIDR·클라우드 리전 코드(예: `ap-northeast-2`)는 보안상 자동 거부됩니다 |
| 개인정보처리방침 슬러그 (`privacy_policy_slug`) | sirsoft-page 플러그인에 등록된 처리방침 페이지의 슬러그. 비어있으면 쿠키 배너의 정책 링크가 자동 숨겨집니다 |
### 쿠키 배너
| 항목 | 설명 |
|------|------|
| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단 + 마이페이지 동의 관리 카드가 일괄 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다 |
| 배너 위치 (`banner_position`) | 하단 바 / 좌하단 팝업 / 우하단 팝업 / 중앙 모달 |
### 자동 차단 정책
쿠키 배너가 ON 일 때 다음 카테고리의 외부 도메인 리소스가 동의 전까지 자동 차단됩니다. 카탈로그 기본값이 시드되어 있으며, 운영자가 카테고리별로 추가·삭제할 수 있습니다.
| 카테고리 | 기본 카탈로그 예시 |
|---------|------------------|
| 기능 (functional) | Crisp, Intercom, Tawk.to, Weglot 등 |
| 분석 (analytics) | Google Analytics, Hotjar, Mixpanel, 네이버 프리미엄 로그분석 등 |
| 마케팅 (marketing) | Facebook Pixel, Google Ads, Kakao Pixel, YouTube embed 등 |
도메인 형식: `example.com` 또는 와일드카드 `*.example.com`. `localhost` 같은 단일 라벨, 한글 도메인(xn-- 변환)은 미지원.
## 자체 호스팅 추적 자원 분류
자체 도메인에서 호스팅되는 추적 스크립트·iframe·임베드는 도메인 매칭 대상이 아니므로 HTML 속성으로 분류합니다.
```html
<script src="/js/my-analytics.js" data-gdpr-category="analytics"></script>
<iframe src="/embed/custom-tracker" data-gdpr-category="marketing"></iframe>
```
동의 전까지 자동 차단되며, 동의 후 자동 복원됩니다. 동의 철회 시 다시 차단됩니다.
## 정책 버전 발행
`관리자 → 플러그인 → GDPR 설정` 의 「정책 버전」 카드에서 「+ 새 버전 발행」 을 클릭합니다. 발행 즉시 모든 회원이 다음 방문 시 재동의 화면(amber 안내 박스 + 「최신 정책으로 갱신」 버튼)을 보게 됩니다.
| 발행이 필요한 변경 | 발행이 필요 없는 변경 |
|------------------|---------------------|
| 정책 본문 (개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 |
| 카테고리 의미 변경 | UI 라벨/설명 정정 |
| 위탁자·데이터 보관 정보 변경 | 운영 주체명·저장 위치 정정 |
발행 시 변경 사유 메모를 함께 저장합니다 (GDPR Art.30 처리 기록 의무).
## 동의 이력 조회
`관리자 → GDPR 동의 이력` 메뉴에서 회원/게스트의 모든 동의 변경 이력을 조회할 수 있습니다.
- **검색**: 이메일 부분 일치, 세션 ID
- **필터**: 카테고리(필수/기능/분석/마케팅), 출처(banner/mypage/register/withdraw 등), 동의 액션(granted/revoked)
- **카테고리 스냅샷**: 각 행 펼침에서 동의 시점의 전체 카테고리 의사 표를 immutable 보존 (GDPR Art.7(1) 입증 자료)
## 가용 훅 (Hook)
다른 확장에서 본 플러그인의 동의 이벤트를 구독할 수 있습니다.
### 액션 훅
| 훅 이름 | 시점 | 인수 |
|---------|------|------|
| `sirsoft-gdpr.consent.granted` | 동의 부여 시 | `GdprUserConsent $consent, string $source` |
| `sirsoft-gdpr.consent.revoked` | 동의 철회 시 | `GdprUserConsent $consent, string $source` |
`$source` 값: `banner` / `mypage` / `mypage_renew_all` / `register` / `withdraw`.
### 훅 등록 예시
```php
use App\Extension\HookManager;
HookManager::addAction(
'sirsoft-gdpr.consent.granted',
function ($consent, string $source) {
// 예: 분석 동의 부여 시 외부 분석 도구에 사용자 식별 전송
if ($consent->consent_key === 'cookie_analytics' && $consent->is_consented) {
AnalyticsService::identify($consent->user_id);
}
},
priority: 10
);
```
## 데이터베이스
| 테이블 | 용도 | 보존 정책 |
|--------|------|----------|
| `gdpr_user_consents` | 회원 현재 동의 상태 (mutable) | 사용자 삭제 시 명시 삭제 |
| `gdpr_user_consent_histories` | 동의 변경 이력 (immutable append-only) | 사용자 삭제 시 `user_id`/IP/UA 만 NULL 익명화하여 행 보존 (GDPR Art.17 + Art.7(1) 양립) |
| `gdpr_policy_versions` | 정책 버전 발행 이력 (불변) | 영구 보존 (Art.30 처리 기록) |
플러그인 제거 시 위 3 테이블이 자동 DROP 됩니다.
## 게스트 → 회원 동의 승계
본 플러그인은 게스트 → 회원 동의 자동 승계를 제공하지 않습니다. GDPR Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며, 글로벌 CMP 대부분도 자동 승계를 기본으로 제공하지 않습니다. 회원가입 폼 동의로 Art.7(1) 입증 책임이 충족되며, 게스트 시절 동의 이력은 세션 기준으로 보존됩니다.
## 의존성
| 대상 | 의존 수준 | 미설치 시 동작 |
|------|----------|---------------|
| `sirsoft-page` 모듈 | 소프트 (런타임 체크) | 배너 "자세히" 링크만 자동 숨김. 나머지 정상 |
| `sirsoft-basic` 템플릿 | 주입 지점 의존 | 다른 사용자 템플릿 사용 시 해당 템플릿에도 `_user_base.json` 의 공용 확장 지점이 있어야 정상 동작 |
## 테스트 실행
```bash
# 백엔드
php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests
# 프론트엔드
cd plugins/_bundled/sirsoft-gdpr
npm run test:run
```
## 라이선스
MIT
@@ -0,0 +1,22 @@
{
"name": "plugins/sirsoft-gdpr",
"description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Gdpr\\": ["src/", "./"]
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ed69cfb7d799ccb19ce6420c9d3bc5b9",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2",
"ext-json": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -0,0 +1,15 @@
{
"_meta": {
"version": "1.0.0",
"description": "GDPR (일반 데이터 보호 규정) 플러그인 환경설정 기본값 및 프론트엔드 스키마. banner_enabled 가 GDPR 컴플라이언스 모드 단일 토글 — ON 시 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성."
},
"frontend_schema": {
"privacy_policy_slug": { "type": "string", "expose": true },
"legal_entity_name": { "type": "string", "expose": true },
"data_storage_location": { "type": "string", "expose": true },
"banner_enabled": { "type": "boolean", "expose": true },
"banner_position": { "type": "string", "expose": true },
"cookie_categories": { "type": "string", "expose": true },
"blocked_domains": { "type": "object", "expose": true }
}
}
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('gdpr_user_consents');
Schema::create('gdpr_user_consents', function (Blueprint $table) {
$table->id()->comment('행 ID');
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
$table->string('consent_key', 50)->comment('동의 항목 키 (cookie_necessary 등)');
$table->string('consent_category', 30)->nullable()->comment('동의 분류 (cookie 등)');
$table->boolean('is_consented')->default(false)->comment('현재 동의 여부');
$table->timestamp('consented_at')->nullable()->comment('최근 동의 일시');
$table->timestamp('revoked_at')->nullable()->comment('최근 철회 일시');
$table->unsignedInteger('consent_count')->default(0)->comment('총 동의 횟수');
$table->string('policy_version', 20)->nullable()->comment('최근 동의 시점 정책 버전 스냅샷');
$table->string('last_source', 20)->nullable()->comment('최근 변경 경로 (banner/preference_center/register/mypage)');
$table->timestamps();
$table->unique(['user_id', 'consent_key']);
$table->index(['consent_key', 'is_consented']);
});
if (DB::getDriverName() === 'mysql') {
Schema::table('gdpr_user_consents', function (Blueprint $table) {
$table->comment('GDPR 사용자 현재 동의 상태');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('gdpr_user_consents')) {
Schema::dropIfExists('gdpr_user_consents');
}
}
};
@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('gdpr_user_consent_histories');
Schema::create('gdpr_user_consent_histories', function (Blueprint $table) {
$table->id()->comment('행 ID');
$table->foreignId('user_id')
->nullable()
->constrained('users')
->nullOnDelete()
->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)');
$table->string('session_id', 100)->nullable()->comment('게스트 세션 ID (회원이면 NULL)');
$table->string('consent_key', 50)->comment('동의 항목 키');
$table->string('action', 20)->comment('변경 유형 (granted/revoked/acknowledged)');
$table->string('source', 20)->comment('변경 경로 (banner/preference_center/register/mypage/order/withdraw)');
$table->string('policy_version', 20)->nullable()->comment('시점 정책 버전');
$table->json('categories')->nullable()->comment('카테고리 스냅샷');
$table->string('ip_address', 45)->nullable()->comment('IP 주소 (삭제 시 NULL 익명화)');
$table->string('user_agent', 500)->nullable()->comment('User-Agent (삭제 시 NULL 익명화)');
$table->timestamp('created_at')->nullable()->comment('생성 일시 (UPDATED_AT 없음 — 불변 레코드)');
$table->index(['user_id', 'consent_key']);
$table->index('session_id');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('gdpr_user_consent_histories', function (Blueprint $table) {
$table->comment('GDPR 동의 변경 이력 (불변 append-only)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('gdpr_user_consent_histories')) {
Schema::dropIfExists('gdpr_user_consent_histories');
}
}
};
@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('gdpr_policy_versions');
Schema::create('gdpr_policy_versions', function (Blueprint $table) {
$table->id()->comment('행 ID');
$table->unsignedInteger('version')->unique()->comment('단조 증가 정책 버전 (1, 2, 3, ...)');
$table->string('change_type', 20)->comment('변경 종류 (material/non_material/initial)');
$table->text('memo')->nullable()->comment('운영자 변경 사유 메모 (Material 시 필수)');
$table->json('snapshot')->comment('발행 시점 settings 스냅샷 (cookie_categories + blocked_domains + privacy_policy_slug 등)');
$table->foreignId('created_by')
->nullable()
->constrained('users')
->nullOnDelete()
->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)');
$table->timestamp('created_at')->nullable()->comment('발행 일시 (UPDATED_AT 없음 — 불변 레코드)');
$table->index('created_at');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('gdpr_policy_versions', function (Blueprint $table) {
$table->comment('GDPR 정책 버전 발행 이력 (불변 append-only) — Art.7(1) 동의 입증 + Art.30 처리 기록');
});
}
// 신규 설치 시 initial 행 1개 자동 시드 (version=1, change_type=initial).
// 운영자가 첫 카테고리 추가/삭제 시 v2 발행 → 단조 증가.
// 회원 동의 시 policy_version = "1" 자동 기록.
//
// snapshot 은 plugin.php::getConfigValues() 의 settings 기본값 (cookie_categories /
// blocked_domains / privacy_policy_slug 등) 을 그대로 시드 — Art.7(1) 입증 책임 충족.
// 빈 snapshot 으로 두면 회원이 v1 에 동의했을 때 "어떤 정책에 동의했는지" 입증 불가.
// cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태이므로 snapshot
// 에는 디코드된 배열로 정규화 (snapshot 스키마는 항상 객체/배열 — admin 측 발행 경로와 일치).
$defaults = (new \Plugins\Sirsoft\Gdpr\Plugin())->getConfigValues();
if (isset($defaults['cookie_categories']) && is_string($defaults['cookie_categories'])) {
$decoded = json_decode($defaults['cookie_categories'], true);
$defaults['cookie_categories'] = is_array($decoded) ? $decoded : [];
}
DB::table('gdpr_policy_versions')->insertOrIgnore([
'version' => 1,
'change_type' => 'initial',
'memo' => null,
'snapshot' => json_encode($defaults, JSON_UNESCAPED_UNICODE),
'created_by' => null,
'created_at' => now(),
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('gdpr_policy_versions')) {
Schema::dropIfExists('gdpr_policy_versions');
}
}
};
+1
View File
@@ -0,0 +1 @@
.gdpr-hidden{display:none!important}.gdpr-blocked-embed{display:none}.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){display:inline-flex;align-items:center;justify-content:center;min-height:1.5rem;padding:.25rem .5rem;font-size:.75rem;color:#6b7280;background-color:#f9fafb;border:1px dashed #d1d5db;border-radius:.25rem}@media (prefers-color-scheme: dark){.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}}.dark .gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}
+2
View File
@@ -0,0 +1,2 @@
(function(){"use strict";function Q(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function v(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,s]of Object.entries(t))if(o!=="necessary"){for(const i of s)if(Q(n,i))return o}return null}const w="data-gdpr-category",h="gdpr-blocked-embed",x="data-gdpr-original",b=[`script[${w}]`,`iframe[${w}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,g={categories:{},needs_renewal:!1},R={};function j(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function N(e){g=j(e),l!==null&&p()}function V(e){R={...e},l!==null&&p()}function Z(){typeof document>"u"||l!==null||(p(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&te(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ee))}function ee(e){const t=e.detail;t!==null&&typeof t=="object"&&(g=j(t)),p()}function te(e){re(e)&&k(e),e.querySelectorAll(b).forEach(t=>k(t))}function p(){typeof document>"u"||(document.querySelectorAll(`.${h}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&_(t)&&oe(e)}),document.querySelectorAll(b).forEach(e=>k(e)))}function k(e){if(e.classList.contains(h)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(w);if(t!==null&&t!==""){if(_(t))return;H(e,t);return}const n=ne(e);if(n===null)return;const r=v(n,R);r!==null&&(_(r)||H(e,r))}function ne(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function re(e){if(typeof e.matches!="function")return!1;try{return e.matches(b)}catch{return!1}}function H(e,t){const n=document.createElement("div");n.className=h,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(x,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function oe(e){const t=e.getAttribute(x);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function _(e){return e==="necessary"?!0:g.needs_renewal?!1:g.categories[e]===!0}const se=500,ie=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,C=0;function ce(e){e.isTrusted&&(C=Date.now())}function ae(){if(!B){B=!0;for(const e of ie){const t=ce;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function M(){const e=window.event;return e&&e.isTrusted?!0:C===0?!1:Date.now()-C<=se}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let A=!1,a=null,y={functionalConsented:!1,necessaryAllowlist:u};function le(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function ue(e){return y.necessaryAllowlist.includes(e)}function fe(e){const t=le(e);return t===null?!1:!!(de(e)||ue(t)||y.functionalConsented||M())}function de(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ge(e){if(A)return;if(A=!0,y=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){A=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){fe(r)&&t.call(this,r)}})}function P(e){y=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"}];let $=!1,E=null,m={functionalConsented:!1,necessaryAllowlist:f};function pe(e,t){for(const n of m.necessaryAllowlist){if(n.storage&&n.storage!==t)continue;const r=n.matchType??"exact";if(r==="exact"&&n.key===e||r==="prefix"&&e.startsWith(n.key))return!0}return!1}function ye(e,t){return!!(pe(e,t)||m.functionalConsented||M())}function me(e){if($)return;$=!0,m=e;const t=Storage.prototype;E=t.setItem,t.setItem=function(n,r){const o=this===window.sessionStorage?"sessionStorage":"localStorage";ye(n,o)&&E&&E.call(this,n,r)}}function G(e){m=e}function we(e,t,n){for(const r of n){if(r.storage&&r.storage!==t)continue;const o=r.matchType??"exact";if(o==="exact"&&r.key===e||o==="prefixLine truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/blocker-domains.ts","../../resources/js/blocker.ts","../../resources/js/userInitiatedTracker.ts","../../resources/js/cookieInterceptor.ts","../../resources/js/storageInterceptor.ts","../../resources/js/functionalCleaner.ts","../../resources/js/layouts.ts","../../resources/js/preblocker.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * GDPR 도메인 기반 차단 매칭 + 기본 카탈로그\n *\n * 운영자가 카테고리별 차단 도메인 목록을 관리하면 `data-gdpr-category` 속성\n * 없이도 외부 추적 리소스를 동의 전 차단합니다. 매칭 알고리즘은 endsWith 기반\n * 으로 정규식을 쓰지 않아 일반 페이지 영향이 미미합니다.\n *\n * @module sirsoft-gdpr/blocker-domains\n */\n\n/**\n * 호스트가 차단 도메인 패턴과 매칭되는지 확인합니다.\n *\n * 패턴 형식:\n * - 정확 매칭: `google-analytics.com` → `google-analytics.com` 만 매칭\n * - 와일드카드: `*.hotjar.com` → `static.hotjar.com` 등 매칭. `hotjar.com`\n * 자체는 매칭 안 됨 (정확 매칭으로 별도 추가 필요)\n *\n * @param host 검사 대상 호스트\n * @param pattern 차단 패턴\n * @return 매칭 여부\n */\nexport function matchesDomain(host: string, pattern: string): boolean {\n const lowerHost = host.toLowerCase();\n const lowerPattern = pattern.toLowerCase();\n\n if (lowerPattern.startsWith('*.')) {\n const suffix = lowerPattern.slice(1); // \".hotjar.com\"\n return lowerHost.endsWith(suffix) && lowerHost !== suffix.slice(1);\n }\n\n return lowerHost === lowerPattern;\n}\n\n/**\n * URL 의 호스트가 카테고리별 차단 목록에 매칭되는지 확인합니다.\n *\n * 동일 origin 은 차단 대상 외 (운영자 자체 자산 보호). `data:`/`blob:` 등 비\n * HTTP 스킴은 외부 호스트가 없으므로 차단 대상 외. `necessary` 카테고리는\n * 매칭 자체를 skip 합니다 (필수 쿠키 보호).\n *\n * @param url 검사 대상 URL (절대 또는 상대)\n * @param patterns 카테고리 → 도메인 패턴 배열\n * @return 매칭된 카테고리, 매칭 없으면 null\n */\nexport function categorizeUrl(\n url: string,\n patterns: Record<string, readonly string[]>,\n): string | null {\n if (typeof window === 'undefined' || typeof URL === 'undefined') return null;\n\n let host: string;\n let protocol: string;\n try {\n const parsed = new URL(url, window.location.href);\n protocol = parsed.protocol;\n host = parsed.hostname;\n } catch {\n return null;\n }\n\n if (protocol !== 'http:' && protocol !== 'https:') return null;\n if (host === '' || host === window.location.hostname) return null;\n\n for (const [category, domains] of Object.entries(patterns)) {\n if (category === 'necessary') continue;\n for (const domain of domains) {\n if (matchesDomain(host, domain)) return category;\n }\n }\n\n return null;\n}\n\n/**\n * 기본 차단 도메인 카탈로그 — 운영자가 `blocked_domains_default_catalog = true`\n * 로 켜야 적용. 운영자 커스텀 목록과 합집합으로 병합됩니다.\n *\n * 카탈로그 갱신은 코드 PR 로만 가능. 사용자별 변경 불가.\n */\nexport const DEFAULT_BLOCKED_DOMAINS: Record<string, readonly string[]> = {\n // functional 카테고리는 자체 호스팅 functional 만 처리 — 외부 도구 도메인은 Phase 2 에서 보강.\n // 현재 빈 배열로 두어 운영자가 admin UI 의 functional 차단 도메인 섹션을 사용할 수 있도록 함.\n functional: [],\n analytics: [\n // Google Analytics 계열\n 'google-analytics.com',\n '*.google-analytics.com',\n 'googletagmanager.com',\n '*.googletagmanager.com',\n 'ssl.google-analytics.com',\n // 행동 분석\n '*.hotjar.com',\n 'static.hotjar.com',\n '*.mixpanel.com',\n 'cdn.mxpnl.com',\n '*.amplitude.com',\n 'cdn.amplitude.com',\n '*.segment.io',\n '*.segment.com',\n // 한국 분석 도구\n 'wcs.naver.net',\n 'wcs.naver.com',\n '*.beusable.net',\n ],\n marketing: [\n // Meta (Facebook)\n 'facebook.net',\n 'connect.facebook.net',\n 'facebook.com',\n '*.facebook.com',\n // Google Ads\n 'doubleclick.net',\n '*.doubleclick.net',\n 'googleadservices.com',\n 'googlesyndication.com',\n 'ads.google.com',\n // 광고 네트워크\n '*.criteo.com',\n 'static.criteo.net',\n '*.adnxs.com',\n '*.taboola.com',\n 'cdn.taboola.com',\n '*.outbrain.com',\n // 한국 광고 네트워크\n '*.kakao.com',\n 'analytics.ad.daum.net',\n // SNS 임베드\n 'platform.twitter.com',\n '*.twitter.comLine truncated
@@ -0,0 +1,625 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "레이아웃 편집기 스펙 — GDPR 플러그인 도메인 sampleData. 관리자 레이아웃(plugin_settings / gdpr_consent_log + 정책 버전 모달)의 data_source ID 전수 스캔 기반: gdprSettings / gdprPolicyVersionCurrent / gdprPolicyVersionHistory / gdprPolicyVersionSnapshot / gdprConsentLog. 응답 shape 는 각 레이아웃의 실제 바인딩 경로(gdprSettings.data.* / gdprPolicyVersion*.data.data.* / gdprConsentLog.data.data[] + pagination) 역추론. 편집기 캔버스 프리뷰에서 generic 폴백 차단용 mock.",
"pluginId": "sirsoft-gdpr",
"version": "1.0.0",
"description": "GDPR 플러그인 레이아웃 편집기 샘플 데이터 (쿠키 동의 설정 / 동의 이력 / 개인정보 정책 버전).",
"sampleData": {
"byDataSourceId": {
"gdprSettings": {
"data": {
"is_blocking_enabled": true,
"auto_block": true,
"respect_dnt": true,
"banner_position": "bottom",
"show_settings_button": true,
"consent_version": 3,
"consent_expiry_days": 365,
"privacy_policy_slug": "privacy",
"privacy_policy_url": "/privacy",
"geo_targeting": false,
"geo_countries": [
"KR",
"JP"
],
"test_mode": false,
"log_consents": true,
"blocked_domains": [
"www.google-analytics.com",
"connect.facebook.net"
],
"cookie_categories": [
{
"key": "necessary",
"name": "필수 쿠키",
"description": "사이트 기본 동작에 반드시 필요한 쿠키입니다.",
"cookies": [
"session",
"csrf_token"
],
"required": true,
"enabled": true
},
{
"key": "analytics",
"name": "분석 쿠키",
"description": "방문 통계를 수집해 서비스를 개선합니다.",
"cookies": [
"_ga",
"_gid"
],
"required": false,
"enabled": true
},
{
"key": "marketing",
"name": "마케팅 쿠키",
"description": "관심사 기반 광고 제공에 사용됩니다.",
"cookies": [
"_fbp"
],
"required": false,
"enabled": false
}
],
"default_blocked_domains_preview": {
"analytics": [
"www.google-analytics.com",
"stats.g.doubleclick.net"
],
"marketing": [
"connect.facebook.net",
"www.googleadservices.com"
]
}
}
},
"gdprPolicyVersionCurrent": {
"data": {
"data": {
"version": 3,
"created_at": "2026-05-20 14:30:00",
"change_type": "material",
"memo": "쿠키 분류 항목 추가 및 보관 기간 변경",
"publisher": {
"name": "관리자",
"email": "admin@example.com"
}
}
}
},
"gdprPolicyVersionHistory": {
"data": {
"data": [
{
"version": 3,
"created_at": "2026-05-20 14:30:00",
"change_type": "material",
"memo": "쿠키 분류 항목 추가 및 보관 기간 변경",
"publisher": {
"name": "관리자",
"email": "admin@example.com"
}
},
{
"version": 2,
"created_at": "2026-03-11 09:05:00",
"change_type": "non_material",
"memo": "문구 다듬기",
"publisher": {
"name": "운영자",
"email": "manager@example.com"
}
},
{
"version": 1,
"created_at": "2026-01-02 10:00:00",
"change_type": "initial",
"memo": "최초 발행",
"publisher": {
"name": "관리자",
"email": "admin@example.com"
}
}
]
}
},
"gdprPolicyVersionSnapshot": {
"data": {
"data": {
"version": 2,
"created_at": "2026-03-11 09:05:00",
"change_type": "non_material",
"memo": "문구 다듬기",
"publisher": {
"name": "운영자",
"email": "manager@example.com"
},
"snapshot": {
"privacy_policy_slug": "privacy",
"cookie_categories": [
{
"key": "necessary",
"name": "필수 쿠키",
"required": true,
"enabled": true
},
{
"key": "analytics",
"name": "분석 쿠키",
"required": false,
"enabled": true
}
]
},
"blocked_domains_list": [
"www.google-analytics.com",
"connect.facebook.net"
]
}
}
},
"gdprConsentLog": {
"data": {
"data": [
{
"id": 1,
"created_at": "2026-05-29 18:22:10",
"ip_address": "203.0.113.10",
"consent_given": true,
"categories": [
"necessary",
"analytics"
],
"policy_version": 3
},
{
"id": 2,
"created_at": "2026-05-29 17:01:42",
"ip_address": "203.0.113.55",
"consent_given": false,
"categories": [
"necessary"
],
"policy_version": 3
},
{
"id": 3,
"created_at": "2026-05-28 11:40:05",
"ip_address": "198.51.100.7",
"consent_given": true,
"categories": [
"necessary",
"analytics",
"marketing"
],
"policy_version": 2
}
],
"pagination": {
"current_page": 1,
"last_page": 4,
"per_page": 20,
"total": 73
}
}
},
"gdprPublicSettings": {
"data": {
"cookie_policy_version": "1",
"privacy_policy_slug": "privacy",
"privacy_policy_available": true,
"legal_entity_name": "",
"data_storage_location": "",
"banner_enabled": true,
"banner_position": "bottom_bar",
"cookie_categories": [
{
"key": "necessary",
"required": true,
"label": {
"ko": "필수 쿠키",
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
}
},
{
"key": "functional",
"required": false,
"label": {
"ko": "기능 쿠키",
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
}
},
{
"key": "analytics",
"required": false,
"label": {
"ko": "분석 쿠키",
"en": "Analytics"
},
"description": {
"ko": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)",
"en": "Used to anonymously measure how visitors use the site so we can improve it. (e.g. Google Analytics, Hotjar)"
}
},
{
"key": "marketing",
"required": false,
"label": {
"ko": "마케팅 쿠키",
"en": "Marketing"
},
"description": {
"ko": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)",
"en": "Used to show ads relevant to your interests, measure ad performance, and embed social media content. (e.g. Facebook Pixel, Google Ads, YouTube embeds)"
}
}
],
"blocked_domains": {
"functional": [
"*.crisp.chat",
"client.crisp.chat",
"*.intercom.io",
"widget.intercom.io",
"*.tawk.to",
"embed.tawk.to",
"cdn.weglot.com",
"*.weglot.com",
"*.usercentrics.eu"
],
"analytics": [
"google-analytics.com",
"*.google-analytics.com",
"googletagmanager.com",
"*.googletagmanager.com",
"ssl.google-analytics.com",
"*.hotjar.com",
"static.hotjar.com",
"*.mixpanel.com",
"cdn.mxpnl.com",
"*.amplitude.com",
"cdn.amplitude.com",
"*.segment.io",
"*.segment.com",
"wcs.naver.net",
"wcs.naver.com",
"*.beusable.net"
],
"marketing": [
"facebook.net",
"connect.facebook.net",
"facebook.com",
"*.facebook.com",
"doubleclick.net",
"*.doubleclick.net",
"googleadservices.com",
"googlesyndication.com",
"ads.google.com",
"*.criteo.com",
"static.criteo.net",
"*.adnxs.com",
"*.taboola.com",
"cdn.taboola.com",
"*.outbrain.com",
"*.kakao.com",
"analytics.ad.daum.net",
"platform.twitter.com",
"*.twitter.com",
"platform.linkedin.com",
"*.linkedin.com"
]
},
"default_blocked_domains_preview": {
"functional": [
"*.crisp.chat",
"client.crisp.chat",
"*.intercom.io",
"widget.intercom.io",
"*.tawk.to",
"embed.tawk.to",
"cdn.weglot.com",
"*.weglot.com",
"*.usercentrics.eu"
],
"analytics": [
"google-analytics.com",
"*.google-analytics.com",
"googletagmanager.com",
"*.googletagmanager.com",
"ssl.google-analytics.com",
"*.hotjar.com",
"static.hotjar.com",
"*.mixpanel.com",
"cdn.mxpnl.com",
"*.amplitude.com",
"cdn.amplitude.com",
"*.segment.io",
"*.segment.com",
"wcs.naver.net",
"wcs.naver.com",
"*.beusable.net"
],
"marketing": [
"facebook.net",
"connect.facebook.net",
"facebook.com",
"*.facebook.com",
"doubleclick.net",
"*.doubleclick.net",
"googleadservices.com",
"googlesyndication.com",
"ads.google.com",
"*.criteo.com",
"static.criteo.net",
"*.adnxs.com",
"*.taboola.com",
"cdn.taboola.com",
"*.outbrain.com",
"*.kakao.com",
"analytics.ad.daum.net",
"platform.twitter.com",
"*.twitter.com",
"platform.linkedin.com",
"*.linkedin.com"
]
}
}
},
"gdprMyConsent": {
"data": {
"has_consented": true,
"consents": {
"analytics": true,
"functional": true,
"marketing": true,
"necessary": true
},
"needs_renewal": false,
"current_policy_version": "1",
"is_member": true
}
},
"gdprMeConsents": {
"data": {
"user_id": 1,
"needs_renewal": false,
"current_policy_version": "1",
"consents": [
{
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
"can_revoke": false,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 3,
"consent_key": "cookie_analytics",
"consent_label": "분석 쿠키",
"consent_description": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 4,
"consent_key": "cookie_marketing",
"consent_label": "마케팅 쿠키",
"consent_description": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
}
]
}
},
"gdprMyConsents": {
"data": [
{
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
"can_revoke": false,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 3,
"consent_key": "cookie_analytics",
"consent_label": "분석 쿠키",
"consent_description": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
},
{
"id": 4,
"consent_key": "cookie_marketing",
"consent_label": "마케팅 쿠키",
"consent_description": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
"can_revoke": true,
"can_grant": false,
"needs_renewal_this_item": false,
"consented_at": "2026-05-24 09:32:38",
"revoked_at": null,
"consent_count": 1,
"policy_version": "1",
"last_source": "banner",
"created_at": "2026-05-24 09:32:38",
"updated_at": "2026-05-24 09:32:38"
}
]
}
}
},
"states": {
"comment": "GDPR 동의 로그 목록 유무.",
"groups": [
{
"comment": "consent-log — gdprConsentLog 데이터소스 빈 배열 시 빈 상태.",
"scope": {
"kind": "route",
"match": "*/admin/plugins/sirsoft-gdpr/consent-log"
},
"items": [
{
"id": "with_data",
"label": "$t:editor.state.with_data",
"default": true
},
{
"id": "empty_data",
"label": "$t:editor.state.empty_data",
"sampleDataOverrides": {
"byDataSourceId": {
"gdprConsentLog": {
"data": {
"data": [],
"meta": {
"total": 0,
"per_page": 20,
"current_page": 1
}
}
}
}
}
}
]
},
{
"comment": " states 전수 — 쿠키 배너(user_global_overlay EP 주입 조각). 배너 루트 if 가 gdprMyConsent.has_consented !== true 를 요구하는데 기본 샘플은 has_consented=true 라 미렌더. 미동의 사용자 샘플(base 샘플과 동일 shape 통째 교체)로 배너를 노출한다. default 항목은 템플릿 _user_base 그룹이 소유(concat — 중복 default 금지).",
"scope": {
"kind": "base",
"match": "_user_base"
},
"items": [
{
"id": "gdpr_cookie_banner_visible",
"label": "$t:editor.state.gdpr_cookie_banner_visible",
"sampleDataOverrides": {
"byDataSourceId": {
"gdprMyConsent": {
"data": {
"has_consented": false,
"consents": {
"analytics": false,
"functional": false,
"marketing": false,
"necessary": true
},
"needs_renewal": false,
"current_policy_version": "1",
"is_member": false
}
}
}
}
}
]
}
]
}
}
@@ -0,0 +1,267 @@
<?php
return [
'consent' => [
'granted' => 'Consent has been saved.',
'granted_again' => 'Consent has been renewed.',
'revoked' => 'Consent has been revoked.',
'renew_all_success' => '{renewed} active consent item(s) renewed to the new policy version.',
'granting' => 'Granting...',
'revoking' => 'Revoking...',
'renewing' => 'Renewing...',
'already_same_state' => 'Already in the same state.',
'required_cannot_revoke' => 'Required consent cannot be revoked.',
'invalid_key' => 'Invalid consent key.',
'category_cookie_necessary' => 'Strictly Necessary',
'category_cookie_functional' => 'Functional',
'category_cookie_analytics' => 'Analytics',
'category_cookie_marketing' => 'Marketing',
],
'blocked_domains' => [
'invalid_format' => 'Invalid domain format. (FQDN only; *.example.com wildcard supported)',
'invalid_format_functional' => 'Functional — Invalid domain format. (FQDN only; *.example.com wildcard supported)',
'invalid_format_analytics' => 'Analytics — Invalid domain format. (FQDN only; *.example.com wildcard supported)',
'invalid_format_marketing' => 'Marketing — Invalid domain format. (FQDN only; *.example.com wildcard supported)',
'too_long' => 'Domain cannot exceed 253 characters.',
'too_long_functional' => 'Functional — Domain cannot exceed 253 characters.',
'too_long_analytics' => 'Analytics — Domain cannot exceed 253 characters.',
'too_long_marketing' => 'Marketing — Domain cannot exceed 253 characters.',
'must_be_array' => 'Each category must be an array of domain strings.',
],
'settings' => [
'saved' => 'Settings have been saved.',
'title' => 'GDPR Settings',
'description' => 'Operators control the cookie consent banner and the MyPage privacy card.',
'section' => [
'operator' => 'Operator / Privacy Policy',
'operator_desc' => 'Register the operator name, data storage location, and privacy policy page slug displayed in the cookie banner and policy page. (GDPR Art.13 transparency obligation)',
'cookie_banner' => 'Cookie Consent Banner',
'cookie_banner_desc' => 'Configure visibility, position, categories, and policy version of the cookie consent banner — all in one place.',
'cookie_banner_settings' => 'Banner Display Settings',
'cookie_categories' => 'Cookie Categories',
'cookie_policy_version' => 'Cookie Policy Version',
'auto_blocking' => 'Auto-blocking Policy',
'auto_blocking_desc' => 'When the Cookie Banner is ON, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents. (No separate toggle — runs automatically with the banner.)',
],
'nav' => [
'operator' => 'Operator',
'cookie_banner' => 'Cookie Banner',
'auto_blocking_policy' => 'Auto-blocking Policy',
],
'status' => [
'enabled' => 'Enabled',
'disabled' => 'Disabled',
'visible' => 'Visible',
'hidden' => 'Hidden',
],
'fields' => [
'cookie_policy_version' => [
'label' => 'Policy Version',
'hint' => "Managed automatically by the system — operators do not enter values directly.\n• Adding/removing categories, category semantic changes, privacy policy page changes → new version auto-published + all members re-consent\n• Domain add/remove, label changes, etc. → no publication",
],
'privacy_policy_slug' => [
'label' => 'Privacy Policy Page Slug',
'hint' => 'Slug of the privacy policy page registered in the Page plugin. If empty, the policy link in the cookie banner is hidden.',
'placeholder' => 'e.g., privacy-policy',
],
'legal_entity_name' => [
'label' => 'Legal Entity / Operator Name',
'hint' => 'The operator name shown in the cookie banner and MyPage.',
'placeholder' => 'e.g., Acme Corp.',
],
'data_storage_location' => [
'label' => 'Data Storage Location',
'hint' => 'Country where user data is stored, shown to users. e.g., "United States (AWS)", "Germany". Do not enter IP addresses, cloud region codes (e.g., ap-northeast-2), or datacenter addresses — these are security-sensitive.',
'placeholder' => 'e.g., Korea (self-hosted IDC) / United States (AWS)',
'sensitive_format' => 'Data Storage Location cannot contain IP addresses, CIDR, or cloud region codes (e.g., ap-northeast-2). Use country names (e.g., Korea, United States) instead.',
],
'banner_enabled' => [
'label' => 'Show Cookie Banner',
'hint' => 'When enabled, the cookie consent banner, pre-consent auto-blocking, and the MyPage consent management card are activated together. (Auto-blocking is the enforcement mechanism for GDPR Art.6 "no processing before consent" and is unified into a single toggle to prevent operators from disabling it independently.)',
],
'banner_position' => [
'label' => 'Banner Position',
'hint' => 'Choose where the cookie banner appears on screen.',
'option_bottom_bar' => 'Bottom bar (full width)',
'option_bottom_left_popup' => 'Bottom-left popup',
'option_bottom_right_popup' => 'Bottom-right popup',
'option_centered_modal' => 'Centered modal',
],
'blocked_domains' => [
'warnings_title' => 'Notes',
'auto_blocking_off_hint' => 'Auto-blocking is only active when the Cookie Banner is ON. Enable Show Cookie Banner in the Cookie Banner card first.',
'warning_visual_break' => 'Be careful when classifying external font/stylesheet domains. Misclassification can break the page silently.',
'warning_policy_bump' => 'Bump the cookie policy version after changing the blocked domain list. This prevents existing consenters from being implicitly enrolled into newly added trackers.',
'warning_domain_format' => 'Domain format: at least one dot. Wildcard *.example.com supported. Single-label (localhost) and IDN domains (convert to xn-- first) are not supported.',
'tag_input_placeholder' => 'Type a domain and press Enter (e.g., google-analytics.com, *.hotjar.com)',
'tag_no_options' => 'No suggestions. Type to add.',
'category' => [
'functional' => 'Functional — Blocked Domains',
'analytics' => 'Analytics — Blocked Domains',
'marketing' => 'Marketing — Blocked Domains',
],
],
'cookie_categories' => [
'description' => 'Open each card\'s [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.',
'category_required_badge' => 'Required',
'category_optional_badge' => 'Optional',
],
],
'save_success' => 'Settings have been saved.',
'save_error' => 'An error occurred while saving settings.',
'validation_error' => 'Please check your inputs.',
'policy_version' => [
'current_label' => 'Current version',
'published_at_label' => 'Published at',
'publisher_label' => 'Publisher',
'publisher_system' => 'System',
'memo_label' => 'Change reason',
'publish_success' => 'A new policy version has been published. All members will be prompted to re-consent on their next visit.',
'publishing' => 'Publishing...',
'change_type' => [
'material' => 'Triggers re-consent',
'non_material' => 'Minor change',
'initial' => 'Initial publication',
],
'history_modal' => [
'title' => 'Policy version history',
'empty' => 'No policy versions have been published yet.',
],
'history' => [
'col' => [
'version' => 'Version',
'created_at' => 'Published at',
'publisher' => 'Publisher',
'change_type' => 'Type',
'memo' => 'Reason',
'snapshot_action' => 'Snapshot',
],
],
'snapshot_view_short' => 'View',
'material_modal' => [
'title' => 'Publish new policy version + trigger re-consent',
'body' => 'The following change will trigger an automatic policy version publication and require all members to re-consent.',
'effect_bump' => 'Automatic policy version publication (monotonic increment)',
'effect_reconsent' => 'All members will see the consent banner again on their next visit',
'effect_history' => 'A new version row will be added to the history modal',
'memo_label' => 'Change reason (required)',
'memo_placeholder' => 'e.g., Added analytics category (Hotjar integration)',
'confirm' => 'Publish new version and save',
],
'snapshot_modal' => [
'title' => 'Policy version v:version snapshot',
'description' => 'Full snapshot of settings at publication time — categories, privacy policy slug, blocked domains. Used by DPO as evidence in consent disputes.',
'section_categories' => 'Cookie categories',
'section_privacy_policy' => 'Privacy policy slug',
'section_blocked_domains' => 'Blocked domain catalog',
'category_required_badge' => 'Required',
'no_categories' => 'No categories registered.',
'no_blocked_domains' => 'No blocked domains registered.',
'view_button' => 'View v:version snapshot',
'close' => 'Close',
'open_policy_page' => 'Open policy page',
'no_policy_slug' => 'No privacy policy page registered.',
],
'not_found' => 'Policy version not found.',
],
],
'admin' => [
'consent_log' => [
'title' => 'GDPR Consent Log',
'description' => 'Audit consent and revocation history of members and guests. IP and User-Agent are included.',
'refresh' => 'Refresh',
'empty' => 'No consent log entries found.',
'clear_filters' => 'Clear filters',
'guest_label' => 'Guest',
'view_member_tooltip' => 'Open member detail page',
'search_placeholder' => 'Search by Email or Session ID',
'search_button' => 'Search',
'search_type' => [
'email' => 'Email',
'session_id' => 'Session ID',
],
'filter' => [
'consent_key_label' => 'Consent Key',
'action_label' => 'Action',
'source_label' => 'Source',
'option_all' => 'All',
],
'action' => [
'granted' => 'Granted',
'revoked' => 'Revoked',
],
'source' => [
'banner' => 'Banner',
'preference_center' => 'Preferences',
'mypage' => 'MyPage',
],
'col' => [
'created_at' => 'Time',
'subject' => 'Subject',
'consent_key' => 'Consent Key',
'action' => 'Action',
'source' => 'Source',
'policy_version' => 'Policy Version',
'ip' => 'IP',
],
'detail' => [
'session_id_label' => 'Session ID',
'user_agent_label' => 'User-Agent',
'categories_label' => 'Categories Snapshot',
'categories_hint' => "The member's category-by-category choices at the time of consent. This record stays immutable even after the policy is updated.",
'snapshot_granted' => 'Granted',
'snapshot_revoked' => 'Revoked',
],
],
],
'mypage' => [
'privacy' => [
'title' => 'Cookie Consent',
'description' => 'Change or revoke your consent for the cookies this site uses.',
'section_consents' => 'My Consents',
'section_consents_hint' => 'Changes apply immediately. Required items cannot be revoked.',
'col_consent_key' => 'Consent',
'col_consented' => 'Status',
'col_consented_at' => 'Consented At',
'col_revoked_at' => 'Revoked At',
'col_policy_version' => 'Policy Version',
'col_action' => 'Action',
'consented' => 'Consented',
'revoked' => 'Revoked',
'revoke' => 'Revoke',
'grant' => 'Consent',
'grant_again' => 'Consent Again',
'required_label' => 'Required',
'required_hint' => 'This item is required for service operation and cannot be revoked.',
'revoke_confirm_title' => 'Revoke Consent',
'revoke_confirm_message' => 'Are you sure you want to revoke this consent? Revoking consent may limit features related to that category.',
'no_consents' => 'No active consent items.',
'needs_renewal' => 'The cookie policy has been updated. Please renew your consent.',
'needs_renewal_with_version' => 'The cookie policy has been updated to version {version}. Please review the optional consent items.',
'needs_renewal_hint' => 'Strictly necessary cookies are essential for site operation and remain unchanged regardless of policy updates.',
'btn_renew_all' => 'Renew Current Consent',
'btn_renew_this_item' => 'Renew Policy',
'renew_all_success' => 'Renewed {renewed} active item(s) to the new policy version.',
],
],
'common' => [
'loading' => 'Loading...',
'refresh' => 'Refresh',
],
'banner' => [
're_appear_reason' => 'The cookie policy has been updated to version :version. Please review your consent.',
'consent_required_hint' => 'Features related to declined categories may be limited.',
'btn_renew_consent' => 'Renew Current Consent',
'title' => 'Cookie Notice',
'description' => 'This site uses cookies to improve user experience and for analytics. You can choose which cookies to allow.',
'accept_all' => 'Accept All',
'reject_all' => 'Necessary Only',
'preferences' => 'Preferences',
'save' => 'Save Preferences',
'policy_link' => 'Privacy Policy',
'preferences_title' => 'Cookie Preferences',
'preferences_description' => 'You can grant or revoke each category individually. Required categories cannot be turned off.',
'category_required_badge' => 'Required',
'view_policy_label' => 'View updated policy',
],
];
@@ -0,0 +1,267 @@
<?php
return [
'consent' => [
'granted' => '동의가 저장되었습니다.',
'granted_again' => '동의가 갱신되었습니다.',
'revoked' => '동의가 철회되었습니다.',
'renew_all_success' => '활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.',
'granting' => '동의 중...',
'revoking' => '철회 중...',
'renewing' => '갱신 중...',
'already_same_state' => '이미 동일한 상태입니다.',
'required_cannot_revoke' => '필수 항목은 철회할 수 없습니다.',
'invalid_key' => '유효하지 않은 동의 항목입니다.',
'category_cookie_necessary' => '필수 쿠키',
'category_cookie_functional' => '기능 쿠키',
'category_cookie_analytics' => '분석 쿠키',
'category_cookie_marketing' => '마케팅 쿠키',
],
'blocked_domains' => [
'invalid_format' => '올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)',
'invalid_format_functional' => '기능 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)',
'invalid_format_analytics' => '분석 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)',
'invalid_format_marketing' => '마케팅 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)',
'too_long' => '도메인은 253자를 초과할 수 없습니다.',
'too_long_functional' => '기능 카테고리 — 도메인은 253자를 초과할 수 없습니다.',
'too_long_analytics' => '분석 카테고리 — 도메인은 253자를 초과할 수 없습니다.',
'too_long_marketing' => '마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.',
'must_be_array' => '카테고리별 도메인 목록은 배열이어야 합니다.',
],
'settings' => [
'saved' => '설정이 저장되었습니다.',
'title' => 'GDPR 설정',
'description' => '쿠키 동의 배너와 마이페이지 동의 관리 카드의 동작을 운영자가 제어합니다.',
'section' => [
'operator' => '운영 주체 / 개인정보처리방침',
'operator_desc' => '쿠키 배너·정책 페이지에 표시될 운영 주체, 데이터 저장 위치, 정책 페이지 슬러그를 등록합니다. (GDPR Art.13 투명성 의무)',
'cookie_banner' => '쿠키 동의 배너',
'cookie_banner_desc' => '방문자에게 표시되는 쿠키 동의 배너의 노출/위치/카테고리/정책 버전을 한 곳에서 설정합니다.',
'cookie_banner_settings' => '배너 표시 설정',
'cookie_categories' => '쿠키 카테고리',
'cookie_policy_version' => '쿠키 정책 버전',
'auto_blocking' => '자동 차단 정책',
'auto_blocking_desc' => '쿠키 배너 노출이 ON 일 때 아래 카테고리별 차단 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다. (별도 토글 없음 — 배너 노출과 함께 자동 작동)',
],
'nav' => [
'operator' => '운영 정보',
'cookie_banner' => '쿠키 배너',
'auto_blocking_policy' => '자동 차단 정책',
],
'status' => [
'enabled' => '활성',
'disabled' => '비활성',
'visible' => '노출',
'hidden' => '숨김',
],
'fields' => [
'cookie_policy_version' => [
'label' => '정책 버전',
'hint' => "운영자가 직접 입력하지 않고 시스템이 자동 관리합니다.\n• 카테고리 추가/삭제, 카테고리 의미 변경, 정책 페이지 변경 → 새 버전 자동 발행 + 모든 회원 재동의\n• 도메인 추가/삭제, 라벨 변경 등 단순 변경 → 발행 없음",
],
'privacy_policy_slug' => [
'label' => '개인정보처리방침 페이지 슬러그',
'hint' => '페이지 플러그인에 등록된 처리방침 페이지의 슬러그. 비어 있으면 쿠키 배너의 정책 링크가 표시되지 않습니다.',
'placeholder' => '예: privacy-policy',
],
'legal_entity_name' => [
'label' => '법인명 / 운영 주체',
'hint' => '쿠키 배너와 마이페이지에 표시되는 사이트 운영 주체 이름입니다.',
'placeholder' => '예: (주)회사명',
],
'data_storage_location' => [
'label' => '데이터 저장 위치',
'hint' => '사용자에게 안내할 데이터 저장 국가를 입력하세요. 예: "대한민국", "미국 (AWS)". IP 주소, 클라우드 리전 코드(예: ap-northeast-2), 데이터센터 주소는 보안상 입력하지 마세요.',
'placeholder' => '예: 대한민국 (자체 데이터센터) / 미국 (AWS)',
'sensitive_format' => '데이터 저장 위치에 IP 주소, CIDR, 클라우드 리전 코드(예: ap-northeast-2) 는 입력할 수 없습니다. 국가명(예: 대한민국, 미국) 으로 표기하세요.',
],
'banner_enabled' => [
'label' => '쿠키 배너 노출',
'hint' => '활성 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드가 일괄 활성됩니다. (GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 차단을 분리 토글로 끄지 못하도록 단일 토글로 통합)',
],
'banner_position' => [
'label' => '배너 위치',
'hint' => '쿠키 배너가 화면에 표시되는 위치를 선택합니다.',
'option_bottom_bar' => '하단 (전체 폭)',
'option_bottom_left_popup' => '좌하단 팝업',
'option_bottom_right_popup' => '우하단 팝업',
'option_centered_modal' => '중앙 모달',
],
'blocked_domains' => [
'warnings_title' => '안내',
'auto_blocking_off_hint' => '자동 차단은 쿠키 배너 노출이 ON 일 때만 작동합니다. 「쿠키 배너」 카드에서 쿠키 배너 노출 토글을 먼저 켜세요.',
'warning_visual_break' => '외부 폰트·스타일시트는 분류에 주의하세요. 잘못 차단하면 페이지가 조용히 깨질 수 있습니다.',
'warning_policy_bump' => '차단 도메인을 변경한 후 쿠키 정책 버전을 올리세요. 기존 동의자가 새 도메인에도 동의한 것으로 처리되는 문제를 막습니다.',
'warning_domain_format' => '도메인 형식: 점이 1개 이상 포함된 도메인. 와일드카드 *.example.com 가능. 단일 라벨(localhost) 및 한글 도메인(xn-- 변환) 미지원.',
'tag_input_placeholder' => '도메인 입력 후 Enter (예: google-analytics.com, *.hotjar.com)',
'tag_no_options' => '추천 도메인이 없습니다. 직접 입력하세요.',
'category' => [
'functional' => '기능 카테고리 차단 도메인',
'analytics' => '분석 카테고리 차단 도메인',
'marketing' => '마케팅 카테고리 차단 도메인',
],
],
'cookie_categories' => [
'description' => '쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.',
'category_required_badge' => '필수',
'category_optional_badge' => '선택',
],
],
'save_success' => '설정이 저장되었습니다.',
'save_error' => '설정 저장 중 오류가 발생했습니다.',
'validation_error' => '입력 값을 확인해주세요.',
'policy_version' => [
'current_label' => '현재 버전',
'published_at_label' => '발행 일시',
'publisher_label' => '발행자',
'publisher_system' => '시스템',
'memo_label' => '변경 사유',
'publish_success' => '새 정책 버전이 발행되었습니다. 모든 회원이 다음 방문 시 재동의를 진행합니다.',
'publishing' => '발행 중...',
'change_type' => [
'material' => '재동의 트리거',
'non_material' => '단순 변경',
'initial' => '초기 발행',
],
'history_modal' => [
'title' => '정책 버전 발행 이력',
'empty' => '발행된 정책 버전이 없습니다.',
],
'history' => [
'col' => [
'version' => '버전',
'created_at' => '발행 일시',
'publisher' => '발행자',
'change_type' => '종류',
'memo' => '사유',
'snapshot_action' => '본문',
],
],
'snapshot_view_short' => '보기',
'material_modal' => [
'title' => '정책 버전 발행 + 재동의 트리거',
'body' => '다음 변경은 정책 버전 자동 발행과 모든 회원의 재동의 트리거를 발생시킵니다.',
'effect_bump' => '정책 버전 자동 발행 (단조 증가)',
'effect_reconsent' => '모든 회원의 다음 방문 시 재동의 화면 노출',
'effect_history' => '이력 모달에 새 버전 row 추가',
'memo_label' => '변경 사유 (필수)',
'memo_placeholder' => '예: 분석 카테고리 신설 (Hotjar 도입)',
'confirm' => '새 버전 발행 후 저장',
],
'snapshot_modal' => [
'title' => '정책 버전 v:version 본문',
'description' => '발행 시점 settings 의 카테고리 목록·정책 본문 슬러그·차단 도메인 등 전체 본문입니다. DPO 가 동의 분쟁 시 입증 자료로 활용합니다.',
'section_categories' => '쿠키 카테고리',
'section_privacy_policy' => '정책 본문 슬러그',
'section_blocked_domains' => '차단 도메인 카탈로그',
'category_required_badge' => '필수',
'no_categories' => '등록된 카테고리가 없습니다.',
'no_blocked_domains' => '등록된 차단 도메인이 없습니다.',
'view_button' => 'v:version 본문 보기',
'close' => '닫기',
'open_policy_page' => '본문 페이지 열기',
'no_policy_slug' => '등록된 정책 본문 페이지가 없습니다.',
],
'not_found' => '해당 정책 버전을 찾을 수 없습니다.',
],
],
'admin' => [
'consent_log' => [
'title' => 'GDPR 동의 이력',
'description' => '회원·게스트의 동의·철회 변경 이력을 조회합니다. IP·User-Agent 등 감사 정보가 함께 노출됩니다.',
'refresh' => '새로고침',
'empty' => '조회된 동의 이력이 없습니다.',
'clear_filters' => '필터 초기화',
'guest_label' => '게스트',
'view_member_tooltip' => '회원 상세 페이지로 이동',
'search_placeholder' => '이메일 또는 세션 ID 로 검색',
'search_button' => '검색',
'search_type' => [
'email' => '이메일',
'session_id' => '세션 ID',
],
'filter' => [
'consent_key_label' => '동의 항목',
'action_label' => '액션',
'source_label' => '출처',
'option_all' => '전체',
],
'action' => [
'granted' => '동의',
'revoked' => '철회',
],
'source' => [
'banner' => '배너',
'preference_center' => '환경설정',
'mypage' => '마이페이지',
],
'col' => [
'created_at' => '시점',
'subject' => '주체',
'consent_key' => '동의 항목',
'action' => '액션',
'source' => '출처',
'policy_version' => '정책 버전',
'ip' => 'IP',
],
'detail' => [
'session_id_label' => '세션 ID',
'user_agent_label' => 'User-Agent',
'categories_label' => '카테고리 스냅샷',
'categories_hint' => '동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.',
'snapshot_granted' => '동의',
'snapshot_revoked' => '거부',
],
],
],
'mypage' => [
'privacy' => [
'title' => '쿠키 동의 관리',
'description' => '사이트가 사용 중인 쿠키 항목에 대한 동의를 변경하거나 철회할 수 있습니다.',
'section_consents' => '내 동의 현황',
'section_consents_hint' => '동의/철회 시점에 따라 즉시 반영됩니다. 필수 항목은 철회할 수 없습니다.',
'col_consent_key' => '동의 항목',
'col_consented' => '상태',
'col_consented_at' => '동의일',
'col_revoked_at' => '철회일',
'col_policy_version' => '정책 버전',
'col_action' => '작업',
'consented' => '동의',
'revoked' => '철회',
'revoke' => '철회',
'grant' => '동의',
'grant_again' => '다시 동의',
'required_label' => '필수',
'required_hint' => '서비스 제공에 필수적이라 철회할 수 없습니다.',
'revoke_confirm_title' => '동의 철회 확인',
'revoke_confirm_message' => '이 항목을 철회하시겠습니까? 동의를 철회하면 해당 카테고리 관련 기능이 제한될 수 있습니다.',
'no_consents' => '활성 동의 항목이 없습니다.',
'needs_renewal' => '쿠키 정책이 변경되었습니다. 동의를 갱신해주세요.',
'needs_renewal_with_version' => '쿠키 정책이 버전 {version} 으로 변경되었습니다. 선택 항목의 동의를 다시 확인해주세요.',
'needs_renewal_hint' => '필수 쿠키는 사이트 운영에 꼭 필요한 항목이므로 정책 변경과 무관하게 항상 적용됩니다.',
'btn_renew_all' => '현재 동의 갱신',
'btn_renew_this_item' => '정책 갱신',
'renew_all_success' => '활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.',
],
],
'common' => [
'loading' => '불러오는 중...',
'refresh' => '새로고침',
],
'banner' => [
're_appear_reason' => '쿠키 정책이 버전 :version 로 변경되어 동의를 다시 확인합니다.',
'consent_required_hint' => '거부한 항목과 관련된 기능이 제한될 수 있습니다.',
'btn_renew_consent' => '현재 동의 갱신',
'title' => '쿠키 사용 안내',
'description' => '본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.',
'accept_all' => '모두 동의',
'reject_all' => '필수만 사용',
'preferences' => '환경설정',
'save' => '선택 동의',
'policy_link' => '개인정보처리방침',
'preferences_title' => '쿠키 환경설정',
'preferences_description' => '카테고리별 쿠키 사용을 개별 동의/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.',
'category_required_badge' => '필수',
'view_policy_label' => '변경된 정책 본문 보기',
],
];
@@ -0,0 +1,22 @@
{
"name": "@plugins/sirsoft-gdpr",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.0.0",
"@vitest/ui": "^2.1.0",
"jsdom": "^25.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"identifier": "sirsoft-gdpr",
"vendor": "sirsoft",
"name": {
"ko": "GDPR (일반 데이터 보호 규정)",
"en": "GDPR (General Data Protection Regulation)"
},
"version": "1.0.0",
"description": {
"ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.",
"en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal."
},
"license": "MIT",
"g7_version": ">=7.0.0",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-gdpr",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-gdpr/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"css": {
"entry": "resources/css/banner.css",
"output": "dist/css/plugin.css"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 50
}
}
+546
View File
@@ -0,0 +1,546 @@
<?php
namespace Plugins\Sirsoft\Gdpr;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\MenuPermissionType;
use App\Extension\AbstractPlugin;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Models\Menu;
/**
* GDPR (일반 데이터 보호 규정) 플러그인
*
* 쿠키 동의 배너, 동의 이력 저장, 마이페이지 동의 철회 등 GDPR 핵심 대응
* 기능을 제공합니다 (F-01/F-02/F-03/F-04).
*/
class Plugin extends AbstractPlugin
{
/**
* 기본 차단 도메인 카탈로그 (BE SSoT).
*
* 신규 설치 시 `blocked_domains` 기본값 + `GdprSettingsController` 응답
* `default_blocked_domains_preview` 의 데이터 출처입니다.
*
* SSoT 동기 의무: 본 상수는 클라이언트 코드 상수 `resources/js/blocker-domains.ts`
* 의 `DEFAULT_BLOCKED_DOMAINS` 와 동일하게 유지합니다. 한쪽 갱신 시 다른 쪽도
* 같은 PR 에서 갱신해야 하며, 어긋날 경우 신규 설치 사이트와 관리자 UI 추천
* 도메인이 불일치합니다.
*
* @var array<string, array<int, string>>
*/
public const DEFAULT_BLOCKED_DOMAINS_CATALOG = [
// Phase 2: 외부 functional 서비스 도메인 카탈로그 — 운영자가 admin UI 의 functional 차단 도메인
// TagInput 자동완성 추천으로 노출. 운영자가 사이트에 해당 도구 도입 시 클릭으로 등록.
// 외부 functional 도구는 회색 영역 (analytics/marketing 분류도 가능) — 운영자 판단 영역.
'functional' => [
// 고객지원 챗봇
'*.crisp.chat',
'client.crisp.chat',
'*.intercom.io',
'widget.intercom.io',
'*.tawk.to',
'embed.tawk.to',
// 다국어 자동 번역 위젯
'cdn.weglot.com',
'*.weglot.com',
// 사용자 설정 외부 서비스
'*.usercentrics.eu',
],
'analytics' => [
'google-analytics.com',
'*.google-analytics.com',
'googletagmanager.com',
'*.googletagmanager.com',
'ssl.google-analytics.com',
'*.hotjar.com',
'static.hotjar.com',
'*.mixpanel.com',
'cdn.mxpnl.com',
'*.amplitude.com',
'cdn.amplitude.com',
'*.segment.io',
'*.segment.com',
'wcs.naver.net',
'wcs.naver.com',
'*.beusable.net',
],
'marketing' => [
'facebook.net',
'connect.facebook.net',
'facebook.com',
'*.facebook.com',
'doubleclick.net',
'*.doubleclick.net',
'googleadservices.com',
'googlesyndication.com',
'ads.google.com',
'*.criteo.com',
'static.criteo.net',
'*.adnxs.com',
'*.taboola.com',
'cdn.taboola.com',
'*.outbrain.com',
'*.kakao.com',
'analytics.ad.daum.net',
'platform.twitter.com',
'*.twitter.com',
'platform.linkedin.com',
'*.linkedin.com',
],
];
/**
* 플러그인이 제공하는 훅 정보 반환
*
* @return array 훅 정의 배열 (action 2개)
*/
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-gdpr.consent.granted',
'type' => 'action',
'description' => [
'ko' => '동의 부여 시 발화',
'en' => 'Fired when consent is granted',
],
'parameters' => [
'consent' => 'Model - GdprUserConsent',
'source' => 'string - banner|preference_center|register|mypage',
],
],
[
'name' => 'sirsoft-gdpr.consent.revoked',
'type' => 'action',
'description' => [
'ko' => '동의 철회 시 발화',
'en' => 'Fired when consent is revoked',
],
'parameters' => [
'consent' => 'Model - GdprUserConsent',
'source' => 'string - 철회 발생 경로',
],
],
];
}
/**
* 관리자 메뉴 정의
*
* 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로
* 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다.
* 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원.
*
* @return array<int, array<string, mixed>>
*/
public function getAdminMenus(): array
{
// 단독 평면 메뉴 — 본인인증 이력(admin-identity-logs)과 일관된 형태.
// 「설정」은 코어「플러그인 관리 → 설정」 자동 경로 사용 (중복 제거)
return [
[
'name' => ['ko' => 'GDPR 동의 이력', 'en' => 'GDPR Consent Log'],
'slug' => 'sirsoft-gdpr-consent-log',
'url' => '/admin/plugins/sirsoft-gdpr/consent-log',
'icon' => 'fas fa-user-shield',
'order' => 50,
],
];
}
/**
* 플러그인 활성화 — 관리자 메뉴 자동 등록 + privacy 역할 권한 부여.
*
* 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은
* PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다.
*
* helper.grantDefaultRoles 는 admin 역할에만 메뉴 권한을 부여하므로,
* sirsoft-gdpr.privacy 역할에는 별도로 role_menus 피벗에 부여한다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool
{
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($this->getAdminMenus() as $menuData) {
$menu = $helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
);
$this->grantPrivacyRoleToMenuTree($menu);
}
return true;
}
/**
* 플러그인 비활성화 — 관리자 메뉴 일괄 제거.
*
* cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다.
* 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool
{
app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus(
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
currentSlugs: [],
);
return true;
}
/**
* 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리).
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
{
$this->deactivate();
return true;
}
/**
* 메뉴 트리에 sirsoft-gdpr.privacy 역할의 read 권한을 재귀 부여한다.
*
* helper.grantDefaultRoles 는 admin 역할만 자동 부여하므로 privacy 역할은 별도 처리 필요.
*
* @param Menu $menu 대상 메뉴 (자식 포함)
*/
private function grantPrivacyRoleToMenuTree(Menu $menu): void
{
$role = app(RoleRepositoryInterface::class)->findByIdentifier('sirsoft-gdpr.privacy');
if ($role === null) {
return;
}
$menu->roles()->syncWithoutDetaching([
$role->id => ['permission_type' => MenuPermissionType::Read->value],
]);
foreach ($menu->children as $child) {
$this->grantPrivacyRoleToMenuTree($child);
}
}
/**
* 플러그인 권한 목록 반환 (계층 구조)
*
* PluginManager가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록.
* 모든 권한은 admin·sirsoft-gdpr.privacy 두 역할에 매핑.
*
* 권한 분할 의도:
* - view: 동의 이력·설정 조회 (감사관/모니터링 read-only)
* - update: 쿠키 카테고리·정책 버전 등 설정 변경 (정책 설정자)
*
* @return array 권한 정의 배열 (categories 계층 구조)
*/
public function getPermissions(): array
{
return [
'name' => [
'ko' => 'GDPR (일반 데이터 보호 규정)',
'en' => 'GDPR (General Data Protection Regulation)',
],
'description' => [
'ko' => 'GDPR 플러그인이 제공하는 권한',
'en' => 'Permissions provided by the GDPR plugin',
],
'categories' => [
[
'identifier' => 'privacy',
'name' => ['ko' => '개인정보 보호', 'en' => 'Privacy'],
'description' => [
'ko' => 'GDPR 도메인 권한 (조회·설정)',
'en' => 'GDPR domain permissions (view, update settings)',
],
'permissions' => [
[
'action' => 'view',
'name' => ['ko' => '개인정보 조회', 'en' => 'View Privacy'],
'description' => [
'ko' => '동의 이력·GDPR 설정 조회 (감사·모니터링)',
'en' => 'View consent log and GDPR settings (audit / monitoring)',
],
'type' => 'admin',
'roles' => ['admin', 'sirsoft-gdpr.privacy'],
],
[
'action' => 'update',
'name' => ['ko' => '개인정보 설정 변경', 'en' => 'Update Privacy Settings'],
'description' => [
'ko' => '쿠키 카테고리·정책 버전 등 GDPR 플러그인 설정 변경',
'en' => 'Modify GDPR plugin settings (cookie categories, policy version, etc.)',
],
'type' => 'admin',
'roles' => ['admin', 'sirsoft-gdpr.privacy'],
],
],
],
],
];
}
/**
* 플러그인 역할 목록 반환
*
* @return array 역할 정의 배열
*/
public function getRoles(): array
{
return [
[
'identifier' => 'sirsoft-gdpr.privacy',
'name' => ['ko' => '개인정보 운영자', 'en' => 'Privacy Operator'],
'description' => [
'ko' => 'GDPR 도메인 운영 권한 (설정·동의 이력 조회)',
'en' => 'Operational authority over GDPR domain (settings, consent log)',
],
],
];
}
/**
* 플러그인이 동적으로 생성한 테이블 목록 반환
*
* 언인스톨 시 PluginManager가 일괄 삭제 (FK 순서대로)
*
* @return array 테이블명 배열
*/
public function getDynamicTables(): array
{
return [
'gdpr_user_consent_histories',
'gdpr_user_consents',
'gdpr_policy_versions',
];
}
/**
* 설치·업데이트 시 실행할 시더 목록 반환
*
* 권한·역할은 PluginManager 가 getRoles()/getPermissions() 로 자동 등록·동기화하므로
* 별도 시더가 불필요하다.
*
* @return array 시더 클래스 배열
*/
public function getSeeders(): array
{
return [];
}
/**
* 플러그인 설정 값 반환 (기본값)
*
* @return array 설정 값 배열
*/
public function getConfigValues(): array
{
return [
// 정책 메타데이터 — cookie_policy_version 은 gdpr_policy_versions 테이블이 SSoT
// (마이그레이션 시 initial 행 자동 시드). 본 settings 에서는 보관하지 않음.
'privacy_policy_slug' => 'privacy',
'legal_entity_name' => '',
'data_storage_location' => '',
// 쿠키 배너 + 자동 차단 (F-01 / F-02) — banner_enabled 단일 토글로 통합 제어.
// ON 시 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성.
// 차단을 별도 토글로 제공하지 않는 이유: GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘
// 인 차단을 운영자가 단독 OFF 할 수 있으면 위반 조합 (배너 ON + 차단 OFF) 가능 → CNIL
// Microsoft €60M / Amazon €35M / Google €100M 처벌 패턴과 동일. 단일 토글로 구조적 차단.
//
// 기본값 true: 플러그인 활성화 = 운영자의 GDPR 컴플라이언스 의사 표명. 시장 표준 CMP
// (OneTrust / Cookiebot / Iubenda / Klaro) 모두 "활성화 = 즉시 작동" 패턴. 운영자가
// 컴플라이언스 임시 비활성을 원할 때만 OFF.
'banner_enabled' => true,
'banner_position' => 'bottom_bar',
// F-02 도메인 기반 차단 — 카테고리별 운영자 입력.
// 게스트도 차단 동작해야 하므로 공개 응답에 노출 (defaults.json frontend_schema).
// 신규 설치 시 카탈로그 도메인이 채워져 있어 운영자가 토글 ON 만으로
// 즉시 GA·Meta 등 차단 시작 가능. 운영자는 칩 X 클릭으로 빼고 싶은 도메인
// 제거 가능. 카탈로그 갱신 동기화는 관리자 UI 의 "(+ 카탈로그에서 도메인
// 가져오기)" 링크가 클라이언트 측 Set 합집합으로 처리.
'blocked_domains' => self::DEFAULT_BLOCKED_DOMAINS_CATALOG,
'cookie_categories' => json_encode([
[
'key' => 'necessary',
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
],
],
[
'key' => 'analytics',
'required' => false,
'label' => ['ko' => '분석 쿠키', 'en' => 'Analytics'],
'description' => [
'ko' => '방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)',
'en' => 'Used to anonymously measure how visitors use the site so we can improve it. (e.g. Google Analytics, Hotjar)',
],
],
[
'key' => 'marketing',
'required' => false,
'label' => ['ko' => '마케팅 쿠키', 'en' => 'Marketing'],
'description' => [
'ko' => '관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)',
'en' => 'Used to show ads relevant to your interests, measure ad performance, and embed social media content. (e.g. Facebook Pixel, Google Ads, YouTube embeds)',
],
],
]),
// Phase 2 단순화: 운영자 등록 표 (functional_storage_keys / functional_cookies /
// functional_allow_user_initiated) 는 GDPR 원칙과 충돌하여 제거됨. 게이팅은
// strictly necessary allowlist (코드 상수) 외 모든 비-필수 저장을 동의 시까지 차단하는
// 4단계 단순화 규칙으로 처리. WP29 §3.6 user-initiated 면제는 항상 활성.
];
}
/**
* 플러그인 설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
// 정책 메타데이터 — cookie_policy_version 은 gdpr_policy_versions 테이블이 SSoT.
// 운영자는 직접 입력하지 않고, Material 변경 (카테고리 추가/삭제 등) 시 시스템이 자동 발행.
'privacy_policy_slug' => [
'type' => 'string',
'default' => 'privacy',
'label' => ['ko' => '개인정보처리방침 페이지 슬러그', 'en' => 'Privacy Policy Page Slug'],
'hint' => [
'ko' => 'sirsoft-page 모듈의 페이지 slug. 미설치 또는 페이지 부재 시 관련 링크는 자동 숨김됩니다.',
'en' => 'Slug of the sirsoft-page page. Links auto-hide when page is missing.',
],
'required' => false,
],
'legal_entity_name' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '운영 주체명', 'en' => 'Legal Entity Name'],
'hint' => [
'ko' => '개인정보처리방침에 표시될 회사·서비스명. 예: "(주)홍길동컴퍼니"',
'en' => 'Company/service name shown in privacy policy.',
],
'required' => false,
],
'data_storage_location' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '데이터 저장 위치', 'en' => 'Data Storage Location'],
'hint' => [
'ko' => '개인정보가 저장되는 물리적 위치. 예: "한국 (서울 IDC)", "AWS 서울 리전"',
'en' => 'Physical location where personal data is stored.',
],
'required' => false,
],
// 쿠키 배너 + 자동 차단 + 마이페이지 카드 단일 토글 (banner_enabled)
// 기본값 true — 플러그인 활성화 = 운영자의 GDPR 컴플라이언스 의사 표명.
'banner_enabled' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '쿠키 배너 노출', 'en' => 'Show Cookie Banner'],
'hint' => [
'ko' => 'ON 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성. 정책 슬러그·카테고리 설정 후 켜는 것을 권장.',
'en' => 'When ON, the cookie consent banner, pre-consent auto-blocking, and the MyPage consent management card are activated together.',
],
'required' => false,
],
'banner_position' => [
'type' => 'string',
'default' => 'bottom_bar',
'label' => ['ko' => '배너 위치', 'en' => 'Banner Position'],
'hint' => [
'ko' => 'bottom_bar(하단 바) / bottom_left_popup / bottom_right_popup / centered_modal',
'en' => 'Position of the cookie banner.',
],
'required' => false,
],
'blocked_domains' => [
'type' => 'json',
'default' => json_encode(self::DEFAULT_BLOCKED_DOMAINS_CATALOG),
'label' => ['ko' => '추적 도메인 차단 목록', 'en' => 'Tracking Domain Block List'],
'hint' => [
'ko' => '카테고리별 차단 도메인 목록(JSON 객체). 신규 설치 시 카탈로그 도메인이 채워져 있으며 운영자가 추가·삭제 가능. FQDN 만 지원하며 *.example.com 와일드카드 가능.',
'en' => 'Per-category blocked domain list (JSON object). Pre-filled with the catalog on fresh install; operators can add/remove domains. FQDN only; supports *.example.com wildcard.',
],
'required' => false,
],
'cookie_categories' => [
'type' => 'json',
'default' => '[]',
'label' => ['ko' => '쿠키 카테고리 정의', 'en' => 'Cookie Categories'],
'hint' => [
'ko' => '카테고리는 4종 고정(necessary/functional/analytics/marketing). 운영자 편집 입력 없음.',
'en' => 'The four categories are fixed (necessary/functional/analytics/marketing). No operator-editable inputs.',
],
'required' => false,
],
];
}
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['gdpr', 'privacy', 'consent', 'cookie', 'data-protection'],
];
}
/**
* 훅 리스너 목록 반환
*
* PluginManager 가 활성화된 플러그인에 대해 자동으로
* HookListenerRegistrar::register() 호출.
*
* @return array 훅 리스너 클래스 배열
*/
public function getHookListeners(): array
{
return [
\Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener::class,
\Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener::class,
\Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener::class,
\Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener::class,
];
}
}
@@ -0,0 +1,52 @@
/**
* sirsoft-gdpr 쿠키 배너·환경설정 모달 스타일
*
* 레이아웃 확장(`cookie_banner.json` 등)이 Tailwind 클래스로 대부분 스타일링하므로
* 본 파일은 다음 두 가지에 한정:
*
* 1. JS 토글용 유틸리티 클래스 (`.gdpr-hidden`)
* 2. 자동 차단 엔진의 placeholder 스타일 (`.gdpr-blocked-embed`)
*
* 다크 모드는 Tailwind `dark:` variant 와 OS 미디어 쿼리 양쪽 지원.
*/
/* 배너/모달 숨김 토글 — JS 가 add/remove */
.gdpr-hidden {
display: none !important;
}
/* 자동 차단된 임베드의 플레이스홀더
blocker.ts 가 script/iframe 을 div.gdpr-blocked-embed 로 치환할 때 사용.
기본은 inline-flex 0 사이즈 (사용자에게 안 보이도록).
카테고리별 플레이스홀더 UI 가 필요한 경우 사이트 측에서 오버라이드 가능. */
.gdpr-blocked-embed {
display: none;
}
.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden='true']) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: #6b7280; /* gray-500 */
background-color: #f9fafb; /* gray-50 */
border: 1px dashed #d1d5db; /* gray-300 */
border-radius: 0.25rem;
}
@media (prefers-color-scheme: dark) {
.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden='true']) {
color: #9ca3af; /* gray-400 */
background-color: rgba(31, 41, 55, 0.5); /* gray-800/50 */
border-color: #4b5563; /* gray-600 */
}
}
/* 페이지에 .dark 클래스가 있는 경우 (Tailwind dark variant) */
.dark .gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden='true']) {
color: #9ca3af;
background-color: rgba(31, 41, 55, 0.5);
border-color: #4b5563;
}
@@ -0,0 +1,587 @@
{
"extension_point": "user_global_overlay",
"mode": "append",
"data_sources": [
{
"id": "gdprPublicSettings",
"label_key": "$t:sirsoft-gdpr.editor.data_source.gdprPublicSettings",
"type": "api",
"endpoint": "/api/plugins/sirsoft-gdpr/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": false
},
{
"comment": "현재 방문자(회원/게스트)의 쿠키 동의 완료 여부 — 새로고침 후에도 배너 재출력 방지용. 동의 저장 후 refetch. auth_mode:optional 로 토큰 있으면 회원 상태, 없으면 게스트 상태로 조회 (서버 라우트 optional.sanctum 와 짝).",
"id": "gdprMyConsent",
"label_key": "$t:sirsoft-gdpr.editor.data_source.gdprMyConsent",
"type": "api",
"endpoint": "/api/plugins/sirsoft-gdpr/consent/cookie/status",
"method": "GET",
"auto_fetch": true,
"auth_mode": "optional"
}
],
"components": [
{
"id": "gdpr_cookie_banner",
"type": "basic",
"name": "Div",
"comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissed 도 함께 검사",
"if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}",
"props": {
"className": "fixed z-50 {{(gdprPublicSettings?.data?.banner_position === 'bottom_left_popup') ? 'bottom-4 left-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'bottom_right_popup') ? 'bottom-4 right-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'centered_modal') ? 'top-1/2 -translate-y-1/2 inset-x-4 mx-auto max-w-md rounded-lg' : 'bottom-4 left-4 right-4 rounded-lg'))}} bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-4 sm:px-6 sm:py-4"
},
"children": [
{
"comment": "=== 상단: 안내 메시지 (위) + 버튼 3종 (아래, 우측 정렬) + 운영 주체 푸터 라인 (최하단) ===",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "cookie-bite",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-gdpr.banner.title"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-gdpr.banner.description"
},
{
"comment": "A-6 — needs_renewal=true 일 때 재출현 사유 + 거부 시 결과 안내 (Art.5 투명성). 정책 본문 링크는 기존 banner.policy_link 가 needs_renewal 시 라벨 변경 + 강조 처리 (중복 링크 방지). 다크모드 가독성: solid 색 사용 (불투명도 회피).",
"type": "basic",
"name": "Div",
"if": "{{gdprMyConsent?.data?.needs_renewal === true}}",
"props": {
"className": "mt-2 px-2 py-1 text-xs text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-800 border border-amber-300 dark:border-amber-600 rounded space-y-1"
},
"children": [
{
"type": "basic",
"name": "P",
"text": "$t:sirsoft-gdpr.banner.re_appear_reason|version={{gdprMyConsent?.data?.current_policy_version ?? '?'}}"
},
{
"comment": "거부 시 결과 안내 — 일반 동의 거부 안내 표현 (글로벌 CMP 패턴). cookie wall 우려 회피하면서도 사용자가 *어떤 기능이 영향받는지* 인지하도록.",
"type": "basic",
"name": "P",
"props": { "className": "text-amber-800 dark:text-amber-200" },
"text": "$t:sirsoft-gdpr.banner.consent_required_hint"
}
]
},
{
"comment": "개인정보처리방침 — 동의 결정 컨텍스트 보존 위해 새 탭으로 오픈 (rel=noopener,noreferrer). needs_renewal=true 시 라벨이 '변경된 정책 본문 보기' 로 바뀌고 amber 톤으로 강조됨 (중복 링크 방지 — 사유 박스 내부 별도 링크 미사용).",
"type": "basic",
"name": "A",
"if": "{{gdprPublicSettings?.data?.privacy_policy_available === true}}",
"props": {
"href": "/page/{{gdprPublicSettings?.data?.privacy_policy_slug ?? ''}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "{{gdprMyConsent?.data?.needs_renewal === true ? 'inline-flex items-center gap-1 mt-1 text-xs font-semibold text-amber-700 dark:text-amber-300 underline hover:no-underline' : 'inline-flex items-center gap-1 mt-1 text-xs text-blue-600 dark:text-blue-400 hover:underline'}}"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{gdprMyConsent?.data?.needs_renewal === true ? '$t:sirsoft-gdpr.banner.view_policy_label' : '$t:sirsoft-gdpr.banner.policy_link'}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"size": "xs",
"className": "opacity-70"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center justify-end gap-2"
},
"children": [
{
"comment": "환경설정 토글 — 배너 내부 인라인 펼침 영역을 열고 닫음",
"id": "gdpr_preferences_toggle_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"gdprPreferencesOpen": "{{!_global.gdprPreferencesOpen}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-gdpr.banner.preferences"
}
]
},
{
"comment": "필수만 사용 — required 카테고리만 true 로 저장 후 배너 닫기. gdprBannerSubmittingAction 식별자(string)로 어느 버튼이 진행 중인지 추적 — 클릭한 버튼만 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지 (어느 버튼을 눌렀는지 시각적 인지 가능)",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{!!_global.gdprBannerSubmittingAction}}",
"className": "px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "reject_all" } },
{
"handler": "apiCall",
"auth_mode": "optional",
"target": "/api/plugins/sirsoft-gdpr/consent/cookie",
"params": {
"method": "POST",
"body": "{{{ consents: (gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, ['cookie_' + c.key]: c.required === true }), {}), source: 'banner' }}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}"
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "gdprMyConsent" }
},
{
"comment": "자동 차단 엔진 동의 메모리 캐시 갱신 + rescan",
"handler": "sirsoft-gdpr.syncConsent"
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-gdpr.consent.granted"
}
}
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } },
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error?.message ?? 'Error'}}"
}
}
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'reject_all'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'reject_all'}}", "text": "$t:sirsoft-gdpr.banner.reject_all" },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'reject_all'}}", "text": "$t:sirsoft-gdpr.consent.granting" }
]
},
{
"comment": "현재 동의 갱신 (회원) — needs_renewal=true 한정. 회원은 status 테이블에 동의가 영속되므로 renew-all 로 활성 동의의 policy_version 만 신정책으로 bump (사용자 의사 변경 없음, Art.7(3) 자유 변경권 + Recital 32 명확한 긍정 행위 부합). 위치: 환경설정/필수만 사용 다음, 모두 동의 앞.",
"id": "gdpr_keep_consent_button",
"type": "basic",
"name": "Button",
"if": "{{gdprMyConsent?.data?.is_member === true && gdprMyConsent?.data?.needs_renewal === true}}",
"props": {
"type": "button",
"disabled": "{{!!_global.gdprBannerSubmittingAction}}",
"className": "px-4 py-2 text-sm font-medium text-white bg-amber-600 dark:bg-amber-700 rounded hover:bg-amber-700 dark:hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "renew_consent" } },
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-gdpr/consent/renew-all",
"params": { "method": "POST" },
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissed": true } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsent" } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.renew_all_success|renewed={{response?.data?.renewed ?? 0}}" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } },
{ "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } }
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'renew_consent'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'renew_consent'}}", "text": "$t:sirsoft-gdpr.banner.btn_renew_consent" },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'renew_consent'}}", "text": "$t:sirsoft-gdpr.consent.renewing" }
]
},
{
"comment": "모두 동의 — 모든 카테고리 true 로 저장 후 배너 닫기. 회원 + needs_renewal=true 일 때만 미노출 (이전 철회 의사 덮어쓰기 회피 — 옆 keep_consent 버튼이 활성 동의만 갱신). 게스트는 needs_renewal 여부와 무관하게 항상 노출.",
"type": "basic",
"name": "Button",
"if": "{{!(gdprMyConsent?.data?.is_member === true && gdprMyConsent?.data?.needs_renewal === true)}}",
"props": {
"type": "button",
"disabled": "{{!!_global.gdprBannerSubmittingAction}}",
"className": "px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "accept_all" } },
{
"handler": "apiCall",
"auth_mode": "optional",
"target": "/api/plugins/sirsoft-gdpr/consent/cookie",
"params": {
"method": "POST",
"body": "{{{ consents: (gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, ['cookie_' + c.key]: true }), {}), source: 'banner' }}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: true }), {})}}"
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "gdprMyConsent" }
},
{
"comment": "자동 차단 엔진 동의 메모리 캐시 갱신 + rescan",
"handler": "sirsoft-gdpr.syncConsent"
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-gdpr.consent.granted"
}
}
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } },
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error?.message ?? 'Error'}}"
}
}
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'accept_all'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'accept_all'}}", "text": "$t:sirsoft-gdpr.banner.accept_all" },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'accept_all'}}", "text": "$t:sirsoft-gdpr.consent.granting" }
]
}
]
}
]
},
{
"comment": "운영 주체 + 데이터 저장 위치 푸터 라인 — EDPB Guidelines 05/2020 'controller identity must be apparent from the first information layer' 충족 + GDPR Art.13(1)(f) 저장 위치 고지. 3-case 분기로 자연어 한 줄 표현: (1) 둘 다 / (2) 운영자만 / (3) 저장위치만. 둘 다 비면 라인 전체 숨김 (어색한 빈 라벨 회피).",
"id": "gdpr_banner_operator_footer",
"type": "basic",
"name": "Div",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}",
"props": {
"className": "mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.banner.operator_and_storage_line|entity={{gdprPublicSettings?.data?.legal_entity_name ?? ''}}|location={{gdprPublicSettings?.data?.data_storage_location ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name && !gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.banner.operator_only_line|entity={{gdprPublicSettings?.data?.legal_entity_name ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.banner.storage_only_line|location={{gdprPublicSettings?.data?.data_storage_location ?? ''}}"
}
]
},
{
"comment": "=== 하단: 환경설정 인라인 펼침 영역 (preferences_toggle_button 으로 토글) ===",
"id": "gdpr_preferences_panel",
"type": "basic",
"name": "Div",
"if": "{{_global.gdprPreferencesOpen === true}}",
"props": {
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-gdpr.banner.preferences_description"
},
{
"comment": "카테고리 토글 목록",
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-100 dark:divide-gray-700"
},
"iteration": {
"source": "gdprPublicSettings?.data?.cookie_categories ?? []",
"item_var": "category",
"index_var": "categoryIdx"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between gap-3 px-4 py-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-semibold text-gray-900 dark:text-white"
},
"text": "{{category?.label?.ko ?? category?.label?.en ?? category?.key ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{category?.required === true}}",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300"
},
"text": "$t:sirsoft-gdpr.banner.category_required_badge"
}
]
},
{
"comment": "카테고리 description — 사용자 친화 한 줄 (시더의 user_info 표현 사용. 이전 자세히 보기 펼침 영역과 중복되어 펼침 영역 통째 제거)",
"type": "basic",
"name": "P",
"if": "{{!!category?.description?.ko || !!category?.description?.en}}",
"props": {
"className": "mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400"
},
"text": "{{category?.description?.ko ?? category?.description?.en ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Checkbox",
"props": {
"checked": "{{(_global.gdprConsent ?? {})[category?.key] === true || category?.required === true}}",
"disabled": "{{category?.required === true}}",
"className": "h-5 w-5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"gdprConsent": "{{{ ...(_global.gdprConsent ?? {}), [category?.key]: $event.target.checked }}}"
}
}
]
}
]
}
]
},
{
"comment": "저장 버튼 — 현재 _global.gdprConsent 그대로 저장 후 배너 닫기",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{!!_global.gdprBannerSubmittingAction}}",
"className": "px-4 py-2 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "save_selection" } },
{
"handler": "apiCall",
"auth_mode": "optional",
"target": "/api/plugins/sirsoft-gdpr/consent/cookie",
"params": {
"method": "POST",
"body": "{{{ consents: (gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, ['cookie_' + c.key]: ((_global.gdprConsent ?? {})[c.key] === true) || c.required === true }), {}), source: 'banner' }}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprPreferencesOpen": false
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "gdprMyConsent" }
},
{
"comment": "자동 차단 엔진 동의 메모리 캐시 갱신 + rescan",
"handler": "sirsoft-gdpr.syncConsent"
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-gdpr.consent.granted"
}
}
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } },
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error?.message ?? 'Error'}}"
}
}
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'save_selection'}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'save_selection'}}", "text": "$t:sirsoft-gdpr.banner.save" },
{ "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'save_selection'}}", "text": "$t:sirsoft-gdpr.consent.granting" }
]
}
]
}
]
}
]
}
]
}
],
"priority": 50
}
@@ -0,0 +1,532 @@
{
"target_layout": "mypage/profile",
"data_sources": [
{
"comment": "회원 동의 이력 (history)",
"id": "gdprMyConsents",
"label_key": "$t:sirsoft-gdpr.editor.data_source.gdprMyConsents",
"type": "api",
"endpoint": "/api/plugins/sirsoft-gdpr/consent/history",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"comment": "회원 활성 동의 + 정책 버전 갱신 필요 여부",
"id": "gdprMeConsents",
"label_key": "$t:sirsoft-gdpr.editor.data_source.gdprMeConsents",
"type": "api",
"endpoint": "/api/plugins/sirsoft-gdpr/consent/me",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"comment": "공개 설정 — 운영 주체/저장 위치 메타 라인 표시용 (legal_entity_name, data_storage_location). 카드 헤더 하단 1줄 메타 정보로 노출되며, 둘 다 비어있으면 메타 라인 전체 숨김.",
"id": "gdprPublicSettings",
"label_key": "$t:sirsoft-gdpr.editor.data_source.gdprPublicSettings",
"type": "api",
"endpoint": "/api/plugins/sirsoft-gdpr/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": false,
"loading_strategy": "progressive"
}
],
"modals": [
{
"id": "revoke_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-gdpr.mypage.privacy.revoke_confirm_title",
"width": "min(420px, 95vw)",
"closeOnBackdropClick": true
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-4" },
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-body" },
"text": "$t:sirsoft-gdpr.mypage.privacy.revoke_confirm_message"
},
{
"comment": "철회 대상 표시 — 라벨 + 카테고리 친화 설명. raw key (cookie_functional 등) 는 사용자에게 무의미하여 노출하지 않음. 모달은 별도 컨텍스트라 _global 사용 필수 (부모 _local 미해석)",
"type": "basic",
"name": "Div",
"props": { "className": "rounded-lg bg-gray-50 dark:bg-gray-700 px-3 py-2 text-sm text-gray-700 dark:text-gray-200" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "block font-medium" },
"text": "{{_global.revokeTargetLabel ?? _global.revokeTargetKey ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!_global.revokeTargetDescription}}",
"props": { "className": "block text-tertiary mt-0.5 leading-relaxed" },
"text": "{{_global.revokeTargetDescription ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-2" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_local.isRevoking}}",
"className": "btn btn-secondary disabled:opacity-50"
},
"actions": [
{ "type": "click", "handler": "closeModal" }
],
"children": [
{ "type": "basic", "name": "Span", "text": "$t:common.cancel" }
]
},
{
"comment": "철회 대상은 _global 참조 (모달 컨텍스트 분리). isRevoking 은 모달 _local 로 충분 — 모달이 직접 set 하고 직접 read 하므로 컨텍스트 일관됨",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_local.isRevoking || !_global.revokeTargetKey}}",
"className": "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": { "target": "local", "isRevoking": true }
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-gdpr/consent/revoke",
"params": {
"method": "POST",
"body": "{{{ consent_key: _global.revokeTargetKey }}}"
},
"onSuccess": [
{
"handler": "setState",
"params": { "target": "local", "isRevoking": false }
},
{
"handler": "setState",
"params": { "target": "global", "revokeTargetKey": null, "revokeTargetLabel": null, "revokeTargetDescription": null }
},
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMeConsents" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsents" } },
{ "handler": "sirsoft-gdpr.syncConsent" },
{ "handler": "closeModal" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.consent.revoked" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "local", "isRevoking": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } }
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_local.isRevoking}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{!_local.isRevoking}}", "text": "$t:sirsoft-gdpr.mypage.privacy.revoke" },
{ "type": "basic", "name": "Span", "if": "{{_local.isRevoking}}", "text": "$t:sirsoft-gdpr.consent.revoking" }
]
}
]
}
]
}
]
}
],
"injections": [
{
"target_id": "profile_view_bio_card",
"position": "append",
"components": [
{
"id": "gdpr_mypage_privacy_card",
"type": "basic",
"name": "Div",
"comment": "마이페이지 프로필 페이지 — 자기소개/마케팅 카드 다음에 GDPR 동의 매트릭스 카드 (F-04). GDPR Art.7(3) 대칭성 의무: 동의 철회는 동의 부여만큼 쉬워야 함 → 회원에게 동의 데이터가 있을 때만 노출 (빈 카드 노출 방지). Art.7(3) 보장은 *기존 동의가 있는* 회원의 철회권 보장이므로 동의 데이터 없는 신규 회원은 카드 미노출이 정합. LayoutExtensionService 의 'append' 는 array_splice(targetIndex+1) 이라 priority 가 작은 확장이 먼저 삽입되고 나중 확장이 그 자리를 밀어내는 구조 — sirsoft-marketing(100) 다음에 우리가 오게 하려면 우리 priority 가 더 작아야 함. 90 으로 설정.",
"if": "{{(gdprMyConsents?.data?.length ?? 0) > 0 || (gdprMeConsents?.data?.consents?.length ?? 0) > 0}}",
"props": {
"className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"
},
"responsive": {
"portable": {
"props": { "className": "bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4" }
}
},
"children": [
{
"id": "gdpr_card_header",
"type": "basic",
"name": "Div",
"props": { "className": "mb-4 pb-3 border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "H2",
"props": { "className": "section-heading-md" },
"text": "$t:sirsoft-gdpr.mypage.privacy.title"
}
]
},
{
"comment": "운영 주체·데이터 저장 위치 메타 라인 — GDPR Art.13(1)(a)(f) 의무 고지를 마이페이지에서도 접근 가능하도록 보강. 카드 헤더(타이틀 + 구분선) 다음에 자연어 한 줄로 노출. 3-case 분기: (1) 둘 다 / (2) 운영자만 / (3) 저장위치만. 둘 다 비면 블록 전체 숨김.",
"id": "gdpr_card_operator_meta",
"type": "basic",
"name": "Div",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}",
"props": { "className": "mb-4 text-tertiary" },
"children": [
{
"type": "basic",
"name": "P",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.mypage.privacy.operator_and_storage_meta|entity={{gdprPublicSettings?.data?.legal_entity_name ?? ''}}|location={{gdprPublicSettings?.data?.data_storage_location ?? ''}}"
},
{
"type": "basic",
"name": "P",
"if": "{{!!gdprPublicSettings?.data?.legal_entity_name && !gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.mypage.privacy.operator_only_meta|entity={{gdprPublicSettings?.data?.legal_entity_name ?? ''}}"
},
{
"type": "basic",
"name": "P",
"if": "{{!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}",
"text": "$t:sirsoft-gdpr.mypage.privacy.storage_only_meta|location={{gdprPublicSettings?.data?.data_storage_location ?? ''}}"
}
]
},
{
"id": "gdpr_needs_renewal_banner",
"type": "basic",
"name": "Div",
"if": "{{gdprMeConsents?.data?.needs_renewal === true}}",
"comment": "정책 갱신 안내 (#22 옵션 C) — 현재 정책 버전 노출 + 전체 다시 동의 액션. 필수 쿠키는 ePrivacy Art.5(3) 면제로 갱신 대상 아님을 hint 로 명시.",
"props": { "className": "mb-4 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-3 flex-1" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "triangle-exclamation", "size": "sm", "className": "text-amber-600 dark:text-amber-400 flex-shrink-0" }
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-1" },
"children": [
{
"type": "basic",
"name": "P",
"comment": "현재 정책 버전을 노출하여 사용자가 어느 버전에 동의하는지 인지 (#23 — informed consent). G7 표준 $t:key|param={{value}} 토큰 사용 — lang 파일의 {version} placeholder 가 치환됨.",
"props": { "className": "text-warning-soft" },
"text": "$t:sirsoft-gdpr.mypage.privacy.needs_renewal_with_version|version={{gdprMeConsents?.data?.current_policy_version ?? '-'}}"
},
{
"type": "basic",
"name": "P",
"comment": "필수 쿠키는 ePrivacy Art.5(3) 면제로 정책 갱신 대상이 아님을 명시.",
"props": { "className": "mt-1 text-warning-soft" },
"text": "$t:sirsoft-gdpr.mypage.privacy.needs_renewal_hint"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center flex-wrap gap-2 sm:flex-shrink-0" },
"children": [
{
"type": "basic",
"name": "Button",
"comment": "전체 항목 다시 동의 (#19) — 활성 선택형 동의를 새 버전으로 일괄 grant (재동의 의사 표명). isRenewingAll 플래그로 클릭 직후 spinner 노출 + 중복 클릭 차단.",
"props": {
"type": "button",
"disabled": "{{_local.isRenewingAll}}",
"className": "px-3 py-1.5 text-xs bg-amber-600 dark:bg-amber-700 text-white rounded hover:bg-amber-700 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "local", "isRenewingAll": true } },
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-gdpr/consent/renew-all",
"params": { "method": "POST" },
"onSuccess": [
{ "handler": "setState", "params": { "target": "local", "isRenewingAll": false } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMeConsents" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsents" } },
{ "handler": "sirsoft-gdpr.syncConsent" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.renew_all_success|renewed={{response?.data?.renewed ?? 0}}" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "local", "isRenewingAll": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } }
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_local.isRenewingAll}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } },
{ "type": "basic", "name": "Span", "if": "{{!_local.isRenewingAll}}", "text": "$t:sirsoft-gdpr.mypage.privacy.btn_renew_all" },
{ "type": "basic", "name": "Span", "if": "{{_local.isRenewingAll}}", "text": "$t:sirsoft-gdpr.consent.renewing" }
]
}
]
}
]
},
{
"id": "gdpr_section_consents",
"type": "basic",
"name": "Div",
"comment": "내 동의 현황 — 회원 활성 동의 (status) 기반",
"props": { "className": "toggle-group" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "px-4 bg-gray-50 dark:bg-gray-700/50" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-primary text-sm font-semibold" },
"text": "$t:sirsoft-gdpr.mypage.privacy.section_consents"
},
{
"type": "basic",
"name": "P",
"props": { "className": "form-hint" },
"text": "$t:sirsoft-gdpr.mypage.privacy.section_consents_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprMeConsents?.data?.consents ?? []).length === 0}}",
"props": { "className": "py-10 text-center text-label-subtle" },
"text": "$t:sirsoft-gdpr.mypage.privacy.no_consents"
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprMeConsents?.data?.consents ?? []).length > 0}}",
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "text-tertiary grid grid-cols-12 gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-700 font-medium uppercase" },
"children": [
{ "type": "basic", "name": "Div", "props": { "className": "col-span-6" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consent_key" },
{ "type": "basic", "name": "Div", "props": { "className": "col-span-2 text-center" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consented" },
{ "type": "basic", "name": "Div", "props": { "className": "col-span-2" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consented_at" },
{ "type": "basic", "name": "Div", "props": { "className": "col-span-2 text-center" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_action" }
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "divide-y divide-gray-100 dark:divide-gray-700" },
"iteration": {
"source": "gdprMeConsents?.data?.consents ?? []",
"item_var": "consent",
"index_var": "consentIdx"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-12 gap-2 px-4 py-2 items-center text-sm" },
"children": [
{
"type": "basic",
"name": "Div",
"comment": "라벨 + 사용자 친화 1줄 설명. 회원이 영문 식별자(cookie_analytics) 대신 카테고리 의미를 즉시 이해할 수 있도록 카탈로그 description (CookieCategoryService::getDescriptionForKey) 을 함께 노출.",
"props": { "className": "col-span-6" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-primary block font-medium" },
"text": "{{consent?.consent_label ?? consent?.consent_key ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!consent?.consent_description}}",
"props": { "className": "block text-tertiary mt-0.5 leading-relaxed" },
"text": "{{consent?.consent_description ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "col-span-2 flex justify-center" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{consent?.is_consented ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}}" },
"text": "{{consent?.is_consented ? '$t:sirsoft-gdpr.mypage.privacy.consented' : '$t:sirsoft-gdpr.mypage.privacy.revoked'}}"
}
]
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-secondary col-span-2 text-xs" },
"text": "{{consent?.consented_at ?? '-'}}"
},
{
"comment": "액션 컬럼 — Art.7(3) 대칭성 + ePrivacy Art.5(3) 동의 면제 패턴 4가지 분기:\n 1) is_required → 「필수」 회색 라벨 (철회/재동의 둘 다 무의미)\n 2) can_revoke (선택형 + 활성) → 「철회」 빨간 버튼\n 3) can_grant (선택형 + 비활성) → 「다시 동의」/「동의」 파란 버튼\n 4) 그 외 (이론상 발생 X, 안전망)\nServer Resource (`is_required` / `can_grant` / `can_revoke`) 메타가 SSoT 라 클라이언트는 단순 분기.",
"type": "basic",
"name": "Div",
"props": { "className": "flex-center col-span-2 justify-center gap-2" },
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{consent?.is_required === true}}",
"props": {
"className": "text-secondary inline-flex items-center px-2 py-0.5 text-xs italic"
},
"text": "$t:sirsoft-gdpr.mypage.privacy.required_label"
},
{
"type": "basic",
"name": "Button",
"if": "{{consent?.can_revoke === true}}",
"props": {
"type": "button",
"className": "px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"comment": "모달은 별도 컨텍스트라 부모 _local 미해석 — _global 사용 필수. revokeTargetKey/Label/Description 3개를 동시 세팅하여 모달이 라벨 + 친화 설명을 표시할 수 있게 함 (raw key 노출은 사용자에게 무의미하므로 description 으로 대체)",
"handler": "setState",
"params": {
"target": "global",
"revokeTargetKey": "{{consent?.consent_key}}",
"revokeTargetLabel": "{{consent?.consent_label ?? consent?.consent_key}}",
"revokeTargetDescription": "{{consent?.consent_description ?? ''}}"
}
},
{ "handler": "openModal", "target": "revoke_confirm_modal" }
]
}
],
"children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.revoke" }
]
},
{
"type": "basic",
"name": "Button",
"if": "{{consent?.can_grant === true}}",
"comment": "3분기 (#21) — needs_renewal_this_item: 최신 정책으로 갱신 / consent_count>0: 다시 동의 / 그 외: 신규 동의. needs_renewal 분기는 다른 버튼 색 (amber) 으로 시각적 구분. grantingKey 비교로 클릭한 해당 행만 spinner 노출 + 다른 행 클릭은 자동 차단.",
"props": {
"type": "button",
"disabled": "{{!!_local.grantingKey}}",
"className": "{{consent?.needs_renewal_this_item === true ? 'px-3 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center' : 'px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center'}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "local", "grantingKey": "{{consent?.consent_key}}" } },
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-gdpr/consent/grant",
"params": {
"method": "POST",
"body": "{{{ consent_key: consent?.consent_key }}}"
},
"onSuccess": [
{ "handler": "setState", "params": { "target": "local", "grantingKey": null } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMeConsents" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsents" } },
{ "handler": "sirsoft-gdpr.syncConsent" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.consent.granted_again" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "local", "grantingKey": null } },
{ "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } }
]
}
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "if": "{{_local.grantingKey === consent?.consent_key}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } },
{
"type": "basic",
"name": "Span",
"if": "{{_local.grantingKey !== consent?.consent_key}}",
"text": "{{consent?.needs_renewal_this_item === true ? '$t:sirsoft-gdpr.mypage.privacy.btn_renew_this_item' : ((consent?.consent_count ?? 0) > 0 ? '$t:sirsoft-gdpr.mypage.privacy.grant_again' : '$t:sirsoft-gdpr.mypage.privacy.grant')}}"
},
{ "type": "basic", "name": "Span", "if": "{{_local.grantingKey === consent?.consent_key}}", "text": "$t:sirsoft-gdpr.consent.granting" }
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 90
}
@@ -0,0 +1,111 @@
/**
* 도메인 매칭 + 카테고리 판정 단위 테스트.
*
* 카탈로그 합집합 함수(`mergeBlockedDomains`)는 자동 차단 단일 개념 통합과 함께
* 제거되었습니다. 카탈로그 도메인은 신규 설치 시 `blocked_domains` 기본값으로
* 채워지며, 운영자가 입력한 도메인만 차단 엔진에서 사용됩니다.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { matchesDomain, categorizeUrl, DEFAULT_BLOCKED_DOMAINS } from '../blocker-domains';
describe('matchesDomain', () => {
it('정확 매칭 — 동일 호스트만 매칭', () => {
expect(matchesDomain('google-analytics.com', 'google-analytics.com')).toBe(true);
expect(matchesDomain('www.google-analytics.com', 'google-analytics.com')).toBe(false);
expect(matchesDomain('example.com', 'google-analytics.com')).toBe(false);
});
it('와일드카드 매칭 — 서브도메인만 매칭, base 자체는 미매칭', () => {
expect(matchesDomain('static.hotjar.com', '*.hotjar.com')).toBe(true);
expect(matchesDomain('vars.hotjar.com', '*.hotjar.com')).toBe(true);
// base 자체는 별도 정확 매칭 추가 필요
expect(matchesDomain('hotjar.com', '*.hotjar.com')).toBe(false);
expect(matchesDomain('example.com', '*.hotjar.com')).toBe(false);
});
it('대소문자 무관 매칭', () => {
expect(matchesDomain('Google-Analytics.COM', 'google-analytics.com')).toBe(true);
expect(matchesDomain('STATIC.HOTJAR.COM', '*.hotjar.com')).toBe(true);
});
});
describe('categorizeUrl', () => {
const patterns = {
analytics: ['google-analytics.com', '*.hotjar.com'],
marketing: ['facebook.net'],
necessary: ['session.example.com'], // skip 대상이라 이 패턴은 무시되어야 함
} as const;
beforeEach(() => {
// jsdom 의 window.location.hostname 을 own-site 로 가정
Object.defineProperty(window, 'location', {
value: { hostname: 'mysite.test', href: 'https://mysite.test/' },
writable: true,
});
});
it('http(s) 외부 URL — 카테고리 판정', () => {
expect(categorizeUrl('https://www.google-analytics.com/analytics.js', patterns)).toBe(null);
expect(categorizeUrl('https://google-analytics.com/analytics.js', patterns)).toBe('analytics');
expect(categorizeUrl('https://static.hotjar.com/c/hotjar.js', patterns)).toBe('analytics');
expect(categorizeUrl('https://facebook.net/tr', patterns)).toBe('marketing');
});
it('잘못된 URL — null 반환', () => {
expect(categorizeUrl('not a url at all !!!', patterns)).toBe(null);
});
it('data:/blob: URI — 차단 대상 외 (null)', () => {
expect(categorizeUrl('data:image/png;base64,abcd', patterns)).toBe(null);
expect(categorizeUrl('blob:https://mysite.test/abcd', patterns)).toBe(null);
expect(categorizeUrl('about:blank', patterns)).toBe(null);
});
it('동일 origin — 차단 대상 외 (null)', () => {
expect(categorizeUrl('https://mysite.test/js/site.js', patterns)).toBe(null);
expect(categorizeUrl('/js/relative.js', patterns)).toBe(null);
});
it('necessary 카테고리 — 매칭 자체를 skip', () => {
// session.example.com 은 necessary 패턴에 있지만 카테고리가 necessary 라 skip
expect(categorizeUrl('https://session.example.com/x.js', patterns)).toBe(null);
});
it('빈 패턴 — 항상 null', () => {
expect(categorizeUrl('https://google-analytics.com/x.js', {})).toBe(null);
});
});
describe('DEFAULT_BLOCKED_DOMAINS — 시드 데이터 정합성', () => {
it('functional / analytics / marketing 카테고리가 정의되어 있고 도메인 1개 이상 포함', () => {
// 카탈로그는 신규 설치 시 blocked_domains 기본값과 관리자 UI 추천 옵션의 SSoT.
// BE 측 GdprSettingsController::DEFAULT_BLOCKED_DOMAINS_CATALOG 와 동일하게 유지.
// Phase 1: functional 카테고리 추가 (빈 배열 — Phase 2 에서 외부 functional 도구 도메인 채움)
expect(DEFAULT_BLOCKED_DOMAINS.functional).toBeDefined();
expect(DEFAULT_BLOCKED_DOMAINS.analytics).toBeDefined();
expect(DEFAULT_BLOCKED_DOMAINS.marketing).toBeDefined();
expect(DEFAULT_BLOCKED_DOMAINS.analytics.length).toBeGreaterThan(0);
expect(DEFAULT_BLOCKED_DOMAINS.marketing.length).toBeGreaterThan(0);
});
it('Phase 1: functional 키는 빈 배열로 초기화 (Phase 2 에서 채움)', () => {
expect(DEFAULT_BLOCKED_DOMAINS.functional).toEqual([]);
});
it('대표 도메인 — Google Analytics 와 Meta Pixel 포함', () => {
expect(DEFAULT_BLOCKED_DOMAINS.analytics).toContain('google-analytics.com');
expect(DEFAULT_BLOCKED_DOMAINS.marketing).toContain('facebook.net');
});
it('Phase 1: functional 카테고리 매칭은 도메인 등록 시 동작', () => {
// 운영자가 functional 도메인을 등록하면 categorizeUrl 이 functional 반환
const patterns = {
functional: ['*.crisp.chat', 'widget.intercom.io'],
analytics: ['google-analytics.com'],
marketing: ['facebook.net'],
};
expect(categorizeUrl('https://client.crisp.chat/widget.js', patterns)).toBe('functional');
expect(categorizeUrl('https://widget.intercom.io/widget/abc.js', patterns)).toBe('functional');
});
});
@@ -0,0 +1,299 @@
/**
* 차단 엔진 통합 — 속성 기반 + 도메인 기반 분기 + 동의 변경 시 rescan.
*
* jsdom 환경에서 DOM 삽입 → 차단/복원 검증.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setBlockedDomains, setCurrentConsent, startBlocker, stopBlocker } from '../blocker';
const PLACEHOLDER_CLASS = 'gdpr-blocked-embed';
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { hostname: 'mysite.test', href: 'https://mysite.test/' },
writable: true,
});
document.body.innerHTML = '';
document.head.innerHTML = '';
setBlockedDomains({});
setCurrentConsent({});
startBlocker();
});
afterEach(() => {
stopBlocker();
document.body.innerHTML = '';
document.head.innerHTML = '';
});
/**
* MutationObserver microtask 처리 대기.
*/
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* placeholder 가 카테고리로 차단되었는지 확인.
*/
function findPlaceholder(category: string): HTMLElement | null {
return document.querySelector<HTMLElement>(
`.${PLACEHOLDER_CLASS}[data-gdpr-blocked-category="${category}"]`,
);
}
describe('속성 기반 차단 (기존 회귀)', () => {
it('data-gdpr-category=analytics 인 <script> 는 동의 전 placeholder 로 치환', async () => {
const script = document.createElement('script');
script.src = 'https://mysite.test/js/ga.js';
script.setAttribute('data-gdpr-category', 'analytics');
document.body.appendChild(script);
await flush();
expect(document.querySelector('script[data-gdpr-category]')).toBeNull();
expect(findPlaceholder('analytics')).not.toBeNull();
});
it('속성 기반 차단된 요소는 동의 부여 시 rescan 으로 복원', async () => {
const script = document.createElement('script');
script.src = 'https://mysite.test/js/ga.js';
script.setAttribute('data-gdpr-category', 'analytics');
document.body.appendChild(script);
await flush();
expect(findPlaceholder('analytics')).not.toBeNull();
setCurrentConsent({ analytics: true });
await flush();
expect(findPlaceholder('analytics')).toBeNull();
expect(document.querySelector('script[data-gdpr-category]')).not.toBeNull();
});
it('data-gdpr-category=necessary 는 동의 전이라도 차단되지 않음', async () => {
const script = document.createElement('script');
script.src = 'https://mysite.test/js/session.js';
script.setAttribute('data-gdpr-category', 'necessary');
document.body.appendChild(script);
await flush();
expect(document.querySelector('script[data-gdpr-category="necessary"]')).not.toBeNull();
expect(findPlaceholder('necessary')).toBeNull();
});
});
describe('도메인 기반 차단 (신규)', () => {
it('운영자 도메인 정확 매칭 — 속성 없는 외부 <script> 차단', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(script);
await flush();
expect(findPlaceholder('analytics')).not.toBeNull();
});
it('와일드카드 매칭 — 서브도메인 외부 <script> 차단', async () => {
setBlockedDomains({ analytics: ['*.hotjar.com'] });
const script = document.createElement('script');
script.src = 'https://static.hotjar.com/c/hotjar.js';
document.body.appendChild(script);
await flush();
expect(findPlaceholder('analytics')).not.toBeNull();
});
it('<img> 트래킹 픽셀 도메인 매칭 시 차단', async () => {
setBlockedDomains({ marketing: ['facebook.com'] });
const img = document.createElement('img');
img.src = 'https://facebook.com/tr?id=xxx';
document.body.appendChild(img);
await flush();
expect(findPlaceholder('marketing')).not.toBeNull();
expect(document.querySelector('img')).toBeNull();
});
it('<link rel="preconnect"> 도메인 매칭 시 차단', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
const link = document.createElement('link');
link.rel = 'preconnect';
link.href = 'https://google-analytics.com/';
document.head.appendChild(link);
await flush();
expect(findPlaceholder('analytics')).not.toBeNull();
});
it('<iframe> 도메인 매칭 시 차단', async () => {
setBlockedDomains({ marketing: ['*.facebook.com'] });
const iframe = document.createElement('iframe');
iframe.src = 'https://www.facebook.com/plugins/like.php';
document.body.appendChild(iframe);
await flush();
expect(findPlaceholder('marketing')).not.toBeNull();
});
it('동일 origin <script> 는 도메인 미등록이라도 차단되지 않음', async () => {
setBlockedDomains({ analytics: ['mysite.test'] }); // 운영자가 자체 도메인을 잘못 등록
const script = document.createElement('script');
script.src = 'https://mysite.test/js/site.js';
document.body.appendChild(script);
await flush();
expect(document.querySelector('script')).not.toBeNull();
expect(findPlaceholder('analytics')).toBeNull();
});
it('necessary 카테고리는 매칭 자체를 skip', async () => {
setBlockedDomains({ necessary: ['session.example.com'] });
const script = document.createElement('script');
script.src = 'https://session.example.com/x.js';
document.body.appendChild(script);
await flush();
expect(document.querySelector('script')).not.toBeNull();
});
});
describe('속성 + 도메인 동시 매칭 시 속성 우선', () => {
it('속성이 marketing 이고 도메인이 analytics 매칭 — marketing 으로 분류', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
script.setAttribute('data-gdpr-category', 'marketing');
document.body.appendChild(script);
await flush();
// 속성 우선 → marketing 으로 차단
expect(findPlaceholder('marketing')).not.toBeNull();
expect(findPlaceholder('analytics')).toBeNull();
});
it('analytics 동의는 있지만 속성이 marketing 인 경우 — 속성 우선이라 차단 유지', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
setCurrentConsent({ analytics: true });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
script.setAttribute('data-gdpr-category', 'marketing');
document.body.appendChild(script);
await flush();
expect(findPlaceholder('marketing')).not.toBeNull();
});
});
describe('동의 변경 시 rescan', () => {
it('도메인 차단된 요소도 동의 부여 시 복원', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(script);
await flush();
expect(findPlaceholder('analytics')).not.toBeNull();
setCurrentConsent({ analytics: true });
await flush();
expect(findPlaceholder('analytics')).toBeNull();
expect(document.querySelector('script[src*="google-analytics.com"]')).not.toBeNull();
});
it('동의 철회 후 새로 삽입된 요소는 다시 차단', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
setCurrentConsent({ analytics: true });
const first = document.createElement('script');
first.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(first);
await flush();
expect(document.querySelectorAll('script').length).toBe(1);
// 철회
setCurrentConsent({ analytics: false });
await flush();
// 기존 로드된 요소도 rescan 으로 다시 차단
expect(findPlaceholder('analytics')).not.toBeNull();
// 새 요소 삽입 → 차단
const second = document.createElement('script');
second.src = 'https://google-analytics.com/v2.js';
document.body.appendChild(second);
await flush();
const placeholders = document.querySelectorAll(`.${PLACEHOLDER_CLASS}`);
expect(placeholders.length).toBeGreaterThanOrEqual(2);
});
});
describe('preblocker 와의 통합 — 중복 차단 회피', () => {
it('data-gdpr-blocked-category 속성이 있는 <script> 는 MutationObserver 가 중복 처리하지 않음', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
setCurrentConsent({});
// preblocker 가 이미 처리한 상태를 시뮬레이션 — src 는 비어있고 보존 속성만
const script = document.createElement('script');
script.setAttribute('data-gdpr-blocked-src', 'https://google-analytics.com/analytics.js');
script.setAttribute('data-gdpr-blocked-category', 'analytics');
document.body.appendChild(script);
await flush();
// 백업 라인이 중복 placeholder 변환을 수행하지 않아야 함 —
// 원본 <script> 가 그대로 DOM 에 남고 placeholder 미생성
expect(document.querySelector('script[data-gdpr-blocked-category]')).not.toBeNull();
expect(findPlaceholder('analytics')).toBeNull();
});
it('preblocker 보존 속성 없으면 MutationObserver 백업 라인이 정상 차단 (preblocker 우회 케이스)', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
setCurrentConsent({});
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(script);
await flush();
// 백업 라인이 placeholder 로 변환
expect(findPlaceholder('analytics')).not.toBeNull();
});
/**
* 작업 6 (B-2) — needs_renewal=true 일 때 동의된 카테고리도 보수적 차단.
* 옛 정책 동의가 신정책에 그대로 쓰이는 상태 — 사용자가 의사 표명할 때까지 필수 외 차단.
*/
it('needs_renewal=true 면 analytics=true 인 옛 동의에도 SDK 차단 (보수적)', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
setCurrentConsent({ categories: { necessary: true, analytics: true }, needs_renewal: true });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(script);
await flush();
// analytics 카테고리 동의되어 있어도 needs_renewal 때문에 차단
expect(findPlaceholder('analytics')).not.toBeNull();
});
it('plain Record 형태 호출 (BC) 도 그대로 동작 — needs_renewal 미지정 시 false 로 처리', async () => {
setBlockedDomains({ analytics: ['google-analytics.com'] });
// 기존 호출 패턴 (DetailedConsentSnapshot 아님)
setCurrentConsent({ necessary: true, analytics: true });
const script = document.createElement('script');
script.src = 'https://google-analytics.com/analytics.js';
document.body.appendChild(script);
await flush();
// analytics 동의됨 + needs_renewal 미지정 (false) → 차단 안 됨
expect(findPlaceholder('analytics')).toBeNull();
});
});
@@ -0,0 +1,185 @@
/**
* cookieInterceptor 단위 테스트 (Phase 2 단순화).
*
* Document.prototype.cookie setter 가로채기 + 4단계 게이팅 규칙 검증:
* 1. cleared cookie (Max-Age=0 / expires 과거) → 항상 통과 (§117 충돌 회피)
* 2. strictly necessary allowlist → 통과
* 3. functional 동의 → 통과
* 4. user-initiated (WP29 §3.6) 면제 → 사용자 인터랙션 직후 통과
* 5. 그 외 → 차단
*
* "운영자 등록 표" 는 제거됨 (Phase 2 단순화).
*
* 주의: jsdom 의 document.cookie 는 navigation 별 상태 — 테스트마다 clean 처리.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
installCookieInterceptor,
isCookieAllowed,
uninstallCookieInterceptor,
updateCookieInterceptorConfig,
} from '../cookieInterceptor';
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
/** document.cookie 에서 특정 이름의 값 추출 (없으면 null) */
function getCookieValue(name: string): string | null {
const match = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`));
return match ? match.substring(name.length + 1) : null;
}
/** 모든 cookie 파기 — uninstall 상태에서만 호출 */
function clearAllCookies(): void {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const eq = cookie.indexOf('=');
const name = (eq > -1 ? cookie.substring(0, eq) : cookie).trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
}
}
}
describe('cookieInterceptor', () => {
beforeEach(() => {
installUserInitiatedTracker();
clearAllCookies();
});
afterEach(() => {
uninstallCookieInterceptor();
uninstallUserInitiatedTracker();
clearAllCookies();
});
it('strictly necessary cookie (XSRF-TOKEN) → 항상 통과', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'XSRF-TOKEN=abc; path=/';
expect(getCookieValue('XSRF-TOKEN')).toBe('abc');
});
it('functional 동의 시 모든 cookie 통과', () => {
installCookieInterceptor({
functionalConsented: true,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'app_pref=value; path=/';
expect(getCookieValue('app_pref')).toBe('value');
});
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
__setLastInteractionForTest(Date.now());
document.cookie = 'app_pref=value; path=/';
expect(getCookieValue('app_pref')).toBe('value');
});
it('미동의 + 비-사용자 → 차단', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
// user-initiated 미발생
document.cookie = 'app_pref=value; path=/';
expect(getCookieValue('app_pref')).toBeNull();
});
it('미동의 + 임의 cookie + 비-사용자 → 차단', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'random_cookie=value; path=/';
expect(getCookieValue('random_cookie')).toBeNull();
});
it('파싱 불가 cookie 문자열 → 차단 (보수적)', () => {
installCookieInterceptor({
functionalConsented: true,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'malformed_no_equals';
expect(document.cookie).not.toContain('malformed_no_equals');
});
it('cleared cookie (Max-Age=0) → 미동의여도 통과 (§117 충돌 회피)', () => {
// 미동의 상태에서도 파기 cookie 발송은 허용
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
// 사전 저장된 cookie (uninstall 상태에서 미리 설정)
// jsdom 에선 인터셉터 install 전에 직접 cookie 설정이 가능하다고 보장 안 됨 — isCookieAllowed 로 정책 검증
// (실제 cookie 파기 효과는 functionalCleaner 테스트에서 검증)
expect(isCookieAllowed('app_pref=; Max-Age=0; Path=/')).toBe(true);
});
it('updateCookieInterceptorConfig 으로 동의 갱신 → 후속 쓰기 통과', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
// 미동의 + 비-사용자 → 차단
document.cookie = 'app_pref=v1; path=/';
expect(getCookieValue('app_pref')).toBeNull();
// 동의 갱신
updateCookieInterceptorConfig({
functionalConsented: true,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'app_pref=v2; path=/';
expect(getCookieValue('app_pref')).toBe('v2');
});
it('uninstall 후 원본 setter 복원 — 모든 쓰기 통과', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
document.cookie = 'random=v1; path=/';
expect(getCookieValue('random')).toBeNull();
uninstallCookieInterceptor();
document.cookie = 'random=v2; path=/';
expect(getCookieValue('random')).toBe('v2');
});
it('isCookieAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
__setLastInteractionForTest(Date.now());
expect(isCookieAllowed('app_pref=v')).toBe(true); // user-initiated
expect(isCookieAllowed('XSRF-TOKEN=t')).toBe(true); // necessary
expect(isCookieAllowed('malformed')).toBe(false); // 파싱 불가
// user-initiated 만료
__setLastInteractionForTest(0);
expect(isCookieAllowed('app_pref=v')).toBe(false); // 미동의 + 비-사용자
expect(isCookieAllowed('XSRF-TOKEN=t')).toBe(true); // necessary 는 항상
expect(isCookieAllowed('any=; Max-Age=0; Path=/')).toBe(true); // cleared 항상 통과
// 함수 호출만으로 cookie 변경 X
expect(getCookieValue('app_pref')).toBeNull();
});
});
@@ -0,0 +1,132 @@
/**
* functionalCleaner 단위 테스트 (Phase 2 단순화).
*
* cleanup 동작 검증 — strictly necessary allowlist 외 모든 1st-party 저장소 파기:
* - allowlist 외 모든 localStorage / sessionStorage 키 removeItem
* - allowlist 외 모든 cookie Max-Age=0 파기
* - allowlist 키/cookie 는 보존
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanupFunctionalArtifacts } from '../functionalCleaner';
/** document.cookie 에서 특정 이름의 값 추출 */
function getCookieValue(name: string): string | null {
const match = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`));
return match ? match.substring(name.length + 1) : null;
}
function clearAllCookies(): void {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const eq = cookie.indexOf('=');
const name = (eq > -1 ? cookie.substring(0, eq) : cookie).trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
}
}
}
describe('functionalCleaner', () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
clearAllCookies();
});
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
clearAllCookies();
});
it('allowlist 외 localStorage 키 파기', () => {
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('allowlist 외 sessionStorage 키 파기', () => {
window.sessionStorage.setItem('app_session_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.sessionStorage.getItem('app_session_pref')).toBeNull();
});
it('strictly necessary 키 (g7_locale) 는 보존', () => {
window.localStorage.setItem('g7_locale', 'ko');
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('prefix 매칭 키 (g7_devtools_*) 는 보존', () => {
window.localStorage.setItem('g7_devtools_filter', 'enabled');
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('allowlist 외 cookie 파기', () => {
document.cookie = 'app_pref_cookie=value; path=/';
expect(getCookieValue('app_pref_cookie')).toBe('value');
cleanupFunctionalArtifacts();
expect(getCookieValue('app_pref_cookie')).toBeNull();
});
it('strictly necessary cookie (XSRF-TOKEN) 는 보존', () => {
document.cookie = 'XSRF-TOKEN=safe; path=/';
document.cookie = 'app_pref_cookie=value; path=/';
cleanupFunctionalArtifacts();
expect(getCookieValue('XSRF-TOKEN')).toBe('safe');
expect(getCookieValue('app_pref_cookie')).toBeNull();
});
it('storage + cookie 동시 파기 (allowlist 외 전체)', () => {
window.localStorage.setItem('app_pref', 'value');
window.localStorage.setItem('g7_locale', 'ko'); // allowlist 보존
document.cookie = 'app_pref_cookie=value; path=/';
document.cookie = 'XSRF-TOKEN=safe; path=/'; // allowlist 보존
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('app_pref')).toBeNull();
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
expect(getCookieValue('app_pref_cookie')).toBeNull();
expect(getCookieValue('XSRF-TOKEN')).toBe('safe');
});
it('빈 storage 상태 — silent (예외 없음)', () => {
expect(() => {
cleanupFunctionalArtifacts();
}).not.toThrow();
});
it('커스텀 allowlist 옵션으로 보존 키 확장 가능', () => {
window.localStorage.setItem('custom_protected', 'value');
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts({
storageAllowlist: [
{ key: 'custom_protected', storage: 'localStorage', matchType: 'exact' },
],
});
expect(window.localStorage.getItem('custom_protected')).toBe('value');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
});
@@ -0,0 +1,700 @@
/**
* GDPR 플러그인 환경설정 레이아웃 구조 검증
*
* 3 카드 (운영자 작업 순서):
* 1. card_operator (운영 주체 + 정책 페이지)
* 2. card_cookie_banner (배너 + 카테고리 + 정책버전)
* 3. card_auto_blocking_policy (자동 차단 정책 — 차단 도메인 관리)
*
* 마이페이지 동의 관리 카드는 GDPR Art.7(3) 대칭성 의무에 따라 플러그인 활성 시 항상 노출
* — 운영자가 끌 수 있는 토글이 없으므로 관리자 설정 화면에 카드/필드가 존재하지 않는다.
* auto_blocking 동작은 banner_enabled (쿠키 배너 노출) 와 단일 토글로 통합되어 별도 토글 없음.
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import publishModal from '../../../layouts/admin/partials/plugin_settings/_policy_version_publish_modal.json';
import { findById, type AnyNode } from './helpers';
describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / auto_blocking_policy)', () => {
const root = layout as unknown as AnyNode;
describe('레이아웃 메타데이터', () => {
it('layout_name이 "plugin_settings"이다', () => {
expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings');
});
it('_admin_base를 상속한다', () => {
expect((root as { extends?: string }).extends).toBe('_admin_base');
});
});
describe('데이터 소스', () => {
const dataSources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
it('gdprSettings 데이터소스가 정의되어 있다', () => {
const ds = dataSources.find((d) => d.id === 'gdprSettings');
expect(ds).toBeTruthy();
expect((ds as { endpoint?: string }).endpoint).toBe('/api/plugins/sirsoft-gdpr/admin/settings');
});
});
describe('상단 탭 스크롤 (TabNavigationScroll)', () => {
const nav = findById(root, 'settings_tab_navigation');
it('TabNavigationScroll 컴포넌트가 존재한다', () => {
expect(nav).toBeTruthy();
expect(nav?.name).toBe('TabNavigationScroll');
});
it('3 탭이 정의되어 있다 (operator / cookie_banner / auto_blocking_policy)', () => {
const tabs = (nav?.props as { tabs?: { id: string }[] })?.tabs ?? [];
const ids = tabs.map((t) => t.id);
expect(ids).toEqual(['card_operator', 'card_cookie_banner', 'card_auto_blocking_policy']);
});
it('enableScrollSpy: true (스크롤 시 활성 탭 자동 갱신)', () => {
expect((nav?.props as { enableScrollSpy?: boolean })?.enableScrollSpy).toBe(true);
});
});
describe('카드 구조', () => {
const expectedCards = ['card_operator', 'card_cookie_banner', 'card_auto_blocking_policy'];
it.each(expectedCards)('카드 %s 가 존재한다', (cardId) => {
expect(findById(root, cardId)).toBeTruthy();
});
it('카드는 settings_main_content 의 직계 children 으로 3개 모두 배치', () => {
const main = findById(root, 'settings_main_content');
const cardChildren = ((main?.children ?? []) as AnyNode[]).filter((c) =>
(c.id ?? '').startsWith('card_')
);
const cardIds = cardChildren.map((c) => c.id);
expect(cardIds).toEqual(expectedCards);
});
it.each(expectedCards)('카드 %s 의 className은 라이트/다크 모드 + scroll-mt-32 포함', (cardId) => {
const card = findById(root, cardId);
const className = (card?.props as { className?: string })?.className ?? '';
expect(className).toContain('bg-white');
expect(className).toContain('dark:bg-gray-800');
expect(className).toContain('rounded-lg');
expect(className).toContain('scroll-mt-32');
});
});
describe('제거된 카드는 더 이상 존재하지 않는다', () => {
it.each([
'card_dpo',
'card_third_party',
'card_master',
'card_policy',
'card_governance',
'card_privacy_policy',
'card_cookie_categories',
'card_cookie_policy_version',
'card_mypage_tab',
'card_mypage_data',
'card_data_request',
'card_dpo_info',
'card_dpo_footer',
])('카드 %s 미존재', (oldCardId) => {
expect(findById(root, oldCardId)).toBeNull();
});
it('third_party_edit_modal 미존재 — modals 배열에 third_party_edit_modal partial 등록 없음', () => {
const modals = (root as { modals?: Array<{ partial?: string }> }).modals ?? [];
const partials = modals.map((m) => m.partial ?? '');
expect(partials.some((p) => p.includes('third_party_edit_modal'))).toBe(false);
});
});
describe('card_operator (운영 주체 + 정책 페이지)', () => {
it('legal_entity_name + data_storage_location + privacy_policy_slug 3 필드 포함', () => {
const fields = findById(root, 'card_operator_fields');
const ids = ((fields?.children ?? []) as AnyNode[]).map((c) => c.id);
expect(ids).toContain('field_legal_entity_name');
expect(ids).toContain('field_data_storage_location');
expect(ids).toContain('field_privacy_policy_slug');
});
});
describe('card_cookie_banner', () => {
it('banner_enabled (쿠키 배너 노출 단일 토글) / banner_position 포함. auto_blocking_enabled 별도 토글 제거됨', () => {
const fields = findById(root, 'card_cookie_banner_fields');
const serialized = JSON.stringify(fields);
expect(serialized).toContain('field_banner_enabled');
expect(serialized).toContain('field_banner_position');
// 위반 조합 (배너 ON + 차단 OFF) 구조적 차단 — auto_blocking_enabled 토글 제거
expect(serialized).not.toContain('field_auto_blocking_enabled');
});
it('cookie_categories 섹션을 포함한다', () => {
const sub = findById(root, 'subsection_cookie_categories');
expect(sub).toBeTruthy();
});
/**
* 카테고리 카드는 정보 패널이며 필수 여부는 CookieCategory enum 이 SSoT.
* 운영자가 카드에서 required 값을 변경하는 위젯/액션은 존재하지 않는다.
*/
it('카테고리 카드 영역에 Toggle/setState(required) 위젯이 없다', () => {
const rows = findById(root, 'cookie_categories_rows');
const serialized = JSON.stringify(rows);
expect(serialized).not.toContain('"name":"Toggle"');
expect(serialized).not.toContain('form.cookie_categories');
expect(serialized).not.toContain('required: $event.target.checked');
});
it('카테고리 카드 헤더에 「필수」/「선택」 배지가 required 값으로 분기 렌더된다', () => {
const rows = findById(root, 'cookie_categories_rows');
const serialized = JSON.stringify(rows);
expect(serialized).toContain('category_required_badge');
expect(serialized).toContain('category?.required === true');
expect(serialized).toContain('category_optional_badge');
expect(serialized).toContain('category?.required !== true');
});
/**
* Phase 1: functional 카테고리 분기 (아이콘 + scope + tools + 도메인섹션 아이콘)
*
* ICO/CNIL 4분류 체계 부합 — layout JSON 의 4개 위치에 functional 분기가 추가되어
* 운영자 admin UI 에서 4번째 카테고리 카드 + 도메인 차단 섹션 노출. legal_basis 축은
* 운영자 의사결정에 불필요하여 제거됨 (정책 버전 snapshot 에는 보존).
*/
it('Phase 1: cookie_categories 영역에 functional 분기 (아이콘 + scope + tools) 존재', () => {
// 실제 카테고리 카드는 cookie_categories_rows iteration 노드에 위치 (subsection 헤더 별개)
const rows = findById(root, 'cookie_categories_rows');
const serialized = JSON.stringify(rows);
// 1. 카테고리 아이콘 분기 (fa-sliders / emerald 색상)
expect(serialized).toContain("category?.key === 'functional'");
expect(serialized).toContain('"name":"sliders"');
expect(serialized).toContain('text-emerald-600');
expect(serialized).toContain('dark:text-emerald-400');
// 2. scope/tools 2축 functional 안내 (운영자 의사결정 정보)
expect(serialized).toContain('cookie_categories.info.functional.scope');
expect(serialized).toContain('cookie_categories.info.functional.tools');
});
it('admin 카테고리 펼치기 영역에 legal_basis 축은 노출되지 않는다 (운영자 친화화 회귀 가드)', () => {
// 운영자 admin 은 법령 근거를 결정하는 사람이 아니라 사이트 운영자.
// 법령 인용 (ePrivacy Art.5(3) / GDPR Art.6(1)(a) 등) 은 운영자 의사결정에 불필요하여
// 펼치기 영역에서 제거되었다. 정책 버전 snapshot 에 카테고리 본문이 영속되므로
// 감사 자료는 그쪽에서 확인 가능.
const rows = findById(root, 'cookie_categories_rows');
const serialized = JSON.stringify(rows);
expect(serialized).not.toContain('cookie_categories.info.necessary.legal_basis');
expect(serialized).not.toContain('cookie_categories.info.functional.legal_basis');
expect(serialized).not.toContain('cookie_categories.info.analytics.legal_basis');
expect(serialized).not.toContain('cookie_categories.info.marketing.legal_basis');
expect(serialized).not.toContain('cookie_categories.info.legal_basis_label');
});
it('Phase 1: 도메인 차단 섹션의 카테고리 아이콘에 functional 분기 존재', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
const serialized = JSON.stringify(wrapper);
// functional 카테고리 분기 (sliders 아이콘 + emerald 색상)
expect(serialized).toContain("category?.key === 'functional'");
expect(serialized).toContain('"name":"sliders"');
});
it('cookie_policy_version 필드는 배너 표시 설정 그룹에 통합되어 있고 별도 subsection 헤더는 없다', () => {
// 정책 버전은 배너 메타데이터로 배너 토글·위치와 한 묶음
expect(findById(root, 'field_cookie_policy_version')).toBeTruthy();
// subsection_cookie_policy_version 헤더는 단일 개념 통합 후 제거됨
expect(findById(root, 'subsection_cookie_policy_version')).toBeNull();
});
});
describe('card_cookie_banner — 섹션 배치 순서 (운영자 의사결정 흐름)', () => {
it('배너 표시 → 카테고리 순서로 배치된다 (자동 차단 정책은 별도 카드로 이동)', () => {
const fields = findById(root, 'card_cookie_banner_fields');
const childrenIds = ((fields?.children ?? []) as AnyNode[])
.map((c) => c.id)
.filter((id): id is string => typeof id === 'string');
const idxBannerEnabled = childrenIds.indexOf('field_banner_enabled');
const idxBannerPosition = childrenIds.indexOf('field_banner_position');
const idxPolicyVersion = childrenIds.indexOf('field_cookie_policy_version');
const idxCategories = childrenIds.indexOf('subsection_cookie_categories');
// 정책 버전은 배너 표시 그룹 안 (배너 토글·위치 다음, 카테고리 이전)
expect(idxBannerEnabled).toBeGreaterThanOrEqual(0);
expect(idxBannerPosition).toBeGreaterThan(idxBannerEnabled);
expect(idxPolicyVersion).toBeGreaterThan(idxBannerPosition);
expect(idxCategories).toBeGreaterThan(idxPolicyVersion);
// 자동 차단 섹션은 별도 카드(card_auto_blocking_policy)로 이동
expect(childrenIds).not.toContain('subsection_auto_blocking');
expect(childrenIds).not.toContain('subsection_blocked_domains');
});
});
describe('card_auto_blocking_policy — 자동 차단 정책 별도 카드', () => {
it('off_hint(배너 OFF분기) → subsection_blocked_domains(ON분기) 순서. 토글 노드 없음', () => {
const fields = findById(root, 'card_auto_blocking_policy_fields');
expect(fields).toBeTruthy();
const childrenIds = ((fields?.children ?? []) as AnyNode[])
.map((c) => c.id)
.filter((id): id is string => typeof id === 'string');
const idxOffHint = childrenIds.indexOf('blocked_domains_off_hint');
const idxBlocked = childrenIds.indexOf('subsection_blocked_domains');
expect(idxOffHint).toBeGreaterThanOrEqual(0);
expect(idxBlocked).toBe(idxOffHint + 1);
// 위반 조합 차단 — field_auto_blocking_enabled 토글 노드는 제거됨
expect(childrenIds).not.toContain('field_auto_blocking_enabled');
});
});
describe('card_auto_blocking_policy — F-02 추적 도메인 차단 섹션', () => {
it('subsection_blocked_domains 가 banner_enabled === true 가드로 보호된다 (쿠키 배너 OFF 시 본문 노출 안 함)', () => {
const sub = findById(root, 'subsection_blocked_domains');
expect(sub).toBeTruthy();
// 단일 토글 통합 — banner_enabled ON 일 때만 차단 도메인 본문 노출
expect(sub?.if).toBe('{{_local.form?.banner_enabled === true}}');
});
it('쿠키 배너 노출 OFF 시 안내 박스 — 한 줄 구조 + blue 좌측 색띠 시각 형태', () => {
const offHint = findById(root, 'blocked_domains_off_hint');
expect(offHint).toBeTruthy();
// 단일 토글 통합 — banner_enabled OFF (= !banner_enabled) 일 때 안내 표시
expect(offHint?.if).toBe('{{!_local.form?.banner_enabled}}');
const serialized = JSON.stringify(offHint);
// 안내 텍스트
expect(serialized).toContain('blocked_domains.auto_blocking_off_hint');
// ON 박스와 동일한 시각 형태 — blue 좌측 색띠 + circle-info
expect(serialized).toContain('border-l-4');
expect(serialized).toContain('border-l-blue-500');
expect(serialized).toContain('circle-info');
// 한 줄 + 중간 정렬
expect(serialized).toContain('flex items-center');
// 다크모드 가독성: 카드(gray-800)보다 한 단계 어두운 gray-900
expect(serialized).toContain('dark:bg-gray-900');
// 옛 단일 회색 박스(blocked_domains_auto_off_warning) + 옛 wrapper 는 모두 제거
expect(findById(root, 'blocked_domains_auto_off_warning')).toBeNull();
expect(findById(root, 'blocked_domains_body')).toBeNull();
});
it('안내 박스가 3 항목 (외부 폰트 분류 / 정책 버전 bump / 도메인 형식 FQDN 흡수) + 다크모드 가독 가능한 톤 + 좌측 색 띠 + 타이틀 강조 (검토 #8)', () => {
const box = findById(root, 'blocked_domains_warnings_box');
expect(box).toBeTruthy();
const serialized = JSON.stringify(box);
// 박스 3 항목 — 외부 폰트 분류 주의 / 차단 도메인 변경 후 정책 버전 bump / 도메인 형식 (FQDN 흡수)
expect(serialized).toContain('blocked_domains.warning_visual_break');
expect(serialized).toContain('blocked_domains.warning_policy_bump');
expect(serialized).toContain('blocked_domains.warning_domain_format');
expect(serialized).toContain('blocked_domains.warnings_title');
// 메모리 규정 (운영자 안내 박스 3항목 이하 + 차분한 톤) 준수 — 박스에서 제거된 4개 항목
expect(serialized).not.toContain('blocked_domains.warning_revoke_side_effects');
expect(serialized).not.toContain('blocked_domains.preblocker_active');
expect(serialized).not.toContain('blocked_domains.self_hosted_attr');
expect(serialized).not.toContain('blocked_domains.static_html_limitation');
// 타이틀 강조 — 별도 badge 없이 제목 폰트를 text-base 로 키워 타이틀 느낌
expect(serialized).toContain('text-base');
// 옛 badge 키는 lang 과 박스에서 모두 제거됨
expect(serialized).not.toContain('blocked_domains.warnings_badge');
// 기존 옛 항목 키 미사용
expect(serialized).not.toContain('blocked_domains.hint_warning');
expect(serialized).not.toContain('blocked_domains.hint_cookie');
expect(serialized).not.toContain('blocked_domains.hint_policy_bump_before');
expect(serialized).not.toContain('blocked_domains.hint_policy_bump_after');
// 톤: amber 제거 (warning 아님)
expect(serialized).not.toContain('bg-amber-50');
// 다크모드 가독성: 카드(gray-800)보다 한 단계 어두운 gray-900 + 좌측 색 띠
expect(serialized).toContain('dark:bg-gray-900');
expect(serialized).toContain('border-l-4');
// 아이콘: circle-info (안내 성격)
expect(serialized).not.toContain('triangle-exclamation');
expect(serialized).toContain('circle-info');
});
it('카탈로그 사용 토글은 단일 개념 통합 후 제거되었다 (banner_enabled 단일 토글로 일원화)', () => {
// 옛 field_blocked_domains_default_catalog 토글 + 옛 field_auto_blocking_enabled
// 토글 모두 banner_enabled (쿠키 배너 노출) 와 동일한 의미로 통합되어
// 더 이상 노출되지 않는다.
expect(findById(root, 'field_blocked_domains_default_catalog')).toBeNull();
expect(findById(root, 'field_auto_blocking_enabled')).toBeNull();
// 카탈로그 미리보기 펼침/접힘 영역도 제거됨
const card = findById(root, 'card_cookie_banner');
const serialized = JSON.stringify(card);
expect(serialized).not.toContain('blockedDomainsCatalogExpanded');
expect(serialized).not.toContain('use_default_catalog');
});
it('blocked_domains_taginputs iteration 이 cookie_categories 에서 necessary 제외하고 렌더', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
expect(wrapper).toBeTruthy();
expect(wrapper?.iteration?.source).toBe(
"(_local.form?.cookie_categories ?? []).filter(c => c?.key !== 'necessary')"
);
expect(wrapper?.iteration?.item_var).toBe('category');
});
it('TagInput 가 카테고리별 도메인 입력으로 사용되며 creatable=true 자유 입력 허용', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
const serialized = JSON.stringify(wrapper);
// TagInput composite 사용
expect(serialized).toContain('"name":"TagInput"');
// creatable: true 로 자유 입력 허용
expect(serialized).toContain('"creatable":true');
// value 는 form.blocked_domains.{category.key}
expect(serialized).toContain('_local.form?.blocked_domains?.[category.key] ?? []');
// options 는 카탈로그 미리보기에서 동적 추천 (BE 단계 2 보강의 default_blocked_domains_preview)
expect(serialized).toContain('default_blocked_domains_preview');
});
it('TagInput change 이벤트가 $event.target.value 에서 string 배열을 추출하여 form.blocked_domains 정적 키로 setState', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
const serialized = JSON.stringify(wrapper);
// 정적 setState 키 — form.blocked_domains 만 사용 (CRITICAL 규정 준수)
expect(serialized).toContain('"form.blocked_domains"');
// 동적 키는 표현식 내부 spread
expect(serialized).toContain('[category.key]');
// change 이벤트 등록
expect(serialized).toContain('"type":"change"');
// TagInput onChange 는 createFakeEvent 로 { target: { value: string[] } } 형태
// 가짜 이벤트를 보내므로 $event.target.value 로 실제 배열 추출 필수.
// (옛 코드는 $event 객체를 그대로 할당해 칩이 [object Object] 로 표시되는 회귀 발생)
expect(serialized).toContain('$event?.target?.value');
});
it('카탈로그 도메인 일괄 추가 링크 미존재 — 신규 설치 시 카탈로그가 이미 기본값으로 채워져 있고, 카탈로그 갱신 빈도 낮음. 운영자가 의도적으로 제외한 도메인이 재추가되는 위험 회피 (검토 #9)', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
const serialized = JSON.stringify(wrapper);
// 일괄 추가 링크 라벨/툴팁 키 사용 금지 (lang 키 자체 제거됨)
expect(serialized).not.toContain('blocked_domains.add_catalog_link');
// 합집합 로직 (Set 기반 일괄 추가) 미존재
expect(serialized).not.toContain('new Set([');
// 단, TagInput 의 자동완성 options 출처로는 default_blocked_domains_preview 가 계속 사용됨
expect(serialized).toContain('default_blocked_domains_preview');
});
it('FQDN 형식 안내가 안내 박스 안 3번째 항목(warning_domain_format)으로 흡수되고 외부 P 노드는 제거되었다 (검토 #8 옵션 B-원안)', () => {
const sub = findById(root, 'subsection_blocked_domains');
const serialized = JSON.stringify(sub);
// FQDN 안내가 박스 안 단일 항목으로 흡수됨
const box = findById(root, 'blocked_domains_warnings_box');
const boxSerialized = JSON.stringify(box);
expect(boxSerialized).toContain('blocked_domains.warning_domain_format');
// 옛 외부 P 노드 (hint_format) 가 완전히 제거됨 — 안내 영역이 단일 박스로 통합
expect(serialized).not.toContain('blocked_domains.hint_format');
// 외부 hint 의 whitespace-pre-line 클래스도 제거 (옛 P 노드 흔적)
expect(serialized).not.toContain('whitespace-pre-line');
});
it('422 검증 에러 시 카테고리 카드 외곽이 빨간 테두리로 강조 + 카드 내부에 첫 잘못된 도메인 에러 메시지 Span 노출 (검토 #10 옵션 B — 카테고리 내 어느 인덱스든 매칭)', () => {
const wrapper = findById(root, 'blocked_domains_taginputs');
const serialized = JSON.stringify(wrapper);
// 카드 외곽 동적 className — 카테고리 내 *어느 인덱스든* 에러 키가 있으면 true (some)
expect(serialized).toContain("Object.keys(_local.errors ?? {}).some(k => k.startsWith('blocked_domains.' + category.key + '.'))");
expect(serialized).toContain('border-red-500');
expect(serialized).toContain('border-gray-200');
// 카드 내부 에러 Span — Object.entries + find 로 첫 매칭 키의 첫 메시지 추출
expect(serialized).toContain("Object.entries(_local.errors ?? {}).find(([k]) => k.startsWith('blocked_domains.' + category.key + '.'))");
// 에러 Span 시각 시맨틱 (form-error) — 텍스트 톤 + 줄바꿈을 한 결로 통일 (#399)
expect(serialized).toContain('"form-error"');
// 단, 정적 setState 키 (form.blocked_domains) 는 그대로 유지 (회귀 차단)
expect(serialized).toContain('"form.blocked_domains"');
});
it('header_save_button onError 토스트가 422 응답의 첫 번째 검증 에러 메시지를 노출 + fallback 일반화 메시지 유지 (검토 #10 옵션 A 기반)', () => {
const saveBtn = findById(root, 'header_save_button');
const serialized = JSON.stringify(saveBtn);
// 첫 번째 검증 에러 메시지 추출 표현식
expect(serialized).toContain('Object.values(error.errors ?? {})[0]');
// fallback 으로 기존 save_error lang 키 유지
expect(serialized).toContain('sirsoft-gdpr.settings.save_error');
// setState 로 errors 저장 (상단 통합 박스 + 카드 내부 표시에 모두 사용)
expect(serialized).toContain('"errors"');
});
it.each([
['field_legal_entity_name', 'legal_entity_name'],
['field_data_storage_location', 'data_storage_location'],
['field_privacy_policy_slug', 'privacy_policy_slug'],
])('단일 Input 필드 %s — 422 검증 에러 시 빨간 테두리 + 필드 아래 에러 메시지 (검토 #10 옵션 X — 코어 _tab_general 단순 패턴)', (fieldId, formField) => {
const wrapper = findById(root, fieldId);
expect(wrapper).not.toBeNull();
const serialized = JSON.stringify(wrapper);
// Input className 동적 변경 — 대괄호 표기법으로 errors 키 접근 (코어 _tab_general 패턴)
expect(serialized).toContain(`_local.errors?.['${formField}']`);
expect(serialized).toContain('border-red-500');
expect(serialized).toContain('focus:ring-red-500');
// 필드 아래 에러 Span — if 가드 + 첫 메시지 text (코어 단순 패턴)
expect(serialized).toContain(`_local.errors?.['${formField}']?.[0]`);
// 에러 Span 시각 시맨틱 (form-error) — 텍스트 톤 + 줄바꿈을 한 결로 통일 (#399)
expect(serialized).toContain('"form-error"');
});
it('field_cookie_policy_version 는 Input 이 아닌 read-only 카드 — gdpr_policy_versions 테이블이 SSoT (옵션 C 정책 버전 자동 발행 + 운영자 수동 발행)', () => {
const wrapper = findById(root, 'field_cookie_policy_version');
expect(wrapper).not.toBeNull();
const serialized = JSON.stringify(wrapper);
// 옛 Input + name="cookie_policy_version" 패턴 제거됨
expect(serialized).not.toContain('"name":"cookie_policy_version"');
// 새 카드: 현재 버전 표시 + 새 버전 발행 + 이력 토글
expect(serialized).toContain('gdprPolicyVersionCurrent?.data?.data?.version');
// 새 버전 발행 버튼 — publish modal 진입
expect(serialized).toContain('sirsoft-gdpr.settings.policy_version.publish_button');
expect(serialized).toContain('policy_version_publish_modal');
// 이력 collapsible 토글 라벨 (열림/닫힘 두 키 모두 사용)
expect(serialized).toContain('sirsoft-gdpr.settings.policy_version.history_toggle_show');
expect(serialized).toContain('sirsoft-gdpr.settings.policy_version.history_toggle_hide');
// 토글은 _local.policyHistoryOpen state 사용 + 동시에 refetch
expect(serialized).toContain('_local.policyHistoryOpen');
expect(serialized).toContain('refetchDataSource');
expect(serialized).toContain('gdprPolicyVersionHistory');
// 옛 history modal 진입 패턴 제거 — 모달이 아닌 inline collapsible 로 전환됨
expect(serialized).not.toContain('policy_version_history_modal');
expect(serialized).not.toContain('sirsoft-gdpr.settings.policy_version.history_button');
});
it('정책 버전 이력은 inline collapsible Table 로 노출 — _local.policyHistoryOpen 가드로 카드 본문 아래에서 펼침/닫힘 (모달 비사용)', () => {
const wrapper = findById(root, 'field_cookie_policy_version');
const serialized = JSON.stringify(wrapper);
// 표 헤더 5개 (version / created_at / publisher / change_type / memo)
expect(serialized).toContain('history.col.version');
expect(serialized).toContain('history.col.created_at');
expect(serialized).toContain('history.col.publisher');
expect(serialized).toContain('history.col.change_type');
expect(serialized).toContain('history.col.memo');
// 이력 소스 데이터바인딩
expect(serialized).toContain('gdprPolicyVersionHistory?.data?.data');
// 빈 상태 메시지 키
expect(serialized).toContain('sirsoft-gdpr.settings.policy_version.history_empty');
// collapsible 가드 if
expect(serialized).toContain('{{_local.policyHistoryOpen}}');
// 옛 history modal partial 등록 제거 + 옛 modal id 미사용
const modals = (root as { modals?: Array<{ partial?: string }> }).modals ?? [];
const partials = modals.map((m) => m.partial ?? '');
expect(partials.some((p) => p.includes('_policy_version_history_modal'))).toBe(false);
});
it('정책 버전 발행 modal (publish modal) partial 이 modals 배열에 등록되어 있다 — 운영자 수동 발행 진입점', () => {
const modals = (root as { modals?: Array<{ partial?: string }> }).modals ?? [];
const partials = modals.map((m) => m.partial ?? '');
expect(partials.some((p) => p.includes('_policy_version_publish_modal'))).toBe(true);
});
describe('정책 버전 발행 모달 (_policy_version_publish_modal.json) — 진행 상태 UX', () => {
const modalText = JSON.stringify(publishModal);
it('발행 버튼은 isPublishing 동안 spinner + "발행 중..." 라벨 노출 + 양쪽 버튼 disabled', () => {
// PO 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가
// 발행 진행 상태를 알 수 없었음. _local.isPublishing 플래그로 발행 중 시각 피드백 +
// 양쪽 버튼 disabled (취소 버튼도 발행 중엔 막아 도중 닫힘 방지).
// JSON.stringify 기본 출력은 공백 없는 형식 (콜론 뒤 공백 없음).
expect(modalText).toContain('"isPublishing":true');
expect(modalText).toContain('"isPublishing":false');
expect(modalText).toContain('"name":"spinner"');
expect(modalText).toContain('"spin":true');
expect(modalText).toContain('sirsoft-gdpr.settings.policy_version.publishing');
// 취소 버튼도 isPublishing 동안 disabled (도중 닫힘 방지)
expect(modalText).toContain('"disabled":"{{_local.isPublishing}}"');
});
it('closeModal 이 onSuccess 안으로 이동 — 발행 완료 후에만 모달 닫힘 (발행 실패 시 모달 유지하여 재시도 가능)', () => {
// 회귀 가드: 이전 패턴 (sequence 의 첫 액션 closeModal → 그 다음 apiCall) 은
// 발행 실패해도 모달이 닫혀버려 사용자가 처음부터 다시 열어야 했음. closeModal 을
// onSuccess 마지막에 두어 발행 완료 시점에만 닫히고, onError 면 모달 유지.
// onSuccess 배열 안에 closeModal 핸들러가 존재
const onSuccessMatch = modalText.match(/"onSuccess":\[[^\]]*"closeModal"[^\]]*\]/);
expect(onSuccessMatch).not.toBeNull();
// onError 배열 안엔 closeModal 없음 (실패 시 모달 유지)
const onErrorMatch = modalText.match(/"onError":\[([^\]]*)\]/);
expect(onErrorMatch?.[1] ?? '').not.toContain('closeModal');
});
});
it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (PO 피드백: 토글/박스 제거)', () => {
const box = findById(root, 'policy_version_guide_box');
expect(box).not.toBeNull();
const serialized = JSON.stringify(box);
// 2개 다국어 키가 P 텍스트로 노출됨
// 1줄: cookie_policy_version.hint (발행 시점 안내)
// 2줄: policy_version.guide_body (발행 대상 구분)
expect(serialized).toContain('cookie_policy_version.hint');
expect(serialized).toContain('policy_version.guide_body');
// amber 색 회귀 가드 — 통째로 제거
expect(serialized).not.toContain('border-l-amber-500');
expect(serialized).not.toContain('bg-amber-50');
// 토글 회귀 가드 — chevron / policyGuideOpen / circle-question 모두 제거
expect(serialized).not.toContain('policyGuideOpen');
expect(serialized).not.toContain('chevron-down');
expect(serialized).not.toContain('chevron-up');
// guide_title 도 노출 안 함 (토글 헤더로 쓰던 라벨이라 더 이상 필요 없음)
expect(serialized).not.toContain('policy_version.guide_title');
// 옛 발행 항목 keys 미사용 (압축 회귀 가드 보존)
expect(serialized).not.toContain('guide_publish_item_');
expect(serialized).not.toContain('guide_no_publish_');
});
it('init_actions 에 _local.policyGuideOpen 키가 더 이상 존재하지 않는다 (토글 제거 회귀 가드)', () => {
const initActions = (root as { init_actions?: Array<{ params?: Record<string, unknown> }> }).init_actions ?? [];
const setStateAction = initActions.find((a) => (a as { handler?: string }).handler === 'setState');
expect(setStateAction).toBeTruthy();
const params = setStateAction?.params as Record<string, unknown> | undefined;
expect(params).toBeTruthy();
expect(Object.keys(params ?? {})).not.toContain('policyGuideOpen');
});
it('상단 저장 sequence 는 단순 save_success 토스트만 노출 — published_version 분기 / 정책 버전 refetch 제거 (수동 발행 모델)', () => {
const btn = findById(root, 'header_save_button');
const serialized = JSON.stringify(btn);
// 옛 자동 발행 흐름의 분기 / refetch 제거 — 저장은 정책 버전을 발행하지 않으므로 분기 불필요
expect(serialized).not.toContain('response?.data?.published_version');
expect(serialized).not.toContain('policy_version.publish_success');
expect(serialized).not.toContain('gdprPolicyVersionCurrent');
// 단순 save_success 토스트만 사용
expect(serialized).toContain('sirsoft-gdpr.settings.save_success');
});
it('데이터소스 — gdprPolicyVersionCurrent (auto) + gdprPolicyVersionHistory (on demand)', () => {
const dataSources = (root as { data_sources?: { id: string; auto_fetch?: boolean }[] }).data_sources ?? [];
const ids = dataSources.map((d) => d.id);
expect(ids).toContain('gdprPolicyVersionCurrent');
expect(ids).toContain('gdprPolicyVersionHistory');
const current = dataSources.find((d) => d.id === 'gdprPolicyVersionCurrent');
expect(current?.auto_fetch).toBe(true);
const history = dataSources.find((d) => d.id === 'gdprPolicyVersionHistory');
expect(history?.auto_fetch).toBe(false);
});
});
describe('마이페이지 동의 관리 카드 제거 (GDPR Art.7(3) 대칭성 의무)', () => {
it('card_mypage_data 카드는 존재하지 않는다 — 플러그인 활성 시 카드는 항상 노출이므로 운영자 토글 불필요', () => {
expect(findById(root, 'card_mypage_data')).toBeNull();
expect(findById(root, 'card_mypage_data_fields')).toBeNull();
expect(findById(root, 'field_mypage_privacy_tab_visible')).toBeNull();
});
});
describe('necessary 안내 P 노드 제거 (검토 #7)', () => {
/**
* 회귀 가드: 카테고리 카드 묶음 아래 별도 P 노드 (category_edit.required_locked_hint) 가
* 섹션 desc (cookie_categories.description) 와 중복되어 제거됨.
* "필수 카테고리는 회원이 거부할 수 없습니다" 안내는 섹션 desc 한 곳으로 통합.
*/
const layoutJson = JSON.stringify(root);
it('category_edit.required_locked_hint 키가 layout 어디에도 사용되지 않는다', () => {
expect(layoutJson).not.toContain('category_edit.required_locked_hint');
expect(layoutJson).not.toContain('required_locked_hint');
});
});
describe('카테고리 라벨/설명 — locale 자동 반영 (#30 옵션 C)', () => {
/**
* 회귀 가드: 카테고리 라벨/설명이 `?.ko` / `?.en` 고정 키 참조가 아니라
* `$localized()` 헬퍼로 현재 locale 을 자동 반영해야 한다.
* 영문 모드에서 한국어가 메인으로 노출되던 i18n 회귀 차단.
*/
const layoutJson = JSON.stringify(root);
it('카테고리 라벨 바인딩이 ?.ko / ?.en 고정 참조를 사용하지 않는다', () => {
// 고정 locale 참조 패턴이 남아 있으면 안 됨
expect(layoutJson).not.toMatch(/category\?\.label\?\.ko/);
expect(layoutJson).not.toMatch(/category\?\.label\?\.en/);
expect(layoutJson).not.toMatch(/category\?\.description\?\.ko/);
expect(layoutJson).not.toMatch(/category\?\.description\?\.en/);
});
it('카테고리 라벨/설명은 $localized() 헬퍼를 사용한다', () => {
// category.label / category.description 은 $localized() 로 감싸 현재 locale 반영
const labelMatches = layoutJson.match(/\$localized\(category\?\.label\)/g) ?? [];
const descMatches = layoutJson.match(/\$localized\(category\?\.description\)/g) ?? [];
// 라벨: 최소 2회 사용 (카테고리 카드 + 도메인 차단 섹션 라벨)
expect(labelMatches.length, '카테고리 라벨에 $localized() 헬퍼가 2회 이상 사용됨').toBeGreaterThanOrEqual(2);
// 설명: 최소 2회 사용 (description if 조건 + 본문)
expect(descMatches.length, '카테고리 설명에 $localized() 헬퍼가 2회 이상 사용됨').toBeGreaterThanOrEqual(2);
});
it('카테고리 보조 라벨에 카탈로그 key 가 노출된다 (DB/코드 매핑 가독성)', () => {
// 옵션 C 의 디버그/매핑 보조 정보 — (necessary), (analytics) 같은 key 표시 유지
expect(layoutJson).toMatch(/\(\{\{category\?\.key \?\? ''\}\}\)/);
});
});
describe('제거된 DPO / 제3자 필드는 노출되지 않는다', () => {
it.each([
'field_dpo_name',
'field_dpo_email',
'field_dpo_phone',
'field_dpo_position',
'field_dpo_address',
'field_dpo_footer_visible',
'field_dpo_footer_format',
'field_data_request_response_days',
'field_export_retention_days',
'field_dpo_self_review_allowed',
'field_order_consent_enabled',
'third_party_providers_table',
'third_party_providers_empty',
'third_party_providers_rows',
])('필드 %s 미존재', (fieldId) => {
expect(findById(root, fieldId)).toBeNull();
});
});
/**
* 작업 2 (B-3 옵션 X) — 정책 버전 v배지 + 이력 표 행 클릭 → snapshot 모달.
* GDPR Art.7(1) 입증 책임 — DPO 가 분쟁 시 즉시 그 시점 정책 본문 확인 가능.
*/
describe('작업 2: 정책 버전 snapshot 조회', () => {
const layoutJson = JSON.stringify(layout);
it('gdprPolicyVersionSnapshot data_source 가 등록되어 있다', () => {
expect(layoutJson).toContain('"id":"gdprPolicyVersionSnapshot"');
expect(layoutJson).toContain('/api/plugins/sirsoft-gdpr/admin/policy-versions/{{_global.viewingPolicyVersion}}');
});
it('snapshot 모달 partial 이 _shared 위치에 등록되어 있다', () => {
expect(layoutJson).toContain('partials/_shared/_policy_version_snapshot_modal.json');
});
it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (PO 피드백 — 평문 + 명시 액션 분리)', () => {
// v배지는 평문 Span (text 'v...' 으로 항상 표시), 본문 보기 Button 은 if 분기 + sequence
expect(layoutJson).toContain('"text":"v{{gdprPolicyVersionCurrent?.data?.data?.version ?? \'?\'}}"');
expect(layoutJson).toContain('"if":"{{gdprPolicyVersionCurrent?.data?.data?.version}}"');
expect(layoutJson).toContain('"viewingPolicyVersion":"{{gdprPolicyVersionCurrent?.data?.data?.version}}"');
expect(layoutJson).toContain('"dataSourceId":"gdprPolicyVersionSnapshot"');
expect(layoutJson).toContain('"target":"policy_version_snapshot_modal"');
expect(layoutJson).toContain('snapshot_view_short');
});
it('이력 표에 본문 컬럼이 추가되고 각 행에 본문 보기 Button 이 노출된다', () => {
// Thead 에 snapshot_action 컬럼 헤더 추가
expect(layoutJson).toContain('history.col.snapshot_action');
// 각 행의 본문 액션 셀 - viewingPolicyVersion=row.version + sequence
expect(layoutJson).toContain('"viewingPolicyVersion":"{{row?.version}}"');
});
it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (PO 피드백: 토글/박스 제거)', () => {
const box = findById(root, 'policy_version_guide_box');
expect(box).not.toBeNull();
// wrapper 는 className 없음 (라벨 + hint 두 줄을 단순 그룹핑)
const className = String((box?.props as { className?: string } | undefined)?.className ?? '');
expect(className).not.toContain('border');
expect(className).not.toContain('rounded-lg');
// 자식은 Label + 2개 P (hint + guide_body)
const children = (box?.children ?? []) as Array<{ name?: string }>;
const names = children.map((c) => c.name);
expect(names).toContain('Label');
expect(names.filter((n) => n === 'P')).toHaveLength(2);
});
});
});
@@ -0,0 +1,488 @@
/**
* 쿠키 동의 배너 Layout Extension (W-1)
*
* @description
* - extension_point: user_global_overlay (sirsoft-basic _user_base 가 사전에 정의)
* - banner_enabled === true && !gdprBannerDismissed 조건 표시
* - 모두 동의 / 필수만 사용 / 환경설정 / 정책 링크 제공
* - 환경설정은 모달이 아니라 배너 내부 인라인 펼침 패널 (gdpr_preferences_panel)
* - banner_position 4종 분기 (bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal)
* - bottom_bar 는 화면 하단 풀폭 팝업 카드 (bottom-4 left-4 right-4 rounded-lg)
*/
import { describe, it, expect } from 'vitest';
import extension from '../../../extensions/cookie_banner.json';
import { findById, collectHandlers, collectGdprI18nKeys, type AnyNode , serializeForSearch } from './helpers';
describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
const root = extension as unknown as AnyNode & {
extension_point?: string;
mode?: string;
components?: AnyNode[];
modals?: AnyNode[];
priority?: number;
};
describe('Extension 메타데이터', () => {
it('extension_point 가 user_global_overlay 이다 (sirsoft-basic _user_base 가 사전 정의)', () => {
expect(root.extension_point).toBe('user_global_overlay');
});
it('mode 는 append (default 컴포넌트 뒤에 추가)', () => {
expect(root.mode).toBe('append');
});
it('priority 는 50 (높은 우선순위)', () => {
expect(root.priority).toBe(50);
});
it('gdprPublicSettings 데이터소스가 포함된다', () => {
const ds = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const settings = ds.find((d) => d.id === 'gdprPublicSettings');
expect(settings).toBeTruthy();
expect((settings as { auth_required?: boolean }).auth_required).toBe(false);
});
it('gdprMyConsent 데이터소스가 포함된다 (현재 방문자 동의 완료 여부, auth_mode:optional)', () => {
// 회귀 가드: 새로고침 후에도 배너 재출력 방지를 위한 서버 조회 데이터소스.
// /api/plugins/sirsoft-gdpr/consent/cookie/status — 회원/게스트 통합 응답.
// auth_mode:optional 은 회원 토큰이 있으면 함께 전송하여 서버가 회원 동의 상태를 반환하게 한다.
// 토큰을 보내지 않으면 (auth_required:false) 서버 라우트 optional.sanctum 가 항상 게스트로 인식
// → 회원 컨텍스트 누락으로 has_consented:false 가 잘못 반환되어 배너가 다시 뜸.
const ds = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const myConsent = ds.find((d) => d.id === 'gdprMyConsent');
expect(myConsent).toBeTruthy();
expect((myConsent as { endpoint?: string }).endpoint).toBe(
'/api/plugins/sirsoft-gdpr/consent/cookie/status'
);
expect((myConsent as { auth_mode?: string }).auth_mode).toBe('optional');
});
it('modals 섹션이 없다 (환경설정은 인라인 펼침으로 처리)', () => {
// 회귀 가드: 이전엔 gdpr_preferences_modal 모달을 분리 정의했으나
// 배너 내부 인라인 펼침 패널(gdpr_preferences_panel)로 통합됨.
expect(root.modals ?? []).toHaveLength(0);
});
});
describe('주입 컴포넌트', () => {
it('components 배열의 첫 컴포넌트가 gdpr_cookie_banner', () => {
const first = root.components?.[0];
expect((first as { id?: string })?.id).toBe('gdpr_cookie_banner');
});
});
describe('배너 표시 조건', () => {
const banner = findById(root, 'gdpr_cookie_banner');
it('배너 컴포넌트가 존재한다', () => {
expect(banner).toBeTruthy();
});
it('banner_enabled + has_consented + dismissed 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.<key>)', () => {
// 회귀 가드:
// 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `<id>.data.<key>` 한 단계 경로.
// 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고.
// 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — PO 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
expect(banner?.if).toBe(
'{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}'
);
});
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (PO 결정 — 배너 통합)', () => {
// 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해
// 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
const modal = findById(root, 'gdpr_needs_renewal_modal');
expect(modal).toBeNull();
});
it('회원 + needs_renewal=true 한정 「현재 동의 갱신」 버튼이 배너 액션 영역에 노출 (POST /consent/renew-all)', () => {
const keepBtn = findById(root, 'gdpr_keep_consent_button');
expect(keepBtn).not.toBeNull();
// 회원/게스트 식별은 status 응답의 is_member 필드 — G7 layout 컨텍스트엔 auth 변수가
// 존재하지 않아 옛 auth?.user?.id 패턴은 항상 undefined 로 평가되던 회귀 회피.
expect(keepBtn?.if).toContain('gdprMyConsent?.data?.is_member === true');
expect(keepBtn?.if).toContain('needs_renewal === true');
const json = JSON.stringify(keepBtn);
expect(json).toContain('/api/plugins/sirsoft-gdpr/consent/renew-all');
// 라벨은 마이페이지 amber 박스와 의미 일치하는 신 키 사용 (banner.btn_renew_consent)
expect(json).toContain('sirsoft-gdpr.banner.btn_renew_consent');
// onSuccess 에 toast 핸들러 — 마이페이지 amber 박스와 동일 lang 키 (renew_all_success)
// 사용자 피드백 일관성 + 토큰 {renewed} 보간으로 갱신 항목 수 안내
expect(json).toContain('sirsoft-gdpr.mypage.privacy.renew_all_success');
expect(json).toContain('|renewed=');
});
it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (PO 결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => {
const guestBtn = findById(root, 'gdpr_keep_consent_button_guest');
expect(guestBtn).toBeNull();
});
it('옛 banner.keep_label 키는 더 이상 참조되지 않는다 (회귀 가드 — 신 키 banner.btn_renew_consent 로 통일)', () => {
const text = serializeForSearch(root);
expect(text).not.toContain('banner.keep_label');
});
it('master_switch 표현식 미사용 (v1.1.0)', () => {
expect(banner?.if ?? '').not.toContain('master_switch');
});
});
describe('banner_position 분기', () => {
const banner = findById(root, 'gdpr_cookie_banner');
const className = (banner?.props as { className?: string })?.className ?? '';
it.each([
'bottom_left_popup',
'bottom_right_popup',
'centered_modal',
])('%s 위치를 className에서 분기 처리', (position) => {
expect(className).toContain(`'${position}'`);
});
it('기본(bottom_bar) 위치는 풀폭 팝업 카드 (bottom-4 left-4 right-4 rounded-lg)', () => {
// 회귀 가드: 이전엔 화면 하단에 딱 붙는 띠(bottom-0)였지만,
// PO 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨.
expect(className).toContain('bottom-4 left-4 right-4 rounded-lg');
});
it('top_bar 분기는 제거되어 있다', () => {
// 회귀 가드: PO 결정으로 banner_position 옵션에서 top_bar 제거됨.
expect(className).not.toContain("'top_bar'");
});
it('centered_modal 분기는 다른 팝업과 동일한 박스 형태 — 중앙 정렬만 다름', () => {
// 누적 검토 #12: 이전 구현은 외곽 Div 에 inset-0 + bg-black/40 (전체 화면 dim) +
// 자식 Div 에 별도 bg-white 박스. 외곽 className 이 'fixed ... bg-white' 와 'bg-black/40'
// 두 배경을 동시에 가져 Tailwind 빌드 후 뒤에 오는 bg-white 가 이김 → 화면 전체가 흰 박스.
// 수정 후 다른 3종 팝업과 동일한 max-w-md 박스 + top-1/2 -translate-y-1/2 + inset-x-4 mx-auto
// (모바일 1rem gutter, 데스크탑 max-width 까지 가로 중앙) 패턴.
expect(className).toContain('top-1/2');
expect(className).toContain('-translate-y-1/2');
expect(className).toContain('inset-x-4');
expect(className).toContain('mx-auto');
// 4종 위치 모두 max-w-md 박스 cap (bottom_bar 는 left-4 right-4 로 풀폭이므로 제외)
// 정규식: 위치 키 ↔ 따옴표 사이 분기 분리자 (`) ? ' ... '` 또는 `? ' ... '`) 허용
const popupPositions = ['bottom_left_popup', 'bottom_right_popup', 'centered_modal'];
for (const pos of popupPositions) {
expect(className).toMatch(new RegExp(`'${pos}'[^']*\\?\\s*'[^']*max-w-md`));
}
});
it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (PO 의도: 다른 팝업과 동일 외형)', () => {
// 회귀 가드: 이전 구현은 외곽에 bg-black/40 dim 을 깔았으나 자식 박스가 dim 위에 떠야 하는
// 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. PO 가 dim 강제 모달이 아닌
// 단순 중앙 배치만 원하므로 dim 클래스 자체를 제거.
expect(className).not.toContain('bg-black/40');
expect(className).not.toContain('bg-black/50');
expect(className).not.toContain('bg-black/60');
expect(className).not.toContain('bg-black/70');
// inset-0 (화면 전체 덮기) 사용도 금지 — centered_modal 분기 한정으로 확인
expect(className).not.toMatch(/'centered_modal'\s*\?\s*'[^']*inset-0/);
});
it('Tailwind 임의값(arbitrary value) 미사용 — safelist 규정 준수', () => {
// 회귀 가드: w-[calc(100%-2rem)] 같은 임의값은 safelist 미등록 시 빌드에서 제외되어
// 클래스 미적용. memory/feedback_layout_tailwind_arbitrary_values.md 규정.
expect(className).not.toMatch(/\[\S+\]/);
});
it('자식 Div 에 centered_modal 분기 className 이 남아 있지 않다 (이중 박스 회귀 가드)', () => {
// 회귀 가드: 이전 구현은 자식 Div 에도
// 'bg-white dark:bg-gray-800 rounded-lg max-w-lg w-full mx-4 shadow-xl' 를 centered_modal
// 일 때 추가로 입혔다 — 외곽이 박스 시각 속성을 갖게 되면서 자식의 분기는 이중 적용 회귀 원인.
// 자식 Div 는 패딩만 담당해야 한다.
const child = banner?.children?.[0] as { props?: { className?: string } } | undefined;
const childClassName = child?.props?.className ?? '';
expect(childClassName).not.toContain('centered_modal');
expect(childClassName).not.toContain('shadow-xl');
expect(childClassName).not.toContain('max-w-lg');
});
});
describe('동의 액션 3종', () => {
const banner = findById(root, 'gdpr_cookie_banner');
const text = serializeForSearch(banner);
it('"모두 동의" 시 모든 카테고리 true로 POST (cookie_ prefix)', () => {
// 백엔드 화이트리스트는 cookie_necessary / cookie_functional / cookie_analytics / cookie_marketing
// (CookieCategoryService::getAllConsentKeys 가 'cookie_' . c.key 반환).
// Phase 1: functional 카테고리 추가 — iteration 기반이라 카테고리 데이터에 functional 가 있으면 자동 노출
expect(text).toContain("['cookie_' + c.key]: true");
expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/cookie');
});
it('Phase 1: iteration 이 gdprPublicSettings.data.cookie_categories 기반이라 functional 등 4종 카테고리가 자동 토글 노출', () => {
// 카테고리 토글은 cookie_categories 데이터를 iteration 으로 렌더 → 서버 응답에 functional 가 있으면 자동 노출.
// 별도 분기 추가 불필요. 회귀 가드: iteration source 가 cookie_categories 전체 사용.
expect(text).toContain("gdprPublicSettings?.data?.cookie_categories");
// 필수 표시는 c.required === true 기반 — necessary 만 lock
expect(text).toContain('c.required === true');
});
it('"모두 동의" 버튼은 회원 + needs_renewal=true 일 때만 미노출 (GDPR Art.7(3) 자유 변경권 보호 — 이전 철회 의사 덮어쓰기 방지)', () => {
// 회원: 옆 keep_consent 버튼이 활성 동의만 신정책으로 갱신 → "모두 동의" 숨김.
// 게스트: status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용.
// 회원 식별은 status 응답의 is_member 필드 (G7 layout 컨텍스트엔 auth 변수 없음 — 옛 패턴 회귀).
expect(text).toContain('!(gdprMyConsent?.data?.is_member === true && gdprMyConsent?.data?.needs_renewal === true)');
});
it('"필수만 사용" 시 required 카테고리만 true (cookie_ prefix)', () => {
expect(text).toContain("['cookie_' + c.key]: c.required === true");
});
it('POST body 키는 백엔드 검증 규칙(StoreCookieConsentRequest)과 일치 — consents + source 사용', () => {
// 회귀 가드: 백엔드는 consents (객체, cookie_ prefix 키) + source ("banner") 를 요구한다.
// 과거 categories / policy_version 으로 잘못 보내서 422 "유효하지 않은 동의 항목입니다." 반환됨.
expect(text).toContain('consents:');
expect(text).toContain("source: 'banner'");
expect(text).not.toContain('categories:');
});
it('동의 저장 직후 onSuccess 에서 gdprMyConsent 를 refetch (3개 버튼 모두)', () => {
// 회귀 가드: 동의 후 새로고침 시 배너 재출력을 막으려면 서버 동의 상태를 다시 조회해야 함.
// setState gdprBannerDismissed=true 만으로는 글로벌 상태가 새로고침 시 휘발됨.
const refetchOccurrences = (text.match(/"handler":\s*"refetchDataSource"/g) ?? []).length;
expect(refetchOccurrences).toBeGreaterThanOrEqual(3);
expect(text).toContain('"dataSourceId": "gdprMyConsent"');
});
it('동의 저장 apiCall 3건 모두 auth_mode:optional (회원 토큰 전달)', () => {
// 회귀 가드: 회원이 로그인 상태로 배너에서 동의해도 토큰을 보내지 않으면
// 서버 라우트 optional.sanctum 가 항상 게스트로 인식 → g7_gdpr_user_consents 에 저장 안 되고
// history 에만 user_id=NULL 로 기록됨. 클라이언트도 토큰을 같이 보내야 회원 동의로 저장됨.
// 이전 구현(auth_required:false)은 토큰 미전송 → 회원 동의가 누락되는 회귀의 직접 원인.
const optionalOccurrences = (text.match(/"auth_mode":\s*"optional"/g) ?? []).length;
expect(optionalOccurrences).toBeGreaterThanOrEqual(3);
expect(text).not.toContain('"auth_required": false,\n "target": "/api/plugins/sirsoft-gdpr/consent/cookie"');
});
it('동의 저장 직후 onSuccess 에서 sirsoft-gdpr.syncConsent 핸들러를 호출 (3개 버튼 모두)', () => {
// v1.5.0 회귀 가드: 자동 차단 엔진이 클라이언트 쿠키 SSoT 를 버리고 서버 응답 SSoT 로
// 전환됨. 동의 저장 직후 차단 엔진의 메모리 캐시를 갱신하지 않으면 G-4 시나리오 (분석/
// 마케팅 스크립트 동의 직후 즉시 로드) 가 작동 안 함. refetchDataSource 만으로는
// blocker.ts 가 갱신을 알 수 없으므로 별도 핸들러 호출이 필요.
const syncOccurrences = (text.match(/"handler":\s*"sirsoft-gdpr\.syncConsent"/g) ?? []).length;
expect(syncOccurrences).toBeGreaterThanOrEqual(3);
});
it('"환경설정" 클릭 시 인라인 펼침 토글 (모달 미사용)', () => {
// 회귀 가드: 이전엔 openModal handler 로 별도 모달을 열었으나,
// 배너 내부 인라인 펼침으로 변경되어 setState 로 _global.gdprPreferencesOpen 토글.
const toggleBtn = findById(banner ?? null, 'gdpr_preferences_toggle_button');
expect(toggleBtn).toBeTruthy();
const toggleText = serializeForSearch(toggleBtn);
expect(toggleText).toContain('"handler": "setState"');
expect(toggleText).toContain('"gdprPreferencesOpen"');
expect(toggleText).not.toContain('openModal');
});
it('배너 4버튼은 gdprBannerSubmittingAction 식별자 패턴으로 클릭한 버튼만 spinner + 다른 버튼은 원래 라벨 유지', () => {
// 회귀 가드: PO 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어
// 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만
// 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지.
// 4 action 식별자: accept_all / reject_all / save_selection / renew_consent
expect(text).toContain('"gdprBannerSubmittingAction": "accept_all"');
expect(text).toContain('"gdprBannerSubmittingAction": "reject_all"');
expect(text).toContain('"gdprBannerSubmittingAction": "save_selection"');
expect(text).toContain('"gdprBannerSubmittingAction": "renew_consent"');
// disabled 는 4버튼 모두 같은 truthy 가드 (다른 버튼 진행 중에도 disabled)
const disabledMatches = text.match(/!!_global\.gdprBannerSubmittingAction/g) ?? [];
expect(disabledMatches.length).toBeGreaterThanOrEqual(4);
// 스피너 if 가드는 자기 식별자와 정확히 일치할 때만 노출
expect(text).toContain("_global.gdprBannerSubmittingAction === 'accept_all'");
expect(text).toContain("_global.gdprBannerSubmittingAction === 'reject_all'");
expect(text).toContain("_global.gdprBannerSubmittingAction === 'save_selection'");
expect(text).toContain("_global.gdprBannerSubmittingAction === 'renew_consent'");
// 동작별 라벨 (granting / renewing) 모두 사용
expect(text).toContain('"name": "spinner"');
expect(text).toContain('"spin": true');
expect(text).toContain('sirsoft-gdpr.consent.granting');
expect(text).toContain('sirsoft-gdpr.consent.renewing');
// 옛 단일 boolean 플래그 패턴 회귀 차단
expect(text).not.toContain('"gdprBannerSubmitting": true');
});
});
describe('환경설정 인라인 펼침 패널', () => {
const banner = findById(root, 'gdpr_cookie_banner');
const panel = findById(banner ?? null, 'gdpr_preferences_panel');
it('gdpr_preferences_panel 이 배너 내부에 존재한다', () => {
expect(panel).toBeTruthy();
});
it('_global.gdprPreferencesOpen === true 일 때만 표시된다', () => {
expect(panel?.if).toBe('{{_global.gdprPreferencesOpen === true}}');
});
it('필수 카테고리 토글은 disabled', () => {
const text = serializeForSearch(panel);
expect(text).toContain('"disabled": "{{category?.required === true}}"');
});
it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismiss + 성공 토스트', () => {
const text = serializeForSearch(panel);
expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/cookie');
expect(text).toContain('"gdprBannerDismissed": true');
expect(text).toContain('"gdprPreferencesOpen": false');
expect(text).toContain('sirsoft-gdpr.consent.granted');
});
});
describe('정책 링크', () => {
it('privacy_policy_available일 때만 링크 표시', () => {
const text = serializeForSearch(extension);
expect(text).toContain('privacy_policy_available === true');
});
});
describe('환경설정 펼침 — 카테고리 정보 영역 (#14 사용자 친화 표현)', () => {
const banner = findById(root, 'gdpr_cookie_banner');
const panel = findById(banner ?? null, 'gdpr_preferences_panel');
const text = serializeForSearch(panel);
const fullText = serializeForSearch(extension);
it('운영자용 info.{key}.{scope|tools|legal_basis} 키를 직접 참조하지 않는다', () => {
// 누적 검토 #14: 사용자(게스트) 측은 운영자 측과 동일한 info.* 키를 그대로 노출하던 회귀.
// 운영자 측 info 는 분류 결정에 필요한 기술/법규 상세 (자동 차단 대상, GDPR Art.6(1)(a) 등)
// 가 포함되어 사용자 입장에서는 과도/난해.
expect(text).not.toContain('cookie_categories.info.necessary.scope');
expect(text).not.toContain('cookie_categories.info.necessary.tools');
expect(text).not.toContain('cookie_categories.info.necessary.legal_basis');
expect(text).not.toContain('cookie_categories.info.analytics.scope');
expect(text).not.toContain('cookie_categories.info.analytics.tools');
expect(text).not.toContain('cookie_categories.info.analytics.legal_basis');
expect(text).not.toContain('cookie_categories.info.marketing.scope');
expect(text).not.toContain('cookie_categories.info.marketing.tools');
expect(text).not.toContain('cookie_categories.info.marketing.legal_basis');
});
it('"자세히 보기" 토글 + 펼침 영역이 통째로 제거되었다 (카테고리 description 한 줄로 일원화)', () => {
// PO 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과
// 의미상 중복 → "자세히 보기" 토글 자체를 없애고, 카테고리 description 을 사용자 친화
// 표현으로 갈아끼움 (시더 plugin.php / CookieCategoryService 의 기본값 갱신).
expect(text).not.toContain('banner.category_details_expand');
expect(text).not.toContain('banner.category_details_collapse');
expect(text).not.toContain('gdprBannerExpandedCategoryIdx');
expect(text).not.toContain('"chevron-down"');
expect(text).not.toContain('"chevron-up"');
});
it('사용자 측에서 user_info.* 키 참조가 모두 제거되었다 (description 시더로 이전됨)', () => {
// 펼침 영역과 함께 user_info.* 키 참조도 통째로 제거. 사용자 친화 description 은
// 카테고리 데이터의 description 필드에 직접 들어감 (시더 디폴트).
expect(fullText).not.toContain('cookie_categories.user_info');
});
it('legal_basis / scope / tools / description 라벨 키 참조 모두 제거', () => {
// 회귀 가드: 일반 사용자에게 "GDPR Art.6(1)(a)" 등 법조항 / 운영자용 분류 라벨 노출 금지.
expect(fullText).not.toContain('banner.category_legal_basis_label');
expect(fullText).not.toContain('banner.category_scope_label');
expect(fullText).not.toContain('banner.category_tools_label');
expect(fullText).not.toContain('banner.category_description_label');
expect(fullText).not.toContain('banner.category_examples_label');
// 펼침 영역에 사용되던 아이콘들도 제거
expect(fullText).not.toContain('"scale-balanced"');
expect(fullText).not.toContain('"shield-halved"');
expect(fullText).not.toContain('"screwdriver-wrench"');
expect(fullText).not.toContain('"list-ul"');
expect(fullText).not.toContain('"circle-info"');
});
});
describe('카테고리 카드 — 임의값 회귀 가드 (#14 글씨 키우기)', () => {
// PO 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아
// 가독성 저하. 임의값 text-[10px] 11건을 모두 표준 text-xs 로 통일.
it('cookie_banner.json 전체에서 text-[10px] 임의값이 사용되지 않는다', () => {
const fullText = serializeForSearch(extension);
expect(fullText).not.toMatch(/text-\[10px\]/);
});
});
describe('운영 주체 + 저장 위치 푸터 라인 (EDPB §64 + GDPR Art.13(1)(f))', () => {
const banner = findById(root, 'gdpr_cookie_banner');
const footer = findById(banner ?? null, 'gdpr_banner_operator_footer');
it('gdpr_banner_operator_footer 가 배너 내부에 존재한다', () => {
// EDPB Guidelines 05/2020 §64: controller identity 가 동의 1차 화면에서 즉시 식별 가능해야
// informed consent 의 유효성을 입증할 수 있다. 처리방침 링크 뒤로 숨기는 구조는 불충분.
expect(footer).toBeTruthy();
});
it('둘 다 비면 푸터 라인 전체 숨김 — 어느 한 값만 있어도 노출', () => {
expect(footer?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}'
);
});
it('3-case 분기: 둘 다 / 운영자만 / 저장위치만 — 동시 노출 회피를 위해 각 케이스 if 가 상호 배타적', () => {
// 한국어 조사 분기 + 자연어 한 줄 표현을 위해 ko/en lang 에 3종 키를 두고 if 가드로 분기.
const children = (footer?.children ?? []) as AnyNode[];
const both = children.find((c) =>
String(c.text ?? '').includes('operator_and_storage_line')
);
const operatorOnly = children.find((c) =>
String(c.text ?? '').includes('operator_only_line')
);
const storageOnly = children.find((c) =>
String(c.text ?? '').includes('storage_only_line')
);
// 케이스 1: 둘 다 (entity && location)
expect(both?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}'
);
// 케이스 2: 운영자만 (entity && !location)
expect(operatorOnly?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name && !gdprPublicSettings?.data?.data_storage_location}}'
);
// 케이스 3: 저장위치만 (!entity && location)
expect(storageOnly?.if).toBe(
'{{!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}'
);
});
it('각 케이스의 다국어 키 + 토큰 보간 사용 (entity/location 자연어 분기)', () => {
const text = serializeForSearch(footer);
expect(text).toContain('sirsoft-gdpr.banner.operator_and_storage_line|entity=');
expect(text).toContain('|location=');
expect(text).toContain('sirsoft-gdpr.banner.operator_only_line|entity=');
expect(text).toContain('sirsoft-gdpr.banner.storage_only_line|location=');
});
it('푸터 라인은 시각적 무게 최소화 — 작은 보조 텍스트 시맨틱 + 상단 구분선', () => {
// 동의 행위 자체에 시선이 가도록 운영 주체는 부드러운 보조 정보로 처리.
// 작은 회색 텍스트 톤은 표준 시맨틱(.text-tertiary)으로 통일 (#399).
const className = (footer?.props as { className?: string })?.className ?? '';
expect(className).toContain('text-tertiary');
expect(className).toContain('border-t');
});
});
describe('규정 준수', () => {
it('핸들러는 G7 내장 핸들러 또는 sirsoft-gdpr.* 플러그인 등록 핸들러만 사용', () => {
// G7 빌트인 핸들러 + 플러그인이 ActionDispatcher 에 등록한 sirsoft-gdpr.* 네임스페이스 핸들러.
// module-assets.md 의 핸들러 네이밍 규칙: {plugin-identifier}.{handler-name}
const allowedBuiltins = [
'apiCall', 'setState', 'sequence', 'toast', 'refetchDataSource',
];
for (const h of collectHandlers(extension)) {
if (h.startsWith('sirsoft-gdpr.')) continue;
expect(allowedBuiltins).toContain(h);
}
});
it('i18n 키 prefix 검증', () => {
for (const k of collectGdprI18nKeys(extension)) {
expect(k).toMatch(/^\$t:sirsoft-gdpr\./);
}
});
});
});
@@ -0,0 +1,267 @@
/**
* 동의 이력 관리자 레이아웃 (gdpr_consent_log)
*
* #27 회귀 가드 — 체크박스 필터의 옵션 C 하이브리드 동작:
* 1. 체크박스 토글 시 _local.filter 즉시 갱신 (UI 체크 상태 반영)
* 2. 같은 핸들러에서 searchConsentLogs 를 debounce 300 으로 실행 (URL/API 재호출)
* 3. 텍스트 입력 (이메일/세션 ID) 은 검색 버튼/Enter 로만 적용 — 즉시 미적용
*
* 액션 정의 SSoT: named_actions.searchConsentLogs (navigate handler)
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/gdpr_consent_log.json';
import type { AnyNode } from './helpers';
import { findById } from './helpers';
describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 + debounce', () => {
const root = layout as unknown as AnyNode;
type ActionDef = {
type?: string;
event?: string;
handler?: string;
actionRef?: string;
debounce?: number | { delay: number };
params?: Record<string, unknown>;
key?: string;
};
/**
* 체크박스 Input 노드의 actions 배열을 안전하게 추출.
*/
function actionsOf(node: AnyNode | null): ActionDef[] {
return ((node?.actions ?? []) as unknown) as ActionDef[];
}
describe('named_actions.searchConsentLogs (SSoT)', () => {
it('searchConsentLogs 가 navigate 핸들러로 정의되어 있다', () => {
const named = (root as { named_actions?: Record<string, AnyNode> }).named_actions ?? {};
expect(named.searchConsentLogs).toBeTruthy();
expect((named.searchConsentLogs as { handler?: string }).handler).toBe('navigate');
});
it('searchConsentLogs 가 _local.filter 의 모든 키 (search/consentKeys/actions/sources) 를 query 로 전달한다', () => {
const named = (root as { named_actions?: Record<string, { params?: Record<string, unknown> }> }).named_actions ?? {};
const query = (named.searchConsentLogs?.params as { query?: Record<string, string> } | undefined)?.query ?? {};
// 텍스트 검색은 searchType 분기로 전달
expect(query.email).toMatch(/_local\.filter\.search/);
expect(query.session_id).toMatch(/_local\.filter\.search/);
// 체크박스 필터는 배열로 전달
expect(query.consent_keys).toMatch(/_local\.filter\.consentKeys/);
expect(query.actions).toMatch(/_local\.filter\.actions/);
expect(query.sources).toMatch(/_local\.filter\.sources/);
});
});
describe('체크박스 onChange 액션 패턴 (sequence: setState → searchConsentLogs)', () => {
/**
* 한 체크박스의 actions 가 sequence 패턴을 따르는지 검증.
*
* sequence 패턴 (#27 옵션 A 최종 형태):
* - actions[0]: handler=sequence, type=change
* - actions[0].params.actions[0]: setState (filter.{key} 갱신)
* - actions[0].params.actions[1]: actionRef=searchConsentLogs
*
* 왜 sequence 인가:
* actions 배열을 그냥 [setState, navigate] 로 두면 두 액션이 *같은 dataContext closure* 를
* 공유. React 의 setState 는 비동기 스케줄링이라 actions[1] 시점의 dataContext 는 *클릭 직전*
* stale 값 → URL query 가 한 박자 늦은 값 (첫 클릭은 빈 배열로 누락) 으로 회귀.
*
* sequence 핸들러는 ActionDispatcher.handleSequence 가 각 액션 사이에 `currentState` 를
* 추적하고 sequenceContext.data._local 을 최신 상태로 갱신하므로 actions[1] (navigate) 가
* setState 직후의 새 `_local.filter.consentKeys` 를 보고 query 평가 — stale closure 해결.
*/
function expectSequencePattern(checkbox: AnyNode | null, debugLabel: string): void {
expect(checkbox, `${debugLabel}: 체크박스 노드 존재`).toBeTruthy();
const actions = actionsOf(checkbox);
expect(actions.length, `${debugLabel}: 외곽 actions 가 1개 (sequence wrapper)`).toBe(1);
const outer = actions[0];
expect(outer.handler, `${debugLabel}: outer handler=sequence`).toBe('sequence');
expect(outer.type, `${debugLabel}: outer type=change`).toBe('change');
const innerActions = ((outer.params as { actions?: ActionDef[] } | undefined)?.actions ?? []) as ActionDef[];
expect(innerActions.length, `${debugLabel}: sequence.actions 가 2개`).toBe(2);
// 첫 번째 inner: setState
expect(innerActions[0].handler, `${debugLabel}: inner[0]=setState`).toBe('setState');
// 두 번째 inner: searchConsentLogs actionRef
expect(innerActions[1].actionRef, `${debugLabel}: inner[1]=searchConsentLogs actionRef`).toBe('searchConsentLogs');
}
/**
* filter 카테고리 (consent_keys/action/source) 안의 모든 체크박스 노드를 수집.
* 각 필터 row 는 Label > Input 구조이며, Input.props.type === 'checkbox' 이다.
*/
function findCheckboxesIn(filterRowId: string): AnyNode[] {
const filterRow = findById(root, filterRowId);
if (!filterRow) return [];
const out: AnyNode[] = [];
const stack: AnyNode[] = [filterRow];
while (stack.length) {
const cur = stack.pop()!;
const props = (cur.props ?? {}) as { type?: string };
if (cur.name === 'Input' && props.type === 'checkbox') {
out.push(cur);
}
if (Array.isArray(cur.children)) stack.push(...cur.children);
}
return out;
}
it('consent_keys 필터의 체크박스 5개 모두 옵션 C 패턴', () => {
// Phase 1 ICO/CNIL 4분류 체계 도입으로 functional 카테고리 추가됨 →
// 「전체」 + necessary + functional + analytics + marketing = 5개.
const checkboxes = findCheckboxesIn('consent_key_filter');
expect(checkboxes.length, '체크박스 5개 (전체 / necessary / functional / analytics / marketing)').toBe(5);
checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `consent_keys[${idx}]`));
});
it('action 필터의 체크박스 3개 모두 옵션 C 패턴', () => {
const checkboxes = findCheckboxesIn('action_filter');
expect(checkboxes.length, '체크박스 3개 (전체 / granted / revoked)').toBe(3);
checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `action[${idx}]`));
});
it('source 필터의 체크박스 4개 모두 옵션 C 패턴', () => {
const checkboxes = findCheckboxesIn('source_filter');
expect(checkboxes.length, '체크박스 4개 (전체 / banner / preference_center / mypage)').toBe(4);
checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `source[${idx}]`));
});
});
describe('텍스트 입력 (이메일/세션 ID) — 검색 버튼 의존 유지', () => {
it('search 입력 필드는 onChange 에 debounce 또는 searchConsentLogs 자동 호출이 없다 (검색 버튼/Enter 만)', () => {
// 회귀 가드: 텍스트 입력에 debounce 적용 시 사용자가 입력하는 중간마다 API 호출 발생 → 의도된 UX 위반
const stack: AnyNode[] = [root];
const matchedTextInputs: AnyNode[] = [];
while (stack.length) {
const cur = stack.pop()!;
const props = (cur.props ?? {}) as { type?: string; placeholder?: string; name?: string };
// text 입력 = type=text 이거나 type 미지정 + name=search
if (cur.name === 'Input' && (props.type === 'text' || (!props.type && props.name === 'search'))) {
matchedTextInputs.push(cur);
}
if (Array.isArray(cur.children)) stack.push(...cur.children);
}
// 텍스트 입력이 0~다수 — 각각 onChange 액션이 setState 만 하는지 검증
matchedTextInputs.forEach((input) => {
const actions = actionsOf(input);
actions.forEach((a) => {
expect(
a.actionRef,
`텍스트 입력의 onChange 액션은 searchConsentLogs 를 직접 호출하지 않는다 (검색 버튼/Enter 만)`
).not.toBe('searchConsentLogs');
});
});
});
});
describe('subject 컬럼 — 회원 row 클릭 시 회원 상세 페이지 이동 (검토 #26)', () => {
/**
* 회귀 가드: subject 컬럼의 회원 row (row.user_id 존재) 는 클릭 시
* /admin/users/{{row.user.uuid}} 로 이동해야 함. DPO 가 이상 패턴 발견 시
* 즉시 회원 상세 조회 가능하도록 일관성 확보.
* 게스트 row 는 본 작업에서 처리하지 않음 (Span 그대로 — 후속 이슈).
*/
const layoutJson = JSON.stringify(root);
it('회원 row 가 Button + navigate 액션으로 회원 상세 페이지 경로를 가진다', () => {
// subject 컬럼의 회원 row 식별 — if 조건이 !!row.user_id
expect(layoutJson).toContain('"if":"{{!!row.user_id}}"');
// navigate 경로 — /admin/users/{{row.user.uuid}}
expect(layoutJson).toContain('/admin/users/{{row.user.uuid}}');
// click 타입 + navigate 핸들러
expect(layoutJson).toMatch(/"type":"click"[^}]*"handler":"navigate"/);
});
it('게스트 row 는 클릭 액션이 없다 (Span 유지 — 본 작업 범위 외)', () => {
// 게스트 row 식별 — if 조건이 !row.user_id
expect(layoutJson).toContain('"if":"{{!row.user_id}}"');
// 게스트 row 가 actions: [{ ... navigate ... }] 형태를 갖지 않아야 함.
// 회원 상세 이동 (#26) 외에 다른 navigate (검색/필터/마이페이지 진입 등) 는 별개 기능이므로
// 게스트 row 의 *주체 셀 안에* navigate 가 있는지만 강하게 검증.
// 주체 셀의 게스트 분기는 if=!row.user_id 인 Span — actions 키 미존재 필수.
expect(layoutJson).not.toMatch(/"if":"\{\{!row\.user_id\}\}"[^}]*"actions"/);
});
});
/**
* 작업 2 (B-3 옵션 X) — policy_version 컬럼이 평문 Span + 본문 보기 Button 분리.
* PO 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경.
*/
describe('작업 2: policy_version 컬럼 → 평문 + 본문 보기 Button', () => {
const layoutJson = JSON.stringify(layout);
it('gdprPolicyVersionSnapshot data_source 가 등록되어 있다', () => {
expect(layoutJson).toContain('"id":"gdprPolicyVersionSnapshot"');
expect(layoutJson).toContain('/api/plugins/sirsoft-gdpr/admin/policy-versions/{{_global.viewingPolicyVersion}}');
});
it('policy_version_snapshot_modal partial 이 modals 배열에 등록되어 있다', () => {
expect(layoutJson).toContain('partials/_shared/_policy_version_snapshot_modal.json');
});
it('policy_version 컬럼이 평문 Span (값 또는 "-") 으로 항상 표시된다', () => {
// row.policy_version ?? "-" — Span 텍스트
expect(layoutJson).toContain('"text":"{{row.policy_version ?? \'-\'}}"');
});
it('row.policy_version 존재 시 본문 보기 Button 이 추가 노출된다 (sequence)', () => {
// Button if=row.policy_version + setState + refetch + openModal sequence
expect(layoutJson).toContain('"if":"{{row.policy_version}}"');
expect(layoutJson).toContain('"viewingPolicyVersion":"{{row.policy_version}}"');
expect(layoutJson).toContain('"dataSourceId":"gdprPolicyVersionSnapshot"');
expect(layoutJson).toContain('"target":"policy_version_snapshot_modal"');
expect(layoutJson).toContain('snapshot_view_short');
});
});
describe('#25: 카테고리 스냅샷 — raw JSON → 표 형식 + Pre 컴포넌트 의존성 제거', () => {
const layoutJson = JSON.stringify(layout);
it('Pre 컴포넌트와 JSON.stringify 가 레이아웃에서 완전히 제거되었다', () => {
// Pre 는 admin 템플릿 활성 번들에 미노출되어 "조용히 사라짐" 회귀를 유발하던 컴포넌트
// feedback_layout_component_registration_check.md 회귀 가드
expect(layoutJson).not.toContain('"name":"Pre"');
expect(layoutJson).not.toContain('JSON.stringify');
});
it('row.categories_snapshot 배열을 iteration 으로 순회한다 (객체 직접 순회 미사용)', () => {
// 백엔드 Resource 가 categories 객체를 [{key, label_key, granted}] 배열로 변환
expect(layoutJson).toContain('"source":"row.categories_snapshot"');
expect(layoutJson).toContain('"item_var":"snapshot_item"');
});
it('iteration 컨테이너가 빈 스냅샷 가드를 가진다', () => {
// categories_snapshot 이 null 또는 빈 배열이면 영역 자체 미노출
expect(layoutJson).toContain('{{(row.categories_snapshot?.length ?? 0) > 0}}');
});
it('각 행이 동의/거부 두 가지 배지를 분기 노출한다', () => {
expect(layoutJson).toContain('"if":"{{snapshot_item.granted}}"');
expect(layoutJson).toContain('"if":"{{!snapshot_item.granted}}"');
expect(layoutJson).toContain('$t:sirsoft-gdpr.admin.consent_log.detail.snapshot_granted');
expect(layoutJson).toContain('$t:sirsoft-gdpr.admin.consent_log.detail.snapshot_revoked');
});
it('카테고리 라벨은 snapshot_item.label_key 의 $t 해석 + key fallback 패턴이다', () => {
expect(layoutJson).toContain('{{$t(snapshot_item.label_key) ?? snapshot_item.key}}');
});
it('카테고리 스냅샷 영역에 운영자 안내(hint) 한 줄이 노출된다', () => {
expect(layoutJson).toContain('$t:sirsoft-gdpr.admin.consent_log.detail.categories_hint');
});
it('동의/거부 배지가 light/dark 색 쌍을 갖는다 (다크모드 회귀 가드)', () => {
// 동의 = green 불투명 (plugin_settings.json 활성 배지와 동일 패턴 — /40 alpha 는 너무 밝아 사용 금지)
// 동의 배지 클래스 자리는 "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" 형태로 등장
expect(layoutJson).toContain('bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300');
expect(layoutJson).not.toContain('dark:bg-green-900/40');
});
});
});
@@ -0,0 +1,175 @@
/**
* GDPR 플러그인 레이아웃 테스트 공통 헬퍼
*
* JSON 트리에서 ID/이름 검색 및 핸들러 추출을 위한 유틸리티.
*/
export type AnyNode = Record<string, unknown> & {
id?: string;
name?: string;
type?: string;
children?: AnyNode[];
slots?: Record<string, AnyNode[]>;
modals?: Record<string, AnyNode> | AnyNode[];
actions?: AnyNode[];
iteration?: { source?: string; item_var?: string; index_var?: string };
if?: string;
props?: Record<string, unknown>;
text?: string;
};
/**
* 트리 노드에서 특정 id를 재귀 탐색
*
* 일반 레이아웃 구조(children/slots/modals)와 Layout Extension 구조(injections[].components[])
* 양쪽을 모두 탐색합니다.
*/
export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null {
if (!node) return null;
if (node.id === id) return node;
if (Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child, id);
if (found) return found;
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
const found = findById(m as AnyNode, id);
if (found) return found;
}
}
// Layout Extension (Overlay): injections[].components[]
const injections = (node as { injections?: AnyNode[] }).injections;
if (Array.isArray(injections)) {
for (const inj of injections) {
const components = (inj as { components?: AnyNode[] }).components;
if (Array.isArray(components)) {
for (const c of components) {
const found = findById(c, id);
if (found) return found;
}
}
}
}
// Layout Extension (Extension Point): root.components[]
if (Array.isArray((node as { components?: AnyNode[] }).components)) {
for (const c of (node as { components: AnyNode[] }).components) {
const found = findById(c, id);
if (found) return found;
}
}
return null;
}
/**
* 트리 노드에서 특정 name 컴포넌트를 모두 수집
*
* 일반 레이아웃 구조와 Layout Extension 구조 양쪽을 모두 탐색합니다.
*/
export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] {
const results: AnyNode[] = [];
if (!node) return results;
if (node.name === name) results.push(node);
if (Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findAllByName(child, name));
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findAllByName(child, name));
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
results.push(...findAllByName(m as AnyNode, name));
}
}
// Layout Extension (Overlay): injections[].components[]
const injections = (node as { injections?: AnyNode[] }).injections;
if (Array.isArray(injections)) {
for (const inj of injections) {
const components = (inj as { components?: AnyNode[] }).components;
if (Array.isArray(components)) {
for (const c of components) {
results.push(...findAllByName(c, name));
}
}
}
}
// Layout Extension (Extension Point): root.components[]
if (Array.isArray((node as { components?: AnyNode[] }).components)) {
for (const c of (node as { components: AnyNode[] }).components) {
results.push(...findAllByName(c, name));
}
}
return results;
}
/**
* 텍스트 검색용 직렬화 — 원본 JSON 파일(4-space indent) 형식과 동일하게 출력하여
* `text.toContain('"target": "local"')` 같은 공백 포함 패턴이 그대로 매칭되도록 함.
*/
export function serializeForSearch(json: unknown): string {
return JSON.stringify(json, null, 4);
}
/**
* JSON 문자열에서 사용된 핸들러 이름 모두 수집
*/
export function collectHandlers(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? [];
const names = matches
.map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '')
.filter(Boolean);
return Array.from(new Set(names));
}
/**
* JSON 문자열에서 사용된 i18n 키($t:sirsoft-gdpr.*) 모두 수집
*/
export function collectGdprI18nKeys(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/\$t:sirsoft-gdpr\.[a-zA-Z0-9_.]+/g) ?? [];
return Array.from(new Set(matches));
}
/**
* JSON 문자열에서 사용된 컴포넌트 name(대문자 시작) 수집
*/
export function collectComponentNames(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/"name":\s*"([A-Z][a-zA-Z0-9_]*)"/g) ?? [];
const names = matches
.map((m) => m.match(/"name":\s*"([A-Z][a-zA-Z0-9_]*)"/)?.[1] ?? '')
.filter(Boolean);
return Array.from(new Set(names));
}
@@ -0,0 +1,402 @@
/**
* extensions/mypage_privacy_tab.json 구조 검증
*
* 마이페이지 프로필 페이지에 주입되는 GDPR 동의 관리 카드 (F-04).
* 동의 매트릭스 + 철회 모달만 검증.
*/
import { describe, it, expect } from 'vitest';
import extension from '../../../extensions/mypage_privacy_tab.json';
import { findById, type AnyNode, serializeForSearch } from './helpers';
interface Modal extends AnyNode {
id?: string;
}
interface Extension {
target_layout?: string;
priority?: number;
data_sources?: AnyNode[];
modals?: Modal[];
injections?: Array<{
target_id?: string;
position?: string;
components?: AnyNode[];
}>;
}
describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매트릭스 카드', () => {
const root = extension as unknown as Extension;
const card = (root.injections?.[0]?.components ?? []).find(
(c) => (c as { id?: string }).id === 'gdpr_mypage_privacy_card'
) as AnyNode | undefined;
describe('Extension 메타데이터', () => {
it('target_layout 이 mypage/profile 이다', () => {
expect(root.target_layout).toBe('mypage/profile');
});
it('priority 는 90 (sirsoft-marketing 의 100 보다 앞서 처리되어 마케팅 카드 뒤에 배치)', () => {
expect(root.priority).toBe(90);
});
});
describe('데이터 소스', () => {
it('데이터소스 3종 포함 (gdprMyConsents / gdprMeConsents / gdprPublicSettings)', () => {
// gdprPublicSettings 는 GDPR Art.13(1)(a)(f) 의무 고지를 카드 헤더 메타 라인으로 보강하기 위함.
const ids = (root.data_sources ?? []).map((d) => d.id);
expect(ids).toContain('gdprMyConsents');
expect(ids).toContain('gdprMeConsents');
expect(ids).toContain('gdprPublicSettings');
});
it('gdprPublicSettings 는 게스트 응답이 가능해야 한다 (auth_required:false)', () => {
// 마이페이지 카드 자체는 회원 한정이지만, 데이터소스 자체는 공개 API. /api/plugins/sirsoft-gdpr/settings.
const ds = (root.data_sources ?? []).find((d) => d.id === 'gdprPublicSettings');
expect((ds as { auth_required?: boolean } | undefined)?.auth_required).toBe(false);
expect((ds as { endpoint?: string } | undefined)?.endpoint).toBe('/api/plugins/sirsoft-gdpr/settings');
});
it('제거된 gdprMyRequests 데이터소스는 존재하지 않는다', () => {
const ids = (root.data_sources ?? []).map((d) => d.id);
expect(ids).not.toContain('gdprMyRequests');
});
it('마이페이지 카드 노출 토글 제거 — gdprPublicSettingsForMypage 데이터소스 미사용 (GDPR Art.7(3) 대칭성 의무: 카드는 플러그인 활성 시 항상 노출)', () => {
const ids = (root.data_sources ?? []).map((d) => d.id);
expect(ids).not.toContain('gdprPublicSettingsForMypage');
});
});
describe('운영 주체·저장 위치 메타 라인 (GDPR Art.13(1)(a)(f))', () => {
const meta = findById(card ?? null, 'gdpr_card_operator_meta');
it('gdpr_card_operator_meta 메타 라인이 카드 안에 존재한다', () => {
// GDPR Art.13(1)(a) 컨트롤러 신원 + Art.13(1)(f) 저장 위치 의무 고지를 마이페이지에서도
// 접근 가능하도록 보강. EDPB 권고: 동의 1차 화면 외에도 사용자가 자기 정보의 행방을
// 언제든 재확인할 수 있어야 함.
expect(meta).toBeTruthy();
});
it('카드 헤더(타이틀 + 구분선) 다음 형제 위치에 배치 (헤더 내부 X)', () => {
// PO UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출.
// 헤더 내부(border-b 위)에 두면 타이틀과 같은 영역으로 보여 시각적 혼잡 → 헤더 외부로 분리.
const header = findById(card ?? null, 'gdpr_card_header');
const headerChildren = (header?.children ?? []) as AnyNode[];
const metaInsideHeader = headerChildren.find((c) => c.id === 'gdpr_card_operator_meta');
expect(metaInsideHeader).toBeUndefined();
// card.children 에서 헤더 다음 형제로 존재해야 한다.
const cardChildren = (card?.children ?? []) as AnyNode[];
const headerIdx = cardChildren.findIndex((c) => c.id === 'gdpr_card_header');
const metaIdx = cardChildren.findIndex((c) => c.id === 'gdpr_card_operator_meta');
expect(headerIdx).toBeGreaterThanOrEqual(0);
expect(metaIdx).toBe(headerIdx + 1);
});
it('두 값 중 하나라도 있으면 노출, 둘 다 비면 블록 전체 숨김', () => {
expect(meta?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}'
);
});
it('3-case 자연어 분기 — 한국어 조사 회피를 위해 둘 다 / 운영자만 / 저장위치만 각각 별도 P 태그', () => {
const children = (meta?.children ?? []) as AnyNode[];
const both = children.find((c) =>
String(c.text ?? '').includes('operator_and_storage_meta')
);
const operatorOnly = children.find((c) =>
String(c.text ?? '').includes('operator_only_meta')
);
const storageOnly = children.find((c) =>
String(c.text ?? '').includes('storage_only_meta')
);
expect(both?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}'
);
expect(operatorOnly?.if).toBe(
'{{!!gdprPublicSettings?.data?.legal_entity_name && !gdprPublicSettings?.data?.data_storage_location}}'
);
expect(storageOnly?.if).toBe(
'{{!gdprPublicSettings?.data?.legal_entity_name && !!gdprPublicSettings?.data?.data_storage_location}}'
);
// 자연어 한 줄은 블록 단위 P
expect(both?.name).toBe('P');
expect(operatorOnly?.name).toBe('P');
expect(storageOnly?.name).toBe('P');
});
it('가운데 점(·) 구분자 미사용 (자연어 한 줄 패턴이라 구분자 불필요)', () => {
// 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, PO 결정으로 자연어 한 줄로 재설계.
const children = (meta?.children ?? []) as AnyNode[];
const separator = children.find((c) => String(c.text ?? '') === '·');
expect(separator).toBeUndefined();
});
it('다국어 키 + 토큰 보간 사용 (operator_and_storage / operator_only / storage_only)', () => {
const text = serializeForSearch(meta);
expect(text).toContain('sirsoft-gdpr.mypage.privacy.operator_and_storage_meta|entity=');
expect(text).toContain('|location=');
expect(text).toContain('sirsoft-gdpr.mypage.privacy.operator_only_meta|entity=');
expect(text).toContain('sirsoft-gdpr.mypage.privacy.storage_only_meta|location=');
});
it('시각적 무게 최소화 — 작은 보조 텍스트 시맨틱', () => {
// 작은 회색 텍스트 톤은 표준 시맨틱(.text-tertiary)으로 통일 (#399).
const className = (meta?.props as { className?: string })?.className ?? '';
expect(className).toContain('text-tertiary');
});
});
describe('모달', () => {
it('revoke_confirm_modal 이 modals 배열에 포함', () => {
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
expect(modal).toBeTruthy();
expect(modal?.name).toBe('Modal');
});
it('제거된 데이터 요청·자가 취소 모달은 존재하지 않는다', () => {
const ids = (root.modals ?? []).map((m) => m.id);
expect(ids).not.toContain('gdpr_data_request_modal');
expect(ids).not.toContain('gdpr_cancel_confirm_modal');
});
});
describe('주입 위치 (Injection)', () => {
it('profile_view_bio_card 에 append 로 주입', () => {
const inj = root.injections?.[0];
expect(inj?.target_id).toBe('profile_view_bio_card');
expect(inj?.position).toBe('append');
});
it('주입 컴포넌트의 첫 컴포넌트가 gdpr_mypage_privacy_card', () => {
expect(card).toBeTruthy();
});
});
describe('카드 표시 조건', () => {
it('데이터 기반 가드 — 회원에게 동의 데이터가 있을 때만 노출. Art.7(3) 보장 (기존 동의자 철회권) + UX 노이즈 제거 (빈 카드 미노출)', () => {
const guard = (card as { if?: string }).if;
expect(guard).toBe(
'{{(gdprMyConsents?.data?.length ?? 0) > 0 || (gdprMeConsents?.data?.consents?.length ?? 0) > 0}}'
);
});
});
describe('내 동의 현황 섹션 (gdpr_section_consents)', () => {
const section = card ? findById(card, 'gdpr_section_consents') : null;
it('섹션이 존재한다', () => {
expect(section).toBeTruthy();
});
it('제거된 「내 요청 내역」 섹션 (gdpr_section_my_requests) 미존재', () => {
expect(card ? findById(card, 'gdpr_section_my_requests') : null).toBeNull();
});
it('철회 버튼 클릭 시 _global 에 키+라벨+설명 세팅 후 revoke_confirm_modal 오픈', () => {
const text = serializeForSearch(section);
expect(text).toContain('"target": "revoke_confirm_modal"');
expect(text).toContain('"revokeTargetKey": "{{consent?.consent_key}}"');
// raw key (cookie_functional 등) 대신 사용자 친화 description 을 모달에 표시하기 위해
// 행 핸들러에서 consent_description 을 _global 에 세팅한다.
expect(text).toContain('"revokeTargetDescription": "{{consent?.consent_description ?? \'\'}}"');
});
it('동의 항목 컬럼이 라벨 우선 표시 (consent_label fallback consent_key)', () => {
const text = serializeForSearch(section);
expect(text).toContain('consent?.consent_label ?? consent?.consent_key');
});
it('동의 항목 컬럼에 카탈로그 description 1줄 노출 (회원이 영문 식별자 대신 카테고리 의미를 즉시 인지)', () => {
const text = serializeForSearch(section);
// GdprUserConsentResource 가 consent_description 필드 노출 + layout 이 그 값을 보조 Span 으로 표시.
expect(text).toContain('consent?.consent_description');
// raw key 보조 표시 (`font-mono`) 는 회원 화면 노이즈로 제거됨 — description 으로 교체된 회귀 가드.
expect(text).not.toContain('font-mono break-all');
});
});
describe('동의 철회 모달 (revoke_confirm_modal)', () => {
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
it('모달 본문에 raw key (cookie_functional 등) 대신 사용자 친화 description 노출', () => {
// PO 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거.
// 라벨 + description 2줄 구조로 변경됨.
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
const text = serializeForSearch(modal);
// description 바인딩이 모달 본문에 존재
expect(text).toContain('_global.revokeTargetDescription');
// 옛 raw key 표시 (font-mono break-all 클래스) 가 회귀하지 않음
expect(text).not.toContain('font-mono break-all');
});
it('확인 버튼 → POST /consent/revoke + onSuccess 에 refetch + closeModal + toast', () => {
const text = serializeForSearch(modal);
expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/revoke"');
expect(text).toContain('"method": "POST"');
expect(text).toContain('"dataSourceId": "gdprMeConsents"');
expect(text).toContain('"handler": "closeModal"');
});
it('철회 대상 + 확인 버튼 disabled 조건 모두 _global 참조 (모달 컨텍스트 분리)', () => {
const text = serializeForSearch(modal);
expect(text).toContain('_global.revokeTargetKey');
});
it('철회 onSuccess 에서 sirsoft-gdpr.syncConsent 핸들러를 호출한다', () => {
// 회귀 가드: 마이페이지 철회 후 functional 카테고리 cleanup 이 즉시 동작하려면
// syncConsent 핸들러가 호출되어야 함 (storage/cookie interceptor config 갱신 +
// functionalCleaner 호출). 누락 시 새로고침해야 cleanup 됨 (EDPB §117 즉시성 약화).
// 배너 ([cookie_banner.test.tsx:261] — 3개 동의 버튼) 와 동일 패턴.
const text = serializeForSearch(modal);
expect(text).toContain('"handler": "sirsoft-gdpr.syncConsent"');
});
it('확인 버튼에 isRevoking 동안 spinner 아이콘 + "철회 중" 라벨 노출', () => {
// 회귀 가드: 사용자가 클릭 후 "클릭이 먹혔는지" 의심하지 않도록 시각 피드백 제공.
// admin 저장 버튼과 동일 패턴 (Icon spinner spin=true + 텍스트 분기).
const text = serializeForSearch(modal);
expect(text).toContain('"name": "spinner"');
expect(text).toContain('"spin": true');
expect(text).toContain('sirsoft-gdpr.consent.revoking');
});
});
describe('동의 현황 표 컬럼 구성', () => {
const section = card ? findById(card, 'gdpr_section_consents') : null;
it('표 헤더에 col_consent_key / col_consented / col_consented_at / col_action 컬럼이 있다', () => {
const text = serializeForSearch(section);
expect(text).toContain('mypage.privacy.col_consent_key');
expect(text).toContain('mypage.privacy.col_consented');
expect(text).toContain('mypage.privacy.col_consented_at');
expect(text).toContain('mypage.privacy.col_action');
});
});
describe('동의 매트릭스 액션 4분기 (Art.7(3) 대칭성 + ePrivacy Art.5(3))', () => {
const section = card ? findById(card, 'gdpr_section_consents') : null;
it('필수 카테고리 행 — 「필수」 라벨 노출 (철회/재동의 둘 다 무의미)', () => {
const text = serializeForSearch(section);
expect(text).toContain('consent?.is_required === true');
expect(text).toContain('mypage.privacy.required_label');
});
it('선택형 활성 행 — can_revoke=true 일 때 「철회」 빨간 버튼', () => {
const text = serializeForSearch(section);
expect(text).toContain('consent?.can_revoke === true');
expect(text).toContain('bg-red-600');
});
it('선택형 비활성 행 — can_grant=true 일 때 「동의」/「다시 동의」 파란 버튼', () => {
const text = serializeForSearch(section);
expect(text).toContain('consent?.can_grant === true');
expect(text).toContain('mypage.privacy.grant_again');
expect(text).toContain('mypage.privacy.grant');
});
it('동의 부여 버튼 클릭 시 POST /consent/grant + onSuccess 에 refetch + 토스트', () => {
const text = serializeForSearch(section);
expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/grant"');
expect(text).toContain('"dataSourceId": "gdprMeConsents"');
});
it('grant onSuccess 에서 sirsoft-gdpr.syncConsent 핸들러를 호출한다', () => {
// 회귀 가드: 다시 동의 시점에 functional 카테고리가 grant 되면 interceptor 의
// functionalConsented 플래그를 즉시 true 로 갱신해야 새 storage/cookie 쓰기가
// 통과됨. 누락 시 새로고침 전까지 차단 유지 (동의 즉시성 약화).
const text = serializeForSearch(section);
// 액션 컬럼 안의 grant 버튼이 syncConsent 호출하는지 검증.
// 섹션에는 revoke 버튼도 있어 toContain 중복 일치 가능 — match 회수로 정확히 검증.
// 액션 컬럼 sequence 가 모달을 열도록 setState 만 한 revoke 버튼과 달리,
// grant 버튼은 직접 apiCall 하므로 onSuccess 가 명시되어 있음.
const matches = text.match(/"handler":\s*"sirsoft-gdpr\.syncConsent"/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('행별 grant 버튼에 grantingKey 비교 패턴 spinner + "동의 중" 라벨 노출', () => {
// 회귀 가드: 행별 분기 — _local.grantingKey 가 해당 행의 consent_key 와 일치할 때만
// spinner 노출. 다른 행 버튼은 disabled (전역 진행 중 표시).
const text = serializeForSearch(section);
expect(text).toContain("_local.grantingKey === consent?.consent_key");
expect(text).toContain('"name": "spinner"');
expect(text).toContain('sirsoft-gdpr.consent.granting');
});
});
describe('정책 갱신 amber 안내 박스 (마이페이지 카드 내 needs_renewal 안내)', () => {
// PO 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지.
// 조건: gdprMeConsents.data.needs_renewal === true 일 때만 노출.
// 본문: 현 정책 버전 안내 (#23 informed consent) + 필수 쿠키 면제 hint
// 액션: 「전체 항목 다시 동의」 (POST /consent/renew-all)
// 제거된 acknowledge-policy 흐름은 본 박스에 부활하지 않음.
it('마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 가 존재한다', () => {
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
expect(banner).not.toBeNull();
});
it('amber 박스는 gdprMeConsents.data.needs_renewal === true 조건으로만 노출된다', () => {
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
expect(banner?.if).toBe('{{gdprMeConsents?.data?.needs_renewal === true}}');
});
it('amber 박스가 현 정책 버전 + 필수 쿠키 면제 hint 를 노출한다', () => {
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
const text = serializeForSearch(banner);
expect(text).toContain('sirsoft-gdpr.mypage.privacy.needs_renewal_with_version');
expect(text).toContain('current_policy_version');
expect(text).toContain('sirsoft-gdpr.mypage.privacy.needs_renewal_hint');
});
it('amber 박스의 「전체 항목 다시 동의」 버튼이 POST /consent/renew-all 을 호출한다', () => {
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
const text = serializeForSearch(banner);
expect(text).toContain('sirsoft-gdpr.mypage.privacy.btn_renew_all');
expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/renew-all');
expect(text).toContain('renew_all_success');
});
it('renew-all onSuccess 에서 sirsoft-gdpr.syncConsent 핸들러를 호출한다', () => {
// 회귀 가드: renew-all 직후 functional 동의가 갱신되거나 신규 카테고리 (cookie_functional)
// 가 활성 동의 매트릭스에 추가되는 경우, interceptor config 와 blocker 캐시를
// 동기화하지 않으면 새로고침 전까지 차단 상태가 유지됨 (동의 후 즉시 미반영 회귀).
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
const text = serializeForSearch(banner);
expect(text).toContain('"handler": "sirsoft-gdpr.syncConsent"');
});
it('「전체 다시 동의」 버튼에 isRenewingAll 동안 spinner + "갱신 중" 라벨 노출', () => {
// 회귀 가드: 클릭 즉시 시각 피드백 + 중복 클릭 차단. admin 저장 버튼 패턴 재사용.
const banner = card ? findById(card, 'gdpr_needs_renewal_banner') : null;
const text = serializeForSearch(banner);
expect(text).toContain('isRenewingAll');
expect(text).toContain('"name": "spinner"');
expect(text).toContain('sirsoft-gdpr.consent.renewing');
});
it('제거된 acknowledge-policy 흐름은 amber 박스에 부활하지 않는다 (회귀 가드)', () => {
const text = serializeForSearch(card);
expect(text).not.toContain('btn_acknowledge_policy');
expect(text).not.toContain('/api/plugins/sirsoft-gdpr/consent/acknowledge-policy');
expect(text).not.toContain('acknowledge_policy_success');
});
});
describe('액션 컬럼 — 「최신 정책으로 갱신」 분기 (#21)', () => {
const section = card ? findById(card, 'gdpr_section_consents') : null;
it('needs_renewal_this_item === true 일 때 amber 톤 버튼 + 「최신 정책으로 갱신」 라벨', () => {
const text = serializeForSearch(section);
expect(text).toContain('consent?.needs_renewal_this_item === true');
expect(text).toContain('mypage.privacy.btn_renew_this_item');
expect(text).toContain('bg-amber-600');
// 기존 분기도 유지
expect(text).toContain('mypage.privacy.grant_again');
expect(text).toContain('mypage.privacy.grant');
});
});
});
@@ -0,0 +1,304 @@
/**
* preblocker 회귀 테스트
*
* preblocker 는 GDPR 플러그인 IIFE 로드 직후 즉시 등록되어
* HTMLScriptElement.prototype.src setter 를 가로채는 사전 차단 엔진이다.
* settings API 응답을 기다리지 않고 보수적 차단(모든 비-necessary 차단)으로 진입하여
* race condition (settings 응답 지연 동안 추적 스크립트 외부 로드 완료) 을 방지한다.
*
* 본 테스트는 jsdom 환경에서 prototype intercept 의 차단/통과/복원 동작을 검증한다.
*
* 우선순위:
* 1. data-gdpr-category 속성 (운영자 명시) — origin 무관
* 2. 외부 origin + 카탈로그 매칭 — 도메인 기반
* 3. 그 외 — 통과 (동일 origin 자기 차단 방지 + 카탈로그 미매칭)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
installPreblocker,
uninstallPreblocker,
updatePreblockerConfig,
restorePreblockedCategory,
type PreblockerConfig,
} from '../preblocker';
const SITE_HOST = 'mysite.test';
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { hostname: SITE_HOST, href: `https://${SITE_HOST}/`, origin: `https://${SITE_HOST}` },
writable: true,
});
document.body.innerHTML = '';
document.head.innerHTML = '';
});
afterEach(() => {
uninstallPreblocker();
document.body.innerHTML = '';
document.head.innerHTML = '';
});
function defaultConfig(overrides: Partial<PreblockerConfig> = {}): PreblockerConfig {
return {
blockedDomains: {
analytics: [
'googletagmanager.com',
'*.googletagmanager.com',
'*.hotjar.com',
'static.hotjar.com',
],
marketing: ['connect.facebook.net'],
},
consent: { necessary: true },
...overrides,
};
}
describe('preblocker — 도메인 기반 차단 (외부 origin)', () => {
it('카탈로그 매칭 + 동의 없음 → src 미설정, blocked 속성 보존', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
expect(script.getAttribute('src')).toBeNull();
expect(script.getAttribute('data-gdpr-blocked-src')).toBe(
'https://www.googletagmanager.com/gtag/js?id=G-DEMO',
);
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
it('카탈로그 매칭 + 동의 있음 → src 설정 통과', () => {
installPreblocker(defaultConfig({ consent: { necessary: true, analytics: true } }));
const script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
expect(script.getAttribute('src')).toBe(
'https://www.googletagmanager.com/gtag/js?id=G-DEMO',
);
expect(script.getAttribute('data-gdpr-blocked-src')).toBeNull();
});
it('와일드카드 매칭 — *.hotjar.com → static.hotjar.com 차단', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.src = 'https://static.hotjar.com/c/hotjar-0.js';
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
it('카탈로그 미매칭 외부 도메인 → 통과', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.js';
expect(script.getAttribute('src')).toBe('https://cdn.example.com/lib.js');
expect(script.getAttribute('data-gdpr-blocked-src')).toBeNull();
});
});
describe('preblocker — 동일 origin 통과 (자기 차단 방지)', () => {
it('동일 origin 절대 URL — 속성 없으면 카탈로그 매칭 무관 통과', () => {
installPreblocker({
blockedDomains: { analytics: [SITE_HOST] }, // 일부러 자기 도메인을 카탈로그에 넣음
consent: { necessary: true },
});
const script = document.createElement('script');
script.src = `https://${SITE_HOST}/api/plugins/sirsoft-gdpr/assets/dist/js/plugin.iife.js`;
expect(script.getAttribute('src')).toBe(
`https://${SITE_HOST}/api/plugins/sirsoft-gdpr/assets/dist/js/plugin.iife.js`,
);
expect(script.getAttribute('data-gdpr-blocked-src')).toBeNull();
});
it('상대 경로 (/...) — 속성 없으면 통과', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.src = '/api/templates/assets/sirsoft-basic/js/components.iife.js';
expect(script.getAttribute('src')).toBe(
'/api/templates/assets/sirsoft-basic/js/components.iife.js',
);
});
it('프로토콜 상대 경로 (//...) 동일 host → 통과', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.src = `//${SITE_HOST}/local.js`;
expect(script.getAttribute('src')).toBe(`//${SITE_HOST}/local.js`);
});
});
describe('preblocker — 속성 기반 차단 (자체 호스팅 추적)', () => {
it('동일 origin + data-gdpr-category=analytics + 동의 없음 → 차단', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.setAttribute('data-gdpr-category', 'analytics');
script.src = `https://${SITE_HOST}/js/self-hosted-analytics.js`;
expect(script.getAttribute('src')).toBeNull();
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
it('동일 origin + data-gdpr-category=analytics + 동의 있음 → 통과', () => {
installPreblocker(defaultConfig({ consent: { necessary: true, analytics: true } }));
const script = document.createElement('script');
script.setAttribute('data-gdpr-category', 'analytics');
script.src = `https://${SITE_HOST}/js/self-hosted-analytics.js`;
expect(script.getAttribute('src')).toBe(
`https://${SITE_HOST}/js/self-hosted-analytics.js`,
);
});
it('data-gdpr-category=necessary → 동의 없음이어도 항상 통과', () => {
installPreblocker(defaultConfig());
const script = document.createElement('script');
script.setAttribute('data-gdpr-category', 'necessary');
script.src = `https://${SITE_HOST}/js/session.js`;
expect(script.getAttribute('src')).toBe(`https://${SITE_HOST}/js/session.js`);
});
it('속성 우선순위 — 외부 origin + 속성=marketing 이지만 카탈로그는 analytics → marketing 으로 차단 판정', () => {
installPreblocker(
defaultConfig({
consent: { necessary: true, analytics: true }, // analytics 만 동의
}),
);
const script = document.createElement('script');
script.setAttribute('data-gdpr-category', 'marketing'); // 운영자 marketing 분류
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
// 속성이 우선 → marketing 동의 없으므로 차단
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('marketing');
});
});
describe('preblocker — 초기 보수적 동의 상태', () => {
it('동의 상태 미지정 시 necessary 만 허용 — 모든 추적 차단', () => {
installPreblocker({
blockedDomains: { analytics: ['googletagmanager.com', '*.googletagmanager.com'] },
// consent 생략
} as PreblockerConfig);
const script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
});
describe('preblocker — updateConfig (settings/consent 응답 후 동기화)', () => {
it('updateConfig 후 새 카탈로그 적용', () => {
installPreblocker({
blockedDomains: { analytics: ['initial-only.test'] },
consent: { necessary: true },
});
// 초기 카탈로그에 없는 도메인은 통과
const script1 = document.createElement('script');
script1.src = 'https://later-added.test/track.js';
expect(script1.getAttribute('src')).toBe('https://later-added.test/track.js');
// 갱신
updatePreblockerConfig({
blockedDomains: { analytics: ['later-added.test'] },
consent: { necessary: true },
});
const script2 = document.createElement('script');
script2.src = 'https://later-added.test/track.js';
expect(script2.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
it('updateConfig 로 동의 부여 후 신규 요청 통과', () => {
installPreblocker(defaultConfig());
const script1 = document.createElement('script');
script1.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
expect(script1.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
updatePreblockerConfig({
blockedDomains: defaultConfig().blockedDomains,
consent: { necessary: true, analytics: true },
});
const script2 = document.createElement('script');
script2.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
expect(script2.getAttribute('src')).toBe(
'https://www.googletagmanager.com/gtag/js?id=G-DEMO',
);
});
});
describe('preblocker — restorePreblockedCategory (동의 후 복원)', () => {
it('차단된 요소를 새 <script> 로 교체하여 src 재설정', () => {
installPreblocker(defaultConfig());
const original = document.createElement('script');
original.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
document.head.appendChild(original);
expect(original.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
expect(original.getAttribute('src')).toBeNull();
// 동의 부여 → config 갱신 후 복원
updatePreblockerConfig({
blockedDomains: defaultConfig().blockedDomains,
consent: { necessary: true, analytics: true },
});
restorePreblockedCategory('analytics');
// 원본은 DOM 에서 제거, 새 요소가 같은 src 로 추가됨
const restored = document.head.querySelector<HTMLScriptElement>(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-DEMO"]',
);
expect(restored).not.toBeNull();
expect(restored?.getAttribute('data-gdpr-blocked-src')).toBeNull();
});
it('동의 안 받은 카테고리는 restore 호출해도 효과 없음', () => {
installPreblocker(defaultConfig());
const original = document.createElement('script');
original.src = 'https://www.googletagmanager.com/gtag/js?id=G-DEMO';
document.head.appendChild(original);
// consent 미갱신 상태에서 restore 호출
restorePreblockedCategory('analytics');
// 여전히 차단 상태
const stillBlocked = document.head.querySelector<HTMLScriptElement>(
'[data-gdpr-blocked-category="analytics"]',
);
expect(stillBlocked).not.toBeNull();
expect(stillBlocked?.getAttribute('src')).toBeNull();
});
});
describe('preblocker — 우회 방지 (createElement 후 직접 속성 설정)', () => {
it('속성 기반 차단 후 운영자가 카테고리 변경해도 일관 동작', () => {
installPreblocker(defaultConfig({ consent: { necessary: true, marketing: true } }));
const script = document.createElement('script');
script.setAttribute('data-gdpr-category', 'analytics'); // analytics 미동의
script.src = `https://${SITE_HOST}/js/internal.js`;
// analytics 차단
expect(script.getAttribute('data-gdpr-blocked-category')).toBe('analytics');
});
});
@@ -0,0 +1,151 @@
/**
* storageInterceptor 단위 테스트 (Phase 2 단순화).
*
* Storage.prototype.setItem 가로채기 + 4단계 게이팅 규칙 검증:
* 1. strictly necessary allowlist 항상 통과
* 2. functional 동의 시 모든 키 통과
* 3. user-initiated (WP29 §3.6) 면제 — 사용자 인터랙션 직후 통과
* 4. 그 외 → 차단
*
* "운영자 등록 표" 는 제거됨 (Phase 2 단순화).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_NECESSARY_ALLOWLIST,
installStorageInterceptor,
isStorageAllowed,
uninstallStorageInterceptor,
updateStorageInterceptorConfig,
} from '../storageInterceptor';
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
describe('storageInterceptor', () => {
beforeEach(() => {
installUserInitiatedTracker();
window.localStorage.clear();
window.sessionStorage.clear();
});
afterEach(() => {
uninstallStorageInterceptor();
uninstallUserInitiatedTracker();
});
it('strictly necessary 키 (g7_locale) → 항상 통과 (미동의여도)', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('g7_locale', 'ko');
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
});
it('prefix 매칭 (g7_devtools_*) → 통과', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('g7_devtools_filter', 'enabled');
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
});
it('functional 동의 시 모든 키 통과 (allowlist 외 키도 통과)', () => {
installStorageInterceptor({
functionalConsented: true,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('app_pref', 'value');
expect(window.localStorage.getItem('app_pref')).toBe('value');
});
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
__setLastInteractionForTest(Date.now());
window.localStorage.setItem('app_pref', 'value');
expect(window.localStorage.getItem('app_pref')).toBe('value');
});
it('미동의 + 비-사용자 (background) → 차단', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
// user-initiated 미발생 (timestamp=0)
window.localStorage.setItem('app_pref', 'value');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('미동의 + 임의 키 + 비-사용자 → 차단 (보수적)', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('random_key', 'value');
expect(window.localStorage.getItem('random_key')).toBeNull();
});
it('updateStorageInterceptorConfig 으로 동의 상태 갱신 가능', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
// 미동의 + 비-사용자 → 차단
window.localStorage.setItem('app_pref', 'value1');
expect(window.localStorage.getItem('app_pref')).toBeNull();
// 동의 갱신
updateStorageInterceptorConfig({
functionalConsented: true,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('app_pref', 'value2');
expect(window.localStorage.getItem('app_pref')).toBe('value2');
});
it('uninstall 후 원본 setItem 복원 — 모든 쓰기 통과', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
// 인터셉터 활성 시 차단
window.localStorage.setItem('random_key', 'v1');
expect(window.localStorage.getItem('random_key')).toBeNull();
// uninstall 후 정상 동작
uninstallStorageInterceptor();
window.localStorage.setItem('random_key', 'v2');
expect(window.localStorage.getItem('random_key')).toBe('v2');
});
it('isStorageAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
__setLastInteractionForTest(Date.now());
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(true); // user-initiated 면제
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary
// user-initiated 가 만료된 시점엔 차단
__setLastInteractionForTest(0);
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(false);
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary 는 항상 통과
// 함수 호출만으로 storage 변경 X
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
});
@@ -0,0 +1,80 @@
/**
* userInitiatedTracker 단위 테스트.
*
* - install 시 capture-phase 리스너 등록
* - isTrusted=true 인터랙션 만 timestamp 갱신
* - threshold 이내 → isUserInitiated=true / 이후 → false
* - window.event 폴백
* - uninstall 시 리스너 해제 + timestamp 리셋
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
USER_INITIATED_THRESHOLD_MS,
__setLastInteractionForTest,
installUserInitiatedTracker,
isUserInitiated,
uninstallUserInitiatedTracker,
} from '../userInitiatedTracker';
describe('userInitiatedTracker', () => {
beforeEach(() => {
installUserInitiatedTracker();
});
afterEach(() => {
uninstallUserInitiatedTracker();
vi.useRealTimers();
});
it('install 직후 인터랙션 없음 → isUserInitiated=false', () => {
expect(isUserInitiated()).toBe(false);
});
it('isTrusted=true click 이벤트 → 직후 isUserInitiated=true', () => {
// jsdom 의 dispatchEvent 는 isTrusted=false. 직접 timestamp 주입으로 동등 검증.
__setLastInteractionForTest(Date.now());
expect(isUserInitiated()).toBe(true);
});
it('threshold 초과 시점 → isUserInitiated=false', () => {
const past = Date.now() - USER_INITIATED_THRESHOLD_MS - 100;
__setLastInteractionForTest(past);
expect(isUserInitiated()).toBe(false);
});
it('threshold 경계값 (정확히 임계값) → 통과 (<=)', () => {
const boundary = Date.now() - USER_INITIATED_THRESHOLD_MS;
__setLastInteractionForTest(boundary);
expect(isUserInitiated()).toBe(true);
});
it('untrusted 이벤트 (스크립트 dispatchEvent) 는 timestamp 갱신 안 함', () => {
// jsdom dispatchEvent 는 isTrusted=false → handler 가 timestamp 갱신 안 함
const event = new MouseEvent('click', { bubbles: true });
window.dispatchEvent(event);
expect(isUserInitiated()).toBe(false);
});
it('uninstall 후 isUserInitiated=false (timestamp 리셋)', () => {
__setLastInteractionForTest(Date.now());
expect(isUserInitiated()).toBe(true);
uninstallUserInitiatedTracker();
expect(isUserInitiated()).toBe(false);
});
it('uninstall 후 재install 가능 (idempotent)', () => {
uninstallUserInitiatedTracker();
expect(() => installUserInitiatedTracker()).not.toThrow();
// re-install 후에도 timestamp 는 초기 상태
expect(isUserInitiated()).toBe(false);
});
it('중복 install 호출 → no-op (리스너 1개만 등록)', () => {
// 두 번째 install 호출이 silent 인지 확인 (예외 X)
expect(() => installUserInitiatedTracker()).not.toThrow();
});
});
@@ -0,0 +1,139 @@
/**
* GDPR 도메인 기반 차단 매칭 + 기본 카탈로그
*
* 운영자가 카테고리별 차단 도메인 목록을 관리하면 `data-gdpr-category` 속성
* 없이도 외부 추적 리소스를 동의 전 차단합니다. 매칭 알고리즘은 endsWith 기반
* 으로 정규식을 쓰지 않아 일반 페이지 영향이 미미합니다.
*
* @module sirsoft-gdpr/blocker-domains
*/
/**
* 호스트가 차단 도메인 패턴과 매칭되는지 확인합니다.
*
* 패턴 형식:
* - 정확 매칭: `google-analytics.com` → `google-analytics.com` 만 매칭
* - 와일드카드: `*.hotjar.com` → `static.hotjar.com` 등 매칭. `hotjar.com`
* 자체는 매칭 안 됨 (정확 매칭으로 별도 추가 필요)
*
* @param host 검사 대상 호스트
* @param pattern 차단 패턴
* @return 매칭 여부
*/
export function matchesDomain(host: string, pattern: string): boolean {
const lowerHost = host.toLowerCase();
const lowerPattern = pattern.toLowerCase();
if (lowerPattern.startsWith('*.')) {
const suffix = lowerPattern.slice(1); // ".hotjar.com"
return lowerHost.endsWith(suffix) && lowerHost !== suffix.slice(1);
}
return lowerHost === lowerPattern;
}
/**
* URL 의 호스트가 카테고리별 차단 목록에 매칭되는지 확인합니다.
*
* 동일 origin 은 차단 대상 외 (운영자 자체 자산 보호). `data:`/`blob:` 등 비
* HTTP 스킴은 외부 호스트가 없으므로 차단 대상 외. `necessary` 카테고리는
* 매칭 자체를 skip 합니다 (필수 쿠키 보호).
*
* @param url 검사 대상 URL (절대 또는 상대)
* @param patterns 카테고리 → 도메인 패턴 배열
* @return 매칭된 카테고리, 매칭 없으면 null
*/
export function categorizeUrl(
url: string,
patterns: Record<string, readonly string[]>,
): string | null {
if (typeof window === 'undefined' || typeof URL === 'undefined') return null;
let host: string;
let protocol: string;
try {
const parsed = new URL(url, window.location.href);
protocol = parsed.protocol;
host = parsed.hostname;
} catch {
return null;
}
if (protocol !== 'http:' && protocol !== 'https:') return null;
if (host === '' || host === window.location.hostname) return null;
for (const [category, domains] of Object.entries(patterns)) {
if (category === 'necessary') continue;
for (const domain of domains) {
if (matchesDomain(host, domain)) return category;
}
}
return null;
}
/**
* 기본 차단 도메인 카탈로그 — 운영자가 `blocked_domains_default_catalog = true`
* 로 켜야 적용. 운영자 커스텀 목록과 합집합으로 병합됩니다.
*
* 카탈로그 갱신은 코드 PR 로만 가능. 사용자별 변경 불가.
*/
export const DEFAULT_BLOCKED_DOMAINS: Record<string, readonly string[]> = {
// functional 카테고리는 자체 호스팅 functional 만 처리 — 외부 도구 도메인은 Phase 2 에서 보강.
// 현재 빈 배열로 두어 운영자가 admin UI 의 functional 차단 도메인 섹션을 사용할 수 있도록 함.
functional: [],
analytics: [
// Google Analytics 계열
'google-analytics.com',
'*.google-analytics.com',
'googletagmanager.com',
'*.googletagmanager.com',
'ssl.google-analytics.com',
// 행동 분석
'*.hotjar.com',
'static.hotjar.com',
'*.mixpanel.com',
'cdn.mxpnl.com',
'*.amplitude.com',
'cdn.amplitude.com',
'*.segment.io',
'*.segment.com',
// 한국 분석 도구
'wcs.naver.net',
'wcs.naver.com',
'*.beusable.net',
],
marketing: [
// Meta (Facebook)
'facebook.net',
'connect.facebook.net',
'facebook.com',
'*.facebook.com',
// Google Ads
'doubleclick.net',
'*.doubleclick.net',
'googleadservices.com',
'googlesyndication.com',
'ads.google.com',
// 광고 네트워크
'*.criteo.com',
'static.criteo.net',
'*.adnxs.com',
'*.taboola.com',
'cdn.taboola.com',
'*.outbrain.com',
// 한국 광고 네트워크
'*.kakao.com',
'analytics.ad.daum.net',
// SNS 임베드
'platform.twitter.com',
'*.twitter.com',
'platform.linkedin.com',
'*.linkedin.com',
],
};
// `mergeBlockedDomains()` 는 카탈로그 토글이 단일 개념으로 통합되면서 제거되었습니다.
// 카탈로그 도메인은 신규 설치 시 `blocked_domains` 기본값으로 채워지며, 차단 엔진은
// 운영자 입력 도메인만 사용합니다. 카탈로그 갱신 동기화는 관리자 UI 의 "카탈로그에서
// 도메인 가져오기" 링크가 클라이언트 측 Set 합집합으로 처리합니다.
@@ -0,0 +1,330 @@
/**
* GDPR 자동 차단 엔진
*
* 두 가지 매칭 방식을 병행 적용합니다:
*
* 1. 속성 기반 — `data-gdpr-category` 속성이 부여된 `<script>` / `<iframe>`
* (운영자가 명시적으로 부여한 의도 — 평가 우선)
* 2. 도메인 기반 — `<script>` / `<iframe>` / `<img>` / `<link>` 의 URL 호스트가
* 운영자 차단 도메인 목록 또는 기본 카탈로그와 매칭
*
* 동의 전에는 placeholder div 로 치환하여 외부 요청 자체를 차단. 동의 부여 시
* 원본 복원. 동일 origin 리소스는 차단 대상 외 (운영자 자체 자산 보호).
*
* GDPR Art.6 — 합법적 처리 근거. 동의 없는 추적 스크립트 차단으로 분석/마케팅
* 카테고리 동의 전 GA·Meta Pixel 등이 작동하지 않도록 합니다.
*
* 동의 상태 SSoT: 서버 응답 (/api/plugins/sirsoft-gdpr/consent/cookie/status).
* 부트스트랩에서 setCurrentConsent() 로 캐시 주입 + 레이아웃 JSON 의 동의 저장
* onSuccess 시점에 `gdpr:consent-changed` 커스텀 이벤트로 캐시 갱신 + rescan.
*
* @module sirsoft-gdpr/blocker
*/
import { categorizeUrl } from './blocker-domains';
const BLOCKED_ATTR = 'data-gdpr-category';
const PLACEHOLDER_CLASS = 'gdpr-blocked-embed';
const ORIGINAL_DATA_ATTR = 'data-gdpr-original';
/**
* MutationObserver SELECTOR — 도메인 매칭 위해 src/href 가진 모든 대상 태그 +
* 기존 속성 기반 매칭 대상.
*/
const BLOCK_SELECTOR = [
`script[${BLOCKED_ATTR}]`,
`iframe[${BLOCKED_ATTR}]`,
'script[src]',
'iframe[src]',
'img[src]',
'link[href]',
].join(', ');
/**
* 카테고리 키 → 동의 여부 (서버 응답 형식 그대로) 또는 needs_renewal 부가정보 포함 형태.
*
* BC: 기존 호출처는 plain Record 형태로 사용. 신규 호출처는 DetailedConsentSnapshot
* 으로 needs_renewal 등 부가 정보 전달 가능. 내부 normalize 로 통일 처리.
*
* 예 (plain): { necessary: true, analytics: false, marketing: false }
* 예 (detailed): { categories: { necessary: true, analytics: true }, needs_renewal: true }
*/
export type ConsentSnapshot = Record<string, boolean> | DetailedConsentSnapshot;
/**
* 부가 정보를 포함한 동의 스냅샷.
*
* needs_renewal=true 면 옛 정책 동의가 신정책에 그대로 쓰이고 있는 상태 →
* 차단 엔진은 필수 외 모든 카테고리를 false 강제 (보수적 차단, GDPR Art.6).
*/
export interface DetailedConsentSnapshot {
categories: Record<string, boolean>;
needs_renewal?: boolean;
}
/**
* 정규화된 동의 캐시 — 내부 표현. union 입력을 본 형태로 통일.
*/
interface NormalizedConsent {
categories: Record<string, boolean>;
needs_renewal: boolean;
}
let observer: MutationObserver | null = null;
let currentConsent: NormalizedConsent = { categories: {}, needs_renewal: false };
let blockedDomains: Record<string, readonly string[]> = {};
/**
* 입력 union 을 NormalizedConsent 로 통일.
*
* @param input plain Record 또는 DetailedConsentSnapshot
* @return 정규화된 NormalizedConsent
*/
function normalizeConsent(input: ConsentSnapshot): NormalizedConsent {
if (input && typeof input === 'object' && 'categories' in input) {
const detailed = input as DetailedConsentSnapshot;
return {
categories: { ...detailed.categories },
needs_renewal: detailed.needs_renewal === true,
};
}
return {
categories: { ...(input as Record<string, boolean>) },
needs_renewal: false,
};
}
/**
* 차단 엔진의 동의 캐시를 갱신합니다.
*
* 부트스트랩(/consent/cookie/status 응답) 또는 동의 저장 직후 호출.
* 캐시 갱신 후 자동으로 rescan 하여 차단/복원 상태를 즉시 반영합니다.
*
* @param consent 카테고리별 동의 상태 스냅샷 (plain Record 또는 DetailedConsentSnapshot)
*/
export function setCurrentConsent(consent: ConsentSnapshot): void {
currentConsent = normalizeConsent(consent);
if (observer !== null) {
rescanBlocked();
}
}
/**
* 차단 도메인 목록을 차단 엔진에 주입합니다.
*
* 부트스트랩에서 운영자 커스텀 + 기본 카탈로그를 병합한 결과를 한 번 주입.
* 이후 동의 변경 시 rescan 에 그대로 사용됩니다.
*
* @param patterns 카테고리 → 도메인 패턴 배열
*/
export function setBlockedDomains(patterns: Record<string, readonly string[]>): void {
blockedDomains = { ...patterns };
if (observer !== null) {
rescanBlocked();
}
}
/**
* 차단 엔진 시작 — DOM 변화 관찰 + 초기 스캔.
*/
export function startBlocker(): void {
if (typeof document === 'undefined' || observer !== null) return;
rescanBlocked();
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
processNode(node);
});
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// 동의 변경 이벤트 — 레이아웃 JSON 동의 저장 onSuccess 또는 setCurrentConsent 호출 시 발화
window.addEventListener('gdpr:consent-changed', handleConsentChanged as EventListener);
}
/**
* 차단 엔진 중지 — 테스트/SPA 라우트 정리용.
*/
export function stopBlocker(): void {
if (observer !== null) {
observer.disconnect();
observer = null;
}
window.removeEventListener('gdpr:consent-changed', handleConsentChanged as EventListener);
}
/**
* gdpr:consent-changed 이벤트 핸들러 — detail 에 카테고리별 동의 스냅샷 기대.
*
* detail 은 plain Record (BC) 또는 DetailedConsentSnapshot 양쪽 호환.
*/
function handleConsentChanged(event: Event): void {
const detail = (event as CustomEvent).detail;
if (detail !== null && typeof detail === 'object') {
currentConsent = normalizeConsent(detail as ConsentSnapshot);
}
rescanBlocked();
}
/**
* 단일 노드 + 자손 처리.
*
* @param el HTMLElement
*/
function processNode(el: HTMLElement): void {
if (matchesBlockSelector(el)) {
gateElement(el);
}
el.querySelectorAll<HTMLElement>(BLOCK_SELECTOR).forEach((child) => gateElement(child));
}
/**
* 페이지 전체 재스캔 — 동의 변경 시 호출.
*/
function rescanBlocked(): void {
if (typeof document === 'undefined') return;
// 1. 차단된 placeholder 중 동의 받은 카테고리는 복원
document.querySelectorAll<HTMLElement>(`.${PLACEHOLDER_CLASS}`).forEach((placeholder) => {
const category = placeholder.dataset.gdprBlockedCategory;
if (category && isCategoryAllowed(category)) {
restoreElement(placeholder);
}
});
// 2. 미차단 요소 중 동의 안 받은 카테고리는 차단
document.querySelectorAll<HTMLElement>(BLOCK_SELECTOR).forEach((el) => gateElement(el));
}
/**
* 동의 안 받은 카테고리이면 placeholder 로 치환.
*
* 평가 우선순위:
* 1. 속성 기반 매칭 (운영자 명시 의도)
* 2. 도메인 기반 매칭 (운영자 차단 도메인 또는 카탈로그)
*
* @param el 차단 대상 요소
*/
function gateElement(el: HTMLElement): void {
// 이미 placeholder 로 변환된 노드는 skip
if (el.classList.contains(PLACEHOLDER_CLASS)) return;
// preblocker (1차 라인) 가 이미 처리한 <script> 는 중복 차단 회피.
// preblocker 는 src 를 무시하고 data-gdpr-blocked-* 속성으로 보존하므로
// 외부 요청이 발생하지 않은 상태. blocker.ts (MutationObserver 백업) 는
// preblocker 우회 케이스만 처리.
if (el.hasAttribute('data-gdpr-blocked-category')) return;
// 1. 속성 기반
const attrCategory = el.getAttribute(BLOCKED_ATTR);
if (attrCategory !== null && attrCategory !== '') {
if (isCategoryAllowed(attrCategory)) return;
replaceWithPlaceholder(el, attrCategory);
return;
}
// 2. 도메인 기반
const url = extractUrl(el);
if (url === null) return;
const domainCategory = categorizeUrl(url, blockedDomains);
if (domainCategory === null) return;
if (isCategoryAllowed(domainCategory)) return;
replaceWithPlaceholder(el, domainCategory);
}
/**
* 차단 대상 요소에서 외부 URL 추출 (`<script src>`, `<iframe src>`, `<img src>`, `<link href>`).
*
* @param el HTMLElement
* @return 외부 URL 또는 null
*/
function extractUrl(el: HTMLElement): string | null {
if (el instanceof HTMLScriptElement) return el.getAttribute('src');
if (el instanceof HTMLIFrameElement) return el.getAttribute('src');
if (el instanceof HTMLImageElement) return el.getAttribute('src');
if (el instanceof HTMLLinkElement) return el.getAttribute('href');
return null;
}
/**
* 요소가 차단 SELECTOR 와 매칭되는지 확인.
*
* @param el HTMLElement
* @return 매칭 여부
*/
function matchesBlockSelector(el: HTMLElement): boolean {
if (typeof el.matches !== 'function') return false;
try {
return el.matches(BLOCK_SELECTOR);
} catch {
return false;
}
}
/**
* 요소를 placeholder 로 교체 (원본은 outerHTML 로 보존).
*
* @param el 차단 대상
* @param category 차단 카테고리
*/
function replaceWithPlaceholder(el: HTMLElement, category: string): void {
const placeholder = document.createElement('div');
placeholder.className = PLACEHOLDER_CLASS;
placeholder.dataset.gdprBlockedCategory = category;
placeholder.dataset.gdprOriginalTag = el.tagName.toLowerCase();
placeholder.setAttribute(ORIGINAL_DATA_ATTR, el.outerHTML);
placeholder.setAttribute('aria-hidden', 'true');
placeholder.style.display = 'none';
el.replaceWith(placeholder);
}
/**
* placeholder 에서 원본 요소 복원.
*
* @param placeholder 차단 placeholder div
*/
function restoreElement(placeholder: HTMLElement): void {
const original = placeholder.getAttribute(ORIGINAL_DATA_ATTR);
if (!original) {
placeholder.remove();
return;
}
const tmp = document.createElement('div');
tmp.innerHTML = original;
const restored = tmp.firstElementChild;
if (restored !== null) {
placeholder.replaceWith(restored);
} else {
placeholder.remove();
}
}
/**
* 카테고리가 허용되었는지 확인.
*
* 필수(necessary) 카테고리는 동의 여부와 무관하게 항상 허용 (ePrivacy Art.5(3) 면제).
* needs_renewal=true (옛 동의가 신정책에 그대로 쓰이는 상태) 면 필수 외 모든 카테고리
* 강제 false 처리 — 사용자가 의사 표명할 때까지 보수적 차단 (GDPR Art.6 강화).
* 그 외는 currentConsent.categories[category] === true 인 경우만 허용.
*
* @param category 카테고리 키
* @return 허용 여부
*/
function isCategoryAllowed(category: string): boolean {
if (category === 'necessary') return true;
if (currentConsent.needs_renewal) return false;
return currentConsent.categories[category] === true;
}
@@ -0,0 +1,208 @@
/**
* GDPR 1st-party Cookie 가로채기 (cookieInterceptor)
*
* `document.cookie` setter 를 가로채 functional 카테고리 미동의 상태에서
* 신규 쿠키 쓰기를 차단. EDPB Guidelines 2/2023 §16 "동의 전 사전 차단" 충족.
*
* 본 모듈은 **클라이언트 측 쓰기** 만 가로챔. 서버 Set-Cookie 응답은
* CookieConsentMiddleware (서버) 가 처리. 두 라인이 함께 동작해야 완전.
*
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
* 1. cleared cookie (Max-Age=0 / expires 과거) → 항상 허용 (§117 충돌 회피)
* 2. strictly necessary allowlist 매칭 → 허용
* 3. functional 동의 → 허용
* 4. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
* 5. 그 외 → 차단
*
* "운영자 등록 표" 는 제거됨 — 모든 비-필수 cookie 는 동일 규칙 적용.
*
* @module sirsoft-gdpr/cookieInterceptor
*/
import { isUserInitiated } from './userInitiatedTracker';
/**
* 인터셉터 설정.
*
* @property functionalConsented functional 카테고리 동의 여부
* @property necessaryAllowlist strictly necessary 쿠키 이름 (서버 cookie 와 별개 — 클라이언트 쓰기 화이트리스트)
*/
export interface CookieInterceptorConfig {
functionalConsented: boolean;
necessaryAllowlist: readonly string[];
}
/**
* 정적 strictly necessary 쿠키 화이트리스트 (클라이언트 쓰기).
*
* 일반적으로 코어가 쓰는 쿠키는 모두 서버 Set-Cookie 로 발급되므로 클라이언트 쓰기는
* 거의 없음. 폼 보호용 XSRF-TOKEN refresh, 세션 ID 등 일부만 화이트리스트.
*/
export const DEFAULT_NECESSARY_COOKIE_ALLOWLIST: readonly string[] = [
'XSRF-TOKEN',
'laravel_session',
'laravel_maintenance',
'gdpr_session',
];
let installed = false;
let originalCookieDescriptor: PropertyDescriptor | null = null;
let config: CookieInterceptorConfig = {
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
};
/**
* "name=value; Path=/; ..." 형식의 cookie 문자열에서 name 만 추출합니다.
*
* @param raw document.cookie setter 에 전달된 raw 문자열
* @return 쿠키 이름 (실패 시 null)
*/
function extractCookieName(raw: string): string | null {
const eqIdx = raw.indexOf('=');
if (eqIdx <= 0) {
return null;
}
return raw.substring(0, eqIdx).trim();
}
/**
* 이름이 strictly necessary 화이트리스트에 포함되는지 검사합니다.
*
* @param name 쿠키 이름
* @return 매칭 여부
*/
function matchesNecessary(name: string): boolean {
return config.necessaryAllowlist.includes(name);
}
/**
* cookie 쓰기가 허용되는지 판정합니다.
*
* 파기 cookie (값 비어있음 + expires 또는 Max-Age 가 과거/0) 는 항상 통과 —
* EDPB §117 (철회 즉시 파기) 와 인터셉터의 §16 (사전 차단) 가 충돌하지 않도록.
* cleaner 가 발송하는 Max-Age=0 cookie 도 본 분기로 통과.
*
* @param rawValue document.cookie setter 입력
* @return 허용 여부
*/
export function isCookieAllowed(rawValue: string): boolean {
const name = extractCookieName(rawValue);
if (name === null) {
// 파싱 불가 → 보수적 차단 (잘못된 cookie 문자열은 어차피 브라우저가 무시).
return false;
}
// 파기 cookie 패턴 — Max-Age=0 / Max-Age=-N / expires=past
if (isClearingCookie(rawValue)) {
return true;
}
if (matchesNecessary(name)) {
return true;
}
if (config.functionalConsented) {
return true;
}
// user-initiated 면제 (WP29 §3.6) — 사용자가 직접 트리거한 일회성 설정은 동의 없이 허용.
if (isUserInitiated()) {
return true;
}
return false;
}
/**
* cookie 문자열이 파기 의도 (Max-Age=0 / 음수 또는 expires 과거) 인지 판정.
*
* 표준 cookie 삭제 패턴 — 브라우저가 즉시 해당 cookie 를 파기. EDPB §117 (철회 즉시 파기)
* 의 필수 메커니즘이므로 인터셉터가 항상 통과시켜야 함.
*
* @param rawValue document.cookie setter 입력
* @return 파기 cookie 여부
*/
function isClearingCookie(rawValue: string): boolean {
const lower = rawValue.toLowerCase();
// Max-Age=0 또는 음수
const maxAgeMatch = lower.match(/max-age\s*=\s*(-?\d+)/);
if (maxAgeMatch && parseInt(maxAgeMatch[1], 10) <= 0) {
return true;
}
// expires=과거 — 1970 휴리스틱
if (lower.includes('expires=thu, 01 jan 1970') || lower.includes('expires=thu, 01-jan-1970')) {
return true;
}
return false;
}
/**
* 인터셉터를 설치합니다 — Document.prototype 의 cookie setter 를 가로채기.
*
* @param initialConfig 초기 설정
* @return void
*/
export function installCookieInterceptor(initialConfig: CookieInterceptorConfig): void {
if (installed) {
return;
}
installed = true;
config = initialConfig;
originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') ?? null;
if (!originalCookieDescriptor || !originalCookieDescriptor.set || !originalCookieDescriptor.get) {
// 환경 미지원 — 폴백 없이 종료 (jsdom 일부 버전).
installed = false;
return;
}
const originalSet = originalCookieDescriptor.set;
const originalGet = originalCookieDescriptor.get;
Object.defineProperty(Document.prototype, 'cookie', {
configurable: true,
get(this: Document): string {
return originalGet.call(this);
},
set(this: Document, value: string) {
if (!isCookieAllowed(value)) {
return;
}
originalSet.call(this, value);
},
});
}
/**
* 인터셉터 설정을 갱신합니다.
*
* @param newConfig 갱신할 설정
* @return void
*/
export function updateCookieInterceptorConfig(newConfig: CookieInterceptorConfig): void {
config = newConfig;
}
/**
* 인터셉터를 해제합니다 (테스트 / cleanup).
*
* @return void
*/
export function uninstallCookieInterceptor(): void {
if (!installed) {
return;
}
if (originalCookieDescriptor) {
Object.defineProperty(Document.prototype, 'cookie', originalCookieDescriptor);
}
originalCookieDescriptor = null;
config = {
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
};
installed = false;
}
@@ -0,0 +1,143 @@
/**
* GDPR functional 카테고리 cleanup 함수 (functionalCleaner — Phase 2 단순화)
*
* 사용자가 functional 동의를 철회하거나 부팅 시 functional 미동의 상태인 경우,
* **strictly necessary allowlist 외 모든** localStorage / sessionStorage / cookie
* 를 즉시 파기. EDPB Guidelines 05/2020 §117 "동의 철회 시 즉시 중단" 충족.
*
* cleanup 정책 (Phase 2 단순화):
* - 운영자 등록 표 (functional_storage_keys / functional_cookies) 불필요
* - strictly necessary allowlist (코드 상수) 외 모든 키/이름을 자동 파기
* - GDPR 원칙 "strictly necessary 외 비-필수는 동의 전 차단" 의 cleanup 측 적용
*
* 본 함수는 인터셉터 install 이후 호출되어도 안전 — removeItem 은 인터셉터가
* 통과시키며, cookie 파기 패턴 (Max-Age=0) 도 cookieInterceptor 의 `isClearingCookie`
* 가드로 통과.
*
* @module sirsoft-gdpr/functionalCleaner
*/
import {
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
} from './cookieInterceptor';
import {
DEFAULT_NECESSARY_ALLOWLIST,
type NecessaryAllowlistEntry,
type StorageKind,
} from './storageInterceptor';
/**
* cleanup 옵션 (선택).
*
* @property storageAllowlist strictly necessary storage allowlist (기본: DEFAULT_NECESSARY_ALLOWLIST)
* @property cookieAllowlist strictly necessary cookie allowlist (기본: DEFAULT_NECESSARY_COOKIE_ALLOWLIST)
*/
export interface FunctionalCleanupOptions {
storageAllowlist?: readonly NecessaryAllowlistEntry[];
cookieAllowlist?: readonly string[];
}
/**
* 키가 strictly necessary storage allowlist 에 매칭되는지 검사합니다.
*
* @param key 스토리지 키
* @param storage 호출 스토리지 종류
* @param allowlist allowlist 항목 배열
* @return 매칭 여부
*/
function isNecessaryStorage(
key: string,
storage: StorageKind,
allowlist: readonly NecessaryAllowlistEntry[],
): boolean {
for (const entry of allowlist) {
if (entry.storage && entry.storage !== storage) {
continue;
}
const matchType = entry.matchType ?? 'exact';
if (matchType === 'exact' && entry.key === key) {
return true;
}
if (matchType === 'prefix' && key.startsWith(entry.key)) {
return true;
}
}
return false;
}
/**
* 한 Storage 인스턴스에서 strictly necessary allowlist 외 모든 키를 파기합니다.
*
* 순회 중 removeItem 으로 storage.length 가 줄어드는 문제를 피하기 위해 키 목록을 먼저 수집.
*
* @param storage 대상 Storage (localStorage 또는 sessionStorage)
* @param storageKind storage 종류 (allowlist 매칭용)
* @param allowlist necessary allowlist
* @return void
*/
function purgeStorage(
storage: Storage,
storageKind: StorageKind,
allowlist: readonly NecessaryAllowlistEntry[],
): void {
const keys: string[] = [];
for (let i = 0; i < storage.length; i++) {
const k = storage.key(i);
if (k !== null) keys.push(k);
}
for (const key of keys) {
if (isNecessaryStorage(key, storageKind, allowlist)) {
continue;
}
try {
storage.removeItem(key);
} catch {
// SecurityError / QuotaExceeded 등 — 조용히 무시.
}
}
}
/**
* document.cookie 에서 strictly necessary allowlist 외 모든 cookie 를 Max-Age=0 으로 파기합니다.
*
* @param allowlist necessary cookie allowlist (이름 배열)
* @return void
*/
function purgeCookies(allowlist: readonly string[]): void {
const raw = document.cookie;
if (!raw) return;
const cookies = raw.split(';');
for (const cookie of cookies) {
const eq = cookie.indexOf('=');
const name = (eq > -1 ? cookie.substring(0, eq) : cookie).trim();
if (!name) continue;
if (allowlist.includes(name)) continue;
// 표준 cookie 파기 패턴 — Max-Age=0 + 빈 값 + Path=/
// cookieInterceptor 의 isClearingCookie 가드로 통과.
try {
document.cookie = `${name}=; Max-Age=0; Path=/`;
} catch {
// 무시.
}
}
}
/**
* functional 카테고리 cleanup — strictly necessary allowlist 외 모든 1st-party 저장소 파기.
*
* 부팅 시점 (재방문 + 미동의) 또는 동의 철회 시점에 호출.
*
* @param options 옵션 (생략 시 기본 allowlist 사용)
* @return void
*/
export function cleanupFunctionalArtifacts(options: FunctionalCleanupOptions = {}): void {
const storageAllowlist = options.storageAllowlist ?? DEFAULT_NECESSARY_ALLOWLIST;
const cookieAllowlist = options.cookieAllowlist ?? DEFAULT_NECESSARY_COOKIE_ALLOWLIST;
purgeStorage(window.localStorage, 'localStorage', storageAllowlist);
purgeStorage(window.sessionStorage, 'sessionStorage', storageAllowlist);
purgeCookies(cookieAllowlist);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 전역 타입 선언 — sirsoft-gdpr 플러그인
*
* Vite 의 CSS side-effect import 를 TypeScript 가 인식할 수 있도록 모듈 선언 추가.
*/
declare module '*.css' {
const content: string;
export default content;
}
@@ -0,0 +1,261 @@
/**
* sirsoft-gdpr 플러그인 진입점
*
* 페이지 로드 시 자동 부트스트랩:
*
* 0. 사전 차단 즉시 등록 (preblocker) — settings fetch 기다리지 않고
* window.G7Config.plugins['sirsoft-gdpr'].blocked_domains 를 동기 사용하여
* HTMLScriptElement.prototype.src setter 가로채기. 보수적 동의 (necessary 만)
* 상태로 진입하여 settings/consent 응답 지연 동안 추적 스크립트가 외부 로드되는
* race condition 방지.
* 1. Phase 2 1st-party storage/cookie 인터셉터 즉시 등록 — strictly necessary
* allowlist 외 모든 비-필수 저장을 동의 시까지 차단 (EDPB Guidelines 2/2023 §16).
* user-initiated 면제 (WP29 §3.6) 는 항상 활성.
* 2. 공개 설정 페치 (banner_enabled / cookie_policy_version / blocked_domains)
* 3. 자동 차단 엔진 (MutationObserver 백업 라인) 시작 — banner_enabled=true 시
* 4. 현재 방문자의 카테고리별 동의 상태 페치 후 preblocker + blocker + interceptor 동기화
* 5. 부팅 시점 functional 미동의면 cleanup 호출 (allowlist 외 모든 storage/cookie 파기, EDPB §117)
* 6. ActionDispatcher 에 `sirsoft-gdpr.syncConsent` 핸들러 등록 — 레이아웃의 동의 저장
* onSuccess 에서 호출하여 두 차단 엔진 + 인터셉터 동기화 + 필요 시 cleanup
*
* 동의 SSoT 는 서버 DB. 배너 표시/저장은 cookie_banner.json 레이아웃이 단독 처리하며,
* 차단 엔진 + 인터셉터는 `sirsoft-gdpr.syncConsent` 핸들러로 동기화.
*
* @module sirsoft-gdpr/index
*/
import '../css/banner.css';
import { startBlocker, setCurrentConsent, setBlockedDomains } from './blocker';
import {
installCookieInterceptor,
updateCookieInterceptorConfig,
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
} from './cookieInterceptor';
import { cleanupFunctionalArtifacts } from './functionalCleaner';
import { fetchPublicSettings, fetchConsentSnapshot } from './layouts';
import {
installPreblocker,
updatePreblockerConfig,
restorePreblockedCategory,
type PreblockerConfig,
} from './preblocker';
import {
installStorageInterceptor,
updateStorageInterceptorConfig,
DEFAULT_NECESSARY_ALLOWLIST,
} from './storageInterceptor';
import { installUserInitiatedTracker } from './userInitiatedTracker';
const PLUGIN_IDENTIFIER = 'sirsoft-gdpr';
interface ActionDispatcher {
registerHandler: (name: string, handler: (...args: unknown[]) => unknown) => void;
}
interface G7Config {
plugins?: Record<string, Record<string, unknown>>;
}
/**
* G7Config 인라인 페이로드에서 GDPR 차단 카탈로그 즉시 조회.
*
* @return 카테고리 → 도메인 패턴 배열
*/
function readInlineBlockedDomains(): Record<string, readonly string[]> {
if (typeof window === 'undefined') return {};
const config = (window as unknown as { G7Config?: G7Config }).G7Config;
const pluginConfig = config?.plugins?.[PLUGIN_IDENTIFIER];
const raw = pluginConfig?.blocked_domains;
if (raw === undefined || raw === null || typeof raw !== 'object') return {};
const normalized: Record<string, readonly string[]> = {};
for (const [category, domains] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(domains)) continue;
const strs = domains.filter((d): d is string => typeof d === 'string' && d !== '');
if (strs.length > 0) normalized[category] = strs;
}
return normalized;
}
/**
* banner_enabled 도 G7Config 페이로드에 인라인 — 페이지 진입 즉시 사용 가능.
*
* @return banner_enabled boolean
*/
function readInlineBannerEnabled(): boolean {
if (typeof window === 'undefined') return false;
const config = (window as unknown as { G7Config?: G7Config }).G7Config;
const pluginConfig = config?.plugins?.[PLUGIN_IDENTIFIER];
return pluginConfig?.banner_enabled === true;
}
/**
* G7Core.getActionDispatcher() 가 준비될 때까지 대기 후 핸들러 등록.
*
* 모듈/플러그인 에셋 로딩 순서에 따라 ActionDispatcher 가 아직 초기화 안 됐을 수
* 있으므로 setTimeout 으로 polling. module-assets.md 의 권장 패턴.
*/
function registerSyncHandler(): void {
const dispatcher = (
window as unknown as { G7Core?: { getActionDispatcher?: () => ActionDispatcher | undefined } }
).G7Core?.getActionDispatcher?.();
if (!dispatcher) {
setTimeout(registerSyncHandler, 100);
return;
}
// sirsoft-gdpr.syncConsent — 동의 저장 직후 차단 엔진 + 인터셉터 동기화
dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.syncConsent`, async () => {
const snapshot = await fetchConsentSnapshot();
if (snapshot === null) return;
const functionalConsented = !snapshot.needs_renewal && snapshot.categories.functional === true;
// 1. blocker.ts (MutationObserver 백업 라인) 동기화 — rescan 으로 placeholder 복원
setCurrentConsent({ categories: snapshot.categories, needs_renewal: snapshot.needs_renewal });
// 2. preblocker (1차 라인) 동기화 — 새 차단 판정에 즉시 반영
const blockedDomains = readInlineBlockedDomains();
updatePreblockerConfig({
blockedDomains,
consent: snapshot.categories,
needs_renewal: snapshot.needs_renewal,
});
// 3. Phase 2: storage / cookie 인터셉터 동기화
updateStorageInterceptorConfig({
functionalConsented,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
updateCookieInterceptorConfig({
functionalConsented,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
// 4. EDPB §117: 동의 철회 즉시 파기 — strictly necessary allowlist 외 모든 storage/cookie cleanup.
if (!functionalConsented) {
cleanupFunctionalArtifacts();
}
// 5. 동의 받은 카테고리의 preblocker 차단 요소 복원 (단, needs_renewal=true 면 복원 안 함)
if (!snapshot.needs_renewal) {
for (const [category, allowed] of Object.entries(snapshot.categories)) {
if (allowed === true && category !== 'necessary') {
restorePreblockedCategory(category);
}
}
}
});
}
/**
* 부트스트랩 — DOMContentLoaded 또는 즉시 실행.
*/
async function bootstrap(): Promise<void> {
if (typeof window === 'undefined') return;
// 핸들러 등록은 banner_enabled 활성 여부와 무관하게 항상 수행.
registerSyncHandler();
// 0. preblocker + 1st-party 인터셉터 즉시 등록 (banner_enabled=true 시) — settings fetch 기다리지 않음.
// G7Config 인라인 페이로드 (HTML 응답에 동기 포함) 로 즉시 카탈로그 사용 가능.
// 보수적 동의 상태 (necessary 만) 로 진입 — settings/consent fetch 완료 후 보강.
const inlineBannerEnabled = readInlineBannerEnabled();
if (inlineBannerEnabled) {
const inlineDomains = readInlineBlockedDomains();
installPreblocker({
blockedDomains: inlineDomains,
consent: { necessary: true },
});
// Phase 2: functional 1st-party storage 게이팅 (EDPB Guidelines 2/2023 §16).
// - userInitiatedTracker: 사용자 인터랙션 시각 기록 (WP29 §3.6 면제 판정용, 항상 활성)
// - storageInterceptor: localStorage / sessionStorage 신규 쓰기 가로채기
// - cookieInterceptor: document.cookie 신규 쓰기 가로채기
// 모두 strictly necessary allowlist (코드 상수) 외 미동의 + 비-사용자 쓰기는 차단.
installUserInitiatedTracker();
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
installCookieInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
}
// 1. 공개 설정 fetch — banner_enabled / blocked_domains 의 SSoT 확정값 조회.
// G7Config 인라인은 *페이지 진입 시 스냅샷* 이라 SPA 라우트 이동 후 변경 가능성 대비.
const settings = await fetchPublicSettings();
if (!settings) {
return;
}
const bannerEnabled = settings.banner_enabled === true;
if (!bannerEnabled) {
return;
}
// 도메인 기반 차단 목록 주입 (blocker.ts 백업 라인) — 운영자 입력 도메인만 사용.
const customDomains = (settings.blocked_domains as Record<string, string[]> | undefined) ?? {};
setBlockedDomains(customDomains);
// preblocker 카탈로그도 보강 (인라인 페이로드와 응답 일치성 확인 + 누락 시 보강)
const preblockerConfig: PreblockerConfig = {
blockedDomains: customDomains,
consent: { necessary: true },
};
updatePreblockerConfig(preblockerConfig);
startBlocker();
// 2. 현재 동의 상태 페치 후 두 엔진 + 인터셉터 동기화 + 복원
// needs_renewal=true 면 옛 동의가 신정책에 그대로 쓰이는 상태 → 복원 안 함 (보수적 차단 유지)
const snapshot = await fetchConsentSnapshot();
const functionalConsented =
snapshot !== null && !snapshot.needs_renewal && snapshot.categories.functional === true;
// Phase 2: storage / cookie 인터셉터 설정 갱신 (functional 동의 여부만 반영).
updateStorageInterceptorConfig({
functionalConsented,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
updateCookieInterceptorConfig({
functionalConsented,
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
});
// Phase 2: 부팅 시점 functional 미동의면 allowlist 외 모든 storage/cookie 파기 (EDPB §117).
// - 재방문 시 이전 세션의 잔류 데이터 정리
// - 신규 게스트 / 거부 후 재방문 모두 동일 처리
if (!functionalConsented) {
cleanupFunctionalArtifacts();
}
if (snapshot !== null) {
setCurrentConsent({ categories: snapshot.categories, needs_renewal: snapshot.needs_renewal });
updatePreblockerConfig({
blockedDomains: customDomains,
consent: snapshot.categories,
needs_renewal: snapshot.needs_renewal,
});
if (!snapshot.needs_renewal) {
for (const [category, allowed] of Object.entries(snapshot.categories)) {
if (allowed === true && category !== 'necessary') {
restorePreblockedCategory(category);
}
}
}
}
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
void bootstrap();
});
} else {
void bootstrap();
}
}
@@ -0,0 +1,151 @@
/**
* GDPR 레이아웃 확장 부트스트랩
*
* G7 코어가 `resources/extensions/*.json` 파일들을 자동 발견·등록하므로
* 별도의 명시적 등록 코드는 필요 없다. 본 모듈은:
*
* 1. 페이지 로드 직후 `/plugins/sirsoft-gdpr/settings` 호출하여 banner_enabled 토글 확인
* 2. `/plugins/sirsoft-gdpr/consent/cookie/status` 호출하여 카테고리별 동의 상태 페치
* (회원/게스트 통합 — 인증 불필요. 회원은 sanctum 토큰이 있으면 자동 적용)
*
* banner_enabled 가 단일 토글 (쿠키 배너 노출) — ON 시 배너 노출 + 동의 전 외부 추적
* 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성. 차단 별도 토글 없음 (위반 조합 구조적 차단).
*
* 마이페이지 동의 카드(F-04)는 GDPR Art.7(3) 대칭성 의무에 따라 회원에게 동의 데이터가 있을
* 때만 노출 (빈 카드 노출 방지). 가드 조건은 `mypage_privacy_tab.json` 의 `if` 속성에 정의.
*
* G7Core.api 가 로드된 환경에서는 G7Core.api.get() 사용 (auth/devtools/locale 헤더 자동),
* 미로드 환경(부트 초기 등)에서는 fetch 로 fallback.
*
* @module sirsoft-gdpr/layouts
*/
export interface GdprPublicSettings {
cookie_policy_version: string;
/**
* 쿠키 배너 노출 단일 토글. ON 시 배너 + 자동 차단 + 마이페이지 카드 일괄 활성.
*/
banner_enabled: boolean;
/**
* 카테고리 → 차단 도메인 패턴 배열 (정확 매칭 또는 `*.example.com` 와일드카드).
* 게스트도 차단 동작해야 하므로 공개 응답에 포함.
*/
blocked_domains?: Record<string, string[]>;
/**
* 기본 카탈로그(GA/GTM/Meta Pixel 등) 자동 적용 토글. default OFF.
*/
blocked_domains_default_catalog?: boolean;
[key: string]: unknown;
}
interface G7ApiClient {
get: <T = unknown>(url: string) => Promise<T>;
}
let cachedSettings: GdprPublicSettings | null = null;
/**
* G7Core.api 클라이언트 반환 (없으면 null).
*
* @return G7ApiClient | null
*/
function resolveG7Api(): G7ApiClient | null {
if (typeof window === 'undefined') return null;
const g7Core = (window as unknown as { G7Core?: { api?: G7ApiClient } }).G7Core;
const api = g7Core?.api;
if (api && typeof api.get === 'function') return api;
return null;
}
/**
* G7Core.api 또는 fetch 로 GET 호출.
*
* @param path API 경로 (G7Core.api 는 `/api` 자동 prefix, fetch 는 `/api/...` 명시)
* @return JSON 데이터 또는 null
*/
async function getJson<T>(path: string): Promise<T | null> {
const api = resolveG7Api();
if (api) {
try {
return await api.get<T>(path);
} catch {
return null;
}
}
// Fallback: G7Core 미로드 (부트 초기) — fetch 직접 사용
try {
const response = await fetch('/api' + path, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
/**
* 공개 설정 페치 + 캐시.
*
* @return GdprPublicSettings
*/
export async function fetchPublicSettings(): Promise<GdprPublicSettings | null> {
if (cachedSettings) return cachedSettings;
const json = await getJson<{ data?: GdprPublicSettings }>('/plugins/sirsoft-gdpr/settings');
const data = json?.data;
if (!data) return null;
cachedSettings = data;
return data;
}
/**
* 현재 방문자(회원/게스트)의 카테고리별 쿠키 동의 상태를 페치합니다.
*
* 자동 차단 엔진(blocker) 의 초기 캐시 주입용. 응답 키는 cookie_ 접두사가 제거된
* 카테고리 키 (예: 'analytics', 'marketing'). 정책 버전이 일치하지 않거나 동의가
* 없으면 빈 객체 반환.
*
* needs_renewal=true 면 옛 정책 동의가 신정책에 그대로 쓰이는 상태 — 차단 엔진이
* 필수 외 모든 카테고리 강제 false 처리 (보수적 차단, GDPR Art.6 강화).
* current_policy_version 은 배너 사유 안내문 ("버전 X 로 변경") 용.
*
* 인증 불필요 — 회원이 sanctum 토큰을 가지고 있으면 자동으로 user_id 기반 응답.
*
* @return 동의 스냅샷 (categories + needs_renewal + current_policy_version), 또는 페치 실패 시 null
*/
export interface FetchedConsentSnapshot {
categories: Record<string, boolean>;
needs_renewal: boolean;
current_policy_version: string;
}
export async function fetchConsentSnapshot(): Promise<FetchedConsentSnapshot | null> {
const json = await getJson<{
data?: {
consents?: Record<string, boolean>;
needs_renewal?: boolean;
current_policy_version?: string;
};
}>('/plugins/sirsoft-gdpr/consent/cookie/status');
const consents = json?.data?.consents;
if (consents === undefined || consents === null) return null;
return {
categories: consents,
needs_renewal: json?.data?.needs_renewal === true,
current_policy_version: String(json?.data?.current_policy_version ?? ''),
};
}
/**
* 캐시 클리어 — 테스트용.
*/
export function clearSettingsCache(): void {
cachedSettings = null;
}
@@ -0,0 +1,223 @@
/**
* GDPR 사전 차단 엔진 (preblocker)
*
* GDPR 플러그인 IIFE 가 로드된 직후 즉시 `HTMLScriptElement.prototype.src` setter 를
* 가로채는 1차 차단 라인. 운영자가 banner_enabled=true 일 때 settings API 응답을
* 기다리지 않고 보수적 차단 (모든 비-necessary 차단) 으로 진입하여 race condition
* (settings 응답 지연 동안 추적 스크립트가 외부 로드 완료되는 회귀) 을 막는다.
*
* 차단 우선순위:
* 1. data-gdpr-category 속성 (운영자 명시) — origin 무관, 자체 호스팅 추적 지원
* 2. 외부 origin + 카탈로그 매칭 — 도메인 기반 (categorizeUrl 재사용)
* 3. 그 외 — 통과 (동일 origin 자기 차단 방지, 카탈로그 미매칭 등)
*
* 차단 시 src 설정을 무시하고 다음 속성을 element 에 보존:
* - data-gdpr-blocked-src: 원본 URL
* - data-gdpr-blocked-category: 매칭된 카테고리
*
* 동의 변경 시 `updatePreblockerConfig` + `restorePreblockedCategory` 호출로
* 새 <script> 요소를 만들어 교체하여 정상 로드 흐름으로 진입.
*
* 본 모듈은 `<script src>` 만 가로챈다. `<iframe src>`, `<img src>`, `<link href>` 는
* 안전성 (이미지/스타일 영향 회피) 을 위해 MutationObserver 백업 라인 (blocker.ts) 에
* 위임한다.
*
* @module sirsoft-gdpr/preblocker
*/
import { categorizeUrl, matchesDomain } from './blocker-domains';
/**
* preblocker 동작 설정.
*
* @property blockedDomains 카테고리 → 도메인 패턴 배열 (운영자 입력 + 카탈로그 합)
* @property consent 카테고리 → 동의 여부 ({ necessary: true, analytics: false, ... })
* @property needs_renewal 옛 정책 동의가 신정책에 그대로 쓰이는 상태 (true 면 필수 외 강제 차단)
*/
export interface PreblockerConfig {
blockedDomains: Record<string, readonly string[]>;
consent?: Record<string, boolean>;
needs_renewal?: boolean;
}
const BLOCKED_SRC_ATTR = 'data-gdpr-blocked-src';
const BLOCKED_CATEGORY_ATTR = 'data-gdpr-blocked-category';
const CATEGORY_ATTR = 'data-gdpr-category';
let installed = false;
let originalSrcDescriptor: PropertyDescriptor | null = null;
let config: PreblockerConfig = { blockedDomains: {}, consent: { necessary: true }, needs_renewal: false };
/**
* 카테고리 허용 여부.
*
* necessary 카테고리는 동의 무관 항상 허용 (ePrivacy Art.5(3) 면제).
* needs_renewal=true (옛 동의가 신정책에 그대로 쓰이는 상태) 면 필수 외 모든 카테고리 강제 차단
* — 사용자가 의사 표명할 때까지 보수적 차단 (GDPR Art.6 강화).
*
* @param category 카테고리 키
* @return 허용 여부
*/
function isAllowed(category: string): boolean {
if (category === 'necessary') return true;
if (config.needs_renewal === true) return false;
return config.consent?.[category] === true;
}
/**
* <script> 요소의 차단 분기 판정.
*
* 1. data-gdpr-category 속성 (운영자 명시) — 우선
* 2. 외부 origin + 카탈로그 매칭 — categorizeUrl 위임
*
* @param el script 요소
* @param value 설정 시도된 src 값
* @return 차단해야 할 카테고리, 차단 불필요면 null
*/
function decideBlock(el: HTMLScriptElement, value: string): string | null {
// 1. 속성 기반 (운영자 명시 우선)
const attrCategory = el.getAttribute(CATEGORY_ATTR);
if (attrCategory !== null && attrCategory !== '') {
if (isAllowed(attrCategory)) return null;
return attrCategory;
}
// 2. 도메인 기반 — categorizeUrl 이 동일 origin / 비-HTTP / necessary skip 처리
const domainCategory = categorizeUrl(value, config.blockedDomains);
if (domainCategory === null) return null;
if (isAllowed(domainCategory)) return null;
return domainCategory;
}
/**
* HTMLScriptElement.prototype.src setter 를 가로채는 사전 차단을 설치합니다.
*
* 이미 설치된 경우 config 만 갱신하고 재설치 안 함.
*
* @param initialConfig 초기 설정 (blockedDomains 필수, consent 미지정 시 necessary 만 허용)
*/
export function installPreblocker(initialConfig: PreblockerConfig): void {
config = {
blockedDomains: initialConfig.blockedDomains ?? {},
consent: initialConfig.consent ?? { necessary: true },
needs_renewal: initialConfig.needs_renewal === true,
};
if (installed) return;
if (typeof HTMLScriptElement === 'undefined') return;
const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
if (descriptor === undefined || typeof descriptor.set !== 'function') return;
originalSrcDescriptor = descriptor;
Object.defineProperty(HTMLScriptElement.prototype, 'src', {
configurable: true,
enumerable: descriptor.enumerable ?? true,
get(this: HTMLScriptElement): string {
return descriptor.get?.call(this) ?? '';
},
set(this: HTMLScriptElement, value: string): void {
const category = decideBlock(this, value);
if (category !== null) {
this.setAttribute(BLOCKED_SRC_ATTR, value);
this.setAttribute(BLOCKED_CATEGORY_ATTR, category);
return;
}
descriptor.set!.call(this, value);
},
});
installed = true;
}
/**
* preblocker 의 카탈로그/동의 상태를 갱신합니다.
*
* settings API 응답 도착 또는 사용자 동의 변경 시 호출.
* 신규 차단 판정에 즉시 반영. 이미 차단된 요소는 restorePreblockedCategory 로 별도 복원.
*
* @param next 새 설정 (부분 갱신 안 함 — 전체 교체)
*/
export function updatePreblockerConfig(next: PreblockerConfig): void {
config = {
blockedDomains: next.blockedDomains ?? {},
consent: next.consent ?? { necessary: true },
needs_renewal: next.needs_renewal === true,
};
}
/**
* 동의 받은 카테고리의 차단된 <script> 요소를 새로 만들어 교체합니다.
*
* 원본 element 는 src 가 비어있는 상태로 보존되어 있으므로, 동일 속성을 가진
* 새 <script> 를 만들어 같은 위치에 삽입한다. 새 요소의 src 할당 시점에 setter 가
* 다시 호출되어 도메인 매칭하지만, updatePreblockerConfig 로 동의가 부여된
* 상태라 통과한다.
*
* 동의가 부여되지 않은 카테고리에 대해 호출 시 동작 안 함.
*
* @param category 복원할 카테고리
*/
export function restorePreblockedCategory(category: string): void {
if (!isAllowed(category)) return;
if (typeof document === 'undefined') return;
const blocked = document.querySelectorAll<HTMLScriptElement>(
`script[${BLOCKED_CATEGORY_ATTR}="${category}"]`,
);
blocked.forEach((original) => {
const src = original.getAttribute(BLOCKED_SRC_ATTR);
if (src === null || src === '') {
return;
}
const fresh = document.createElement('script');
// 원본의 기타 속성 복제 (id, async, defer, type, crossorigin, data-* 등)
for (const attr of Array.from(original.attributes)) {
if (
attr.name === BLOCKED_SRC_ATTR ||
attr.name === BLOCKED_CATEGORY_ATTR ||
attr.name === 'src'
) {
continue;
}
fresh.setAttribute(attr.name, attr.value);
}
const parent = original.parentNode;
if (parent === null) {
return;
}
parent.replaceChild(fresh, original);
// src 할당 — 동의 부여 상태라 setter 통과
fresh.src = src;
});
}
/**
* preblocker 를 제거하여 prototype 을 원상 복구합니다.
*
* 테스트 격리 / SPA 라우트 정리 / 운영자 비활성화 시 호출.
*/
export function uninstallPreblocker(): void {
if (!installed) return;
if (originalSrcDescriptor !== null) {
Object.defineProperty(HTMLScriptElement.prototype, 'src', originalSrcDescriptor);
originalSrcDescriptor = null;
}
installed = false;
config = { blockedDomains: {}, consent: { necessary: true } };
}
/**
* 도메인 매칭 단일 함수 노출 — 외부 디버깅용.
*
* @param host 검사 호스트
* @param pattern 차단 패턴
* @return 매칭 여부
*/
export { matchesDomain };
@@ -0,0 +1,208 @@
/**
* GDPR 1st-party Storage 가로채기 (storageInterceptor)
*
* `Storage.prototype.setItem` 을 가로채 functional 카테고리 미동의 상태에서
* 신규 localStorage / sessionStorage 쓰기를 차단. EDPB Guidelines 2/2023 §16
* "동의 전 사전 차단 (prior consent before storage)" 충족.
*
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
* 1. strictly necessary allowlist 매칭 → 항상 허용
* (XSRF-TOKEN, g7_locale, auth_token, g7_cart_key, g7_cache_version,
* g7-devtools-panel, g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*)
* 2. functional 동의 → 허용
* 3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
* 4. 그 외 → 차단
*
* "운영자 등록 표" 는 제거됨 — GDPR 원칙은 "strictly necessary 외 비-필수 저장은
* 동의 전 차단" 이므로 등록 표 없이 동일하게 모두 게이팅.
*
* window.localStorage.setItem 과 window.sessionStorage.setItem 은 둘 다
* Storage.prototype.setItem 을 공유하므로 1회 가로채기로 양쪽 모두 커버.
*
* @module sirsoft-gdpr/storageInterceptor
*/
import { isUserInitiated } from './userInitiatedTracker';
/**
* Storage 종류 — strictly necessary 판정 시 storage 타입 매칭에 사용.
*/
export type StorageKind = 'localStorage' | 'sessionStorage';
/**
* strictly necessary allowlist 항목.
*
* @property key 정확 매칭 또는 prefix
* @property storage 적용할 스토리지 (생략 시 둘 다)
* @property matchType 'exact' (기본) 또는 'prefix'
*/
export interface NecessaryAllowlistEntry {
key: string;
storage?: StorageKind;
matchType?: 'exact' | 'prefix';
}
/**
* 인터셉터 설정.
*
* @property functionalConsented functional 카테고리 동의 여부
* @property necessaryAllowlist strictly necessary 면제 키 (코어 + 정적)
*/
export interface StorageInterceptorConfig {
functionalConsented: boolean;
necessaryAllowlist: readonly NecessaryAllowlistEntry[];
}
/**
* 정적 strictly necessary allowlist — G7 코어 동작에 필수인 키 + WP29 §3.6 면제 키.
*
* 본 목록은 G7 코어가 정상 동작하는 데 반드시 필요한 키만 포함. 운영자가 추가 등록 불가
* (코드 상수). 운영자가 추가하려면 본 파일 수정 + PR 필요.
*
* prefix 매칭: g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*
* exact 매칭: g7_locale, auth_token, g7_cache_version, g7_cart_key, g7-devtools-panel
*/
export const DEFAULT_NECESSARY_ALLOWLIST: readonly NecessaryAllowlistEntry[] = [
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정
{ key: 'g7_locale', storage: 'localStorage', matchType: 'exact' },
// 인증 토큰 — 로그인 유지 필수 (strictly necessary, Art.6(1)(b))
{ key: 'auth_token', storage: 'localStorage', matchType: 'exact' },
// 코어 캐시 버전 — 운영자가 의도적 갱신 시 사용
{ key: 'g7_cache_version', storage: 'localStorage', matchType: 'exact' },
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
{ key: 'g7_cart_key', storage: 'localStorage', matchType: 'exact' },
// devtools UI 상태 — 개발자 환경 (strictly necessary 개발자 도구)
{ key: 'g7-devtools-panel', storage: 'localStorage', matchType: 'exact' },
// 관리자 페이지 상태 (필터/정렬/컬럼/devtools) — 사용자 의사로 조작
{ key: 'g7_devtools_', matchType: 'prefix' },
{ key: 'g7_filters_', matchType: 'prefix' },
{ key: 'g7_columns_', matchType: 'prefix' },
{ key: 'g7_order_', matchType: 'prefix' },
];
let installed = false;
let originalSetItem: ((key: string, value: string) => void) | null = null;
let config: StorageInterceptorConfig = {
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
};
/**
* 키가 strictly necessary allowlist 에 매칭되는지 검사합니다.
*
* storage 인자는 호출된 storage 종류 (localStorage / sessionStorage). allowlist 엔트리에
* storage 가 명시되어 있으면 정확히 일치할 때만 매칭. 미명시면 둘 다 허용.
*
* @param key 스토리지 키
* @param storage 호출 스토리지 종류
* @return 매칭 여부
*/
function matchesNecessary(key: string, storage: StorageKind): boolean {
for (const entry of config.necessaryAllowlist) {
if (entry.storage && entry.storage !== storage) {
continue;
}
const matchType = entry.matchType ?? 'exact';
if (matchType === 'exact' && entry.key === key) {
return true;
}
if (matchType === 'prefix' && key.startsWith(entry.key)) {
return true;
}
}
return false;
}
/**
* setItem 호출이 허용되는지 판정합니다.
*
* 본 함수는 사이드 이펙트 없음 — 정책 평가만. 차단/통과 결정은 호출자가 수행.
*
* 게이팅 규칙 (4단계):
* 1. strictly necessary → 허용
* 2. functional 동의 → 허용
* 3. user-initiated (WP29 §3.6, 항상 활성) → 허용
* 4. 그 외 → 차단
*
* @param key 스토리지 키
* @param storage 호출 스토리지 종류
* @return 허용 여부
*/
export function isStorageAllowed(key: string, storage: StorageKind): boolean {
if (matchesNecessary(key, storage)) {
return true;
}
if (config.functionalConsented) {
return true;
}
// user-initiated 면제 (WP29 §3.6) — 사용자가 직접 트리거한 일회성 설정은 동의 없이 허용.
// GDPR 표준 면제로 항상 활성 (운영자 토글 없음).
if (isUserInitiated()) {
return true;
}
return false;
}
/**
* 인터셉터를 설치합니다 — Storage.prototype.setItem 을 가로채기.
*
* 중복 install 방지: installed 플래그.
*
* @param initialConfig 초기 설정 (이후 updateStorageInterceptorConfig 로 갱신)
* @return void
*/
export function installStorageInterceptor(initialConfig: StorageInterceptorConfig): void {
if (installed) {
return;
}
installed = true;
config = initialConfig;
const proto = Storage.prototype;
originalSetItem = proto.setItem;
proto.setItem = function (this: Storage, key: string, value: string): void {
const storage: StorageKind = this === window.sessionStorage ? 'sessionStorage' : 'localStorage';
if (!isStorageAllowed(key, storage)) {
return;
}
if (originalSetItem) {
originalSetItem.call(this, key, value);
}
};
}
/**
* 인터셉터 설정을 갱신합니다 — 동의 변경 시 호출.
*
* @param newConfig 갱신할 설정
* @return void
*/
export function updateStorageInterceptorConfig(newConfig: StorageInterceptorConfig): void {
config = newConfig;
}
/**
* 인터셉터를 해제합니다 (테스트 격리 / cleanup 용).
*
* 원본 setItem 을 복원하고 installed 플래그를 리셋.
*
* @return void
*/
export function uninstallStorageInterceptor(): void {
if (!installed) {
return;
}
if (originalSetItem) {
Storage.prototype.setItem = originalSetItem;
}
originalSetItem = null;
config = {
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
};
installed = false;
}
@@ -0,0 +1,136 @@
/**
* GDPR 사용자 의사 표명 추적기 (userInitiatedTracker)
*
* 마지막 사용자 인터랙션 (click / keydown / change / submit) 시각을 기록하여
* 직후 발생하는 storage/cookie 쓰기가 "사용자 의사에 의한 것" 인지 판정.
*
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
* 트리거한 설정 저장 (다크모드 토글, 통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
*
* 판정 기준:
* - 마지막 인터랙션 시각으로부터 USER_INITIATED_THRESHOLD_MS 이내 → user-initiated
* - window.event 로 현재 이벤트 객체가 살아있고 isTrusted=true → user-initiated 폴백
*
* 보수적 윈도우 (500ms) — Page Visibility / setTimeout 으로 지연된 storage 쓰기는
* 비-사용자 트리거로 분류하여 functional_allow_user_initiated=false 와 동일 처리.
*
* 본 모듈은 install 시 capture-phase 리스너를 1회 등록. blocker / interceptor
* 보다 먼저 install 되어야 정확한 타임스탬프를 잡을 수 있음.
*
* @module sirsoft-gdpr/userInitiatedTracker
*/
/**
* "사용자 의사" 로 인정할 인터랙션 후 경과 시간 임계값 (ms).
*
* 너무 짧으면: framework 의 micro-task / re-render 후 setItem 누락
* 너무 길면: 인터랙션과 무관한 background 쓰기까지 통과
*
* 500ms 는 일반적 SPA 의 click → setState → effect → localStorage 체인을 커버.
*/
export const USER_INITIATED_THRESHOLD_MS = 500;
const TRACKED_EVENTS: readonly (keyof WindowEventMap)[] = [
'click',
'keydown',
'change',
'submit',
'pointerdown',
'touchstart',
];
let installed = false;
let lastInteractionTimestamp = 0;
const listeners: Array<{ type: keyof WindowEventMap; handler: EventListener }> = [];
/**
* 인터랙션 핸들러 — isTrusted=true 만 인정 (스크립트가 dispatchEvent 로 위장하는 케이스 차단).
*
* @param event 이벤트
*/
function handleInteraction(event: Event): void {
if (!event.isTrusted) {
return;
}
lastInteractionTimestamp = Date.now();
}
/**
* 추적기를 설치합니다.
*
* - 중복 install 방지: installed 플래그
* - capture phase 등록 — addEventListener(type, handler, true)
* - passive: true — 스크롤 / 터치 성능 보존 (preventDefault 불필요)
*
* @return void
*/
export function installUserInitiatedTracker(): void {
if (installed) {
return;
}
installed = true;
for (const type of TRACKED_EVENTS) {
const handler = handleInteraction as EventListener;
window.addEventListener(type, handler, { capture: true, passive: true });
listeners.push({ type, handler });
}
}
/**
* 추적기를 해제합니다. (테스트 격리 / cleanup 용)
*
* @return void
*/
export function uninstallUserInitiatedTracker(): void {
if (!installed) {
return;
}
for (const { type, handler } of listeners) {
window.removeEventListener(type, handler, { capture: true } as EventListenerOptions);
}
listeners.length = 0;
lastInteractionTimestamp = 0;
installed = false;
}
/**
* 현재 호출이 "사용자 의사" 인지 판정합니다.
*
* 판정 우선순위:
* 1. window.event 가 살아있고 isTrusted=true → true (가장 강력한 신호)
* 2. lastInteractionTimestamp 가 임계값 이내 → true
* 3. 그 외 → false
*
* window.event 는 legacy 이나 동기 호출 스택에서는 여전히 신뢰 가능 — Chrome / Firefox / Safari
* 모두 지원. micro-task 이후엔 null 이 되므로 timestamp 백업이 필수.
*
* @return 사용자 의사 여부
*/
export function isUserInitiated(): boolean {
// window.event 폴백 — 동기 호출 스택에서만 유효 (micro-task 이후 null).
const currentEvent = (window as Window & { event?: Event }).event;
if (currentEvent && currentEvent.isTrusted) {
return true;
}
if (lastInteractionTimestamp === 0) {
return false;
}
return Date.now() - lastInteractionTimestamp <= USER_INITIATED_THRESHOLD_MS;
}
/**
* 테스트 헬퍼 — 마지막 인터랙션 시각을 강제 설정.
*
* 프로덕션 코드에선 사용하지 않음. __tests__ 에서만 import.
*
* @param timestamp 설정할 ms epoch (0 은 미발생 상태)
* @return void
*/
export function __setLastInteractionForTest(timestamp: number): void {
lastInteractionTimestamp = timestamp;
}
@@ -0,0 +1,310 @@
{
"editor": {
"data_source": {
"gdprConsentLog": "Consent Log",
"gdprMeConsents": "My Consents",
"gdprMyConsent": "My Consent",
"gdprMyConsents": "My Consents",
"gdprPolicyVersionCurrent": "Current Policy Version",
"gdprPolicyVersionHistory": "Policy Version History",
"gdprPolicyVersionSnapshot": "Policy Version Snapshot",
"gdprPublicSettings": "GDPR Public Settings",
"gdprSettings": "GDPR Settings"
}
},
"consent": {
"granted": "Consent has been saved.",
"granted_again": "Consent has been renewed.",
"revoked": "Consent has been revoked.",
"renew_all_success": "{renewed} active consent item(s) renewed to the new policy version.",
"granting": "Granting...",
"revoking": "Revoking...",
"renewing": "Renewing...",
"already_same_state": "Already in the same state.",
"required_cannot_revoke": "Required consent cannot be revoked.",
"invalid_key": "Invalid consent key.",
"category_cookie_necessary": "Strictly Necessary",
"category_cookie_functional": "Functional",
"category_cookie_analytics": "Analytics",
"category_cookie_marketing": "Marketing"
},
"blocked_domains": {
"invalid_format": "Invalid domain format. (FQDN only; *.example.com wildcard supported)",
"invalid_format_functional": "Functional — Invalid domain format. (FQDN only; *.example.com wildcard supported)",
"invalid_format_analytics": "Analytics — Invalid domain format. (FQDN only; *.example.com wildcard supported)",
"invalid_format_marketing": "Marketing — Invalid domain format. (FQDN only; *.example.com wildcard supported)",
"too_long": "Domain cannot exceed 253 characters.",
"too_long_functional": "Functional — Domain cannot exceed 253 characters.",
"too_long_analytics": "Analytics — Domain cannot exceed 253 characters.",
"too_long_marketing": "Marketing — Domain cannot exceed 253 characters.",
"must_be_array": "Each category must be an array of domain strings."
},
"settings": {
"saved": "Settings have been saved.",
"title": "GDPR Settings",
"description": "Operators control the cookie consent banner and the MyPage privacy card.",
"section": {
"operator": "Operator \/ Privacy Policy",
"operator_desc": "Register the operator name, data storage location, and privacy policy page slug shown in the cookie banner and policy page.",
"cookie_banner": "Cookie Consent Banner",
"cookie_banner_desc": "Configure visibility, position, categories, and policy version of the cookie consent banner — all in one place.",
"cookie_banner_settings": "Banner Display Settings",
"cookie_categories": "Cookie Categories",
"cookie_policy_version": "Cookie Policy Version",
"auto_blocking": "Auto-blocking Policy",
"auto_blocking_desc": "When the cookie banner is shown, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents."
},
"nav": {
"operator": "Operator",
"cookie_banner": "Cookie Banner",
"auto_blocking_policy": "Auto-blocking Policy"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"visible": "Visible",
"hidden": "Hidden"
},
"fields": {
"cookie_policy_version": {
"label": "Policy Version",
"hint": "When the policy body, category semantics, or operator/retention information changes, click \"+ Publish new version\". On publish, all members will re-consent on their next visit."
},
"privacy_policy_slug": {
"label": "Privacy Policy Page Slug",
"hint": "Slug of the privacy policy page registered in the Page plugin. If empty, the policy link in the cookie banner is hidden.",
"placeholder": "e.g., privacy-policy"
},
"legal_entity_name": {
"label": "Legal Entity \/ Operator Name",
"hint": "The operator name shown in the cookie banner and MyPage.",
"placeholder": "e.g., Acme Corp."
},
"data_storage_location": {
"label": "Data Storage Location",
"hint": "Country where user data is stored, shown to users. e.g., \"United States (AWS)\", \"Germany\". Do not enter IP addresses, cloud region codes (e.g., ap-northeast-2), or datacenter addresses — these are security-sensitive.",
"placeholder": "e.g., Korea (self-hosted IDC) \/ United States (AWS)"
},
"banner_enabled": {
"label": "Show Cookie Banner",
"hint": "When enabled, the cookie banner, pre-consent auto-blocking, and the MyPage consent management card are activated together."
},
"banner_position": {
"label": "Banner Position",
"hint": "Choose where the cookie banner appears on screen.",
"option_bottom_bar": "Bottom bar (full width)",
"option_bottom_left_popup": "Bottom-left popup",
"option_bottom_right_popup": "Bottom-right popup",
"option_centered_modal": "Centered modal"
},
"blocked_domains": {
"warnings_title": "Notes",
"auto_blocking_off_hint": "Auto-blocking is only active when the Cookie Banner is ON. Enable Show Cookie Banner in the Cookie Banner card first.",
"warning_visual_break": "Be careful when classifying external font/stylesheet domains. Misclassification can break the page silently.",
"warning_policy_bump": "Bump the cookie policy version after changing the blocked domain list. This prevents existing consenters from being implicitly enrolled into newly added trackers.",
"warning_domain_format": "Domain format: example.com, *.example.com (wildcard) are supported. Names without dots (like localhost) and non-ASCII domains are not supported.",
"tag_input_placeholder": "Type a domain and press Enter (e.g., google-analytics.com, *.hotjar.com)",
"tag_no_options": "No suggestions. Type to add.",
"category": {
"functional": "Functional — Blocked Domains",
"analytics": "Analytics — Blocked Domains",
"marketing": "Marketing — Blocked Domains"
}
},
"cookie_categories": {
"description": "Open each card's [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.",
"category_required_badge": "Required",
"category_optional_badge": "Optional",
"info": {
"expand": "Show details",
"collapse": "Hide details",
"description_label": "User-facing description",
"scope_label": "Auto-blocking scope",
"tools_label": "Common tools",
"necessary": {
"scope": "Not auto-blocked. Session/auth tokens, shopping basket identifier, user-selected language preference at registration, and cookie consent records are essential to site operation and always allowed.",
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, etc. (no configuration required)"
},
"functional": {
"scope": "External resources matching the functional blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. User preferences such as dark mode and currency selection are also persisted only after consent.",
"tools": "Customer support chatbots (Crisp, Intercom, Tawk.to), translation widgets, user-preference synchronization services, etc."
},
"analytics": {
"scope": "External analytics scripts, embeds, and tracking pixels matching the analytics blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. Resources are restored immediately on consent.",
"tools": "Google Analytics 4 (GA4), Hotjar, Microsoft Clarity, Mixpanel, Matomo, Naver Premium Log Analysis, etc."
},
"marketing": {
"scope": "External advertising, embeds, and tracking pixels matching the marketing blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. Resources are restored immediately on consent.",
"tools": "Meta Pixel (Facebook), Google Ads remarketing, Kakao Pixel, YouTube video embeds, etc."
}
}
}
},
"save_success": "Settings have been saved.",
"save_error": "An error occurred while saving settings.",
"validation_error": "Please check your inputs.",
"unsaved_changes": "You have unsaved changes.",
"policy_version": {
"current_label": "Current version",
"published_at_label": "Published at",
"publisher_label": "Publisher",
"publisher_system": "System",
"memo_label": "Change reason",
"publish_button": "Publish new version",
"history_toggle_show": "View history",
"history_toggle_hide": "Hide history",
"history_empty": "No policy versions have been published yet.",
"publish_success": "A new policy version has been published. All members will be prompted to re-consent on their next visit.",
"publishing": "Publishing...",
"guide_title": "When to publish a new version",
"guide_body": "Routine operational changes such as editing blocked domains or tweaking UI labels do not require publishing a new version.",
"change_type": {
"material": "Triggers re-consent",
"non_material": "Minor change",
"initial": "Initial publication"
},
"history": {
"col": {
"version": "Version",
"created_at": "Published at",
"publisher": "Publisher",
"change_type": "Type",
"memo": "Reason",
"snapshot_action": "Snapshot"
}
},
"snapshot_view_short": "View",
"material_modal": {
"title": "Publish new policy version + trigger re-consent",
"body": "Clicking \"Publish new version\" publishes immediately, and all users must re-consent on their next visit.",
"effect_bump": "Policy version monotonic increment (e.g., v2 → v3)",
"effect_reconsent": "All members will see the consent screen on their next visit",
"effect_history": "A new version row will be added to the history table",
"memo_label": "Change reason (required)",
"memo_placeholder": "e.g., Privacy policy body update / Operator change",
"confirm": "Publish new version"
},
"snapshot_modal": {
"editor_label": "View policy version snapshot",
"title": "Policy version v{version} snapshot",
"description": "Full snapshot of settings at publication time — categories, privacy policy slug, blocked domains. Used by DPO as evidence in consent disputes.",
"section_categories": "Cookie categories",
"section_privacy_policy": "Privacy policy slug",
"section_blocked_domains": "Blocked domain catalog",
"category_required_badge": "Required",
"no_categories": "No categories registered.",
"no_blocked_domains": "No blocked domains registered.",
"view_button": "View v{version} snapshot",
"close": "Close",
"open_policy_page": "Open policy page",
"no_policy_slug": "No privacy policy page registered."
},
"not_found": "Policy version not found."
}
},
"admin": {
"consent_log": {
"title": "GDPR Consent Log",
"description": "Audit consent and revocation history of members and guests. IP and User-Agent are included.",
"refresh": "Refresh",
"empty": "No consent log entries found.",
"clear_filters": "Clear filters",
"guest_label": "Guest",
"view_member_tooltip": "Open member detail page",
"search_placeholder": "Search by Email or Session ID",
"search_button": "Search",
"search_type": {
"email": "Email",
"session_id": "Session ID"
},
"filter": {
"consent_key_label": "Consent Key",
"action_label": "Action",
"source_label": "Source",
"option_all": "All"
},
"action": {
"granted": "Granted",
"revoked": "Revoked"
},
"source": {
"banner": "Banner",
"preference_center": "Preferences",
"mypage": "MyPage"
},
"col": {
"created_at": "Time",
"subject": "Subject",
"consent_key": "Consent Key",
"action": "Action",
"source": "Source",
"policy_version": "Policy Version",
"ip": "IP"
},
"detail": {
"session_id_label": "Session ID",
"user_agent_label": "User-Agent",
"categories_label": "Categories Snapshot",
"categories_hint": "The member's category-by-category choices at the time of consent. This record stays immutable even after the policy is updated.",
"snapshot_granted": "Granted",
"snapshot_revoked": "Revoked"
}
}
},
"mypage": {
"privacy": {
"title": "Cookie Consent",
"description": "Change or revoke your consent for the cookies this site uses.",
"section_consents": "My Consents",
"section_consents_hint": "Changes apply immediately. Required items cannot be revoked.",
"col_consent_key": "Consent",
"col_consented": "Status",
"col_consented_at": "Consented At",
"col_revoked_at": "Revoked At",
"col_policy_version": "Policy Version",
"col_action": "Action",
"consented": "Consented",
"revoked": "Revoked",
"revoke": "Revoke",
"grant": "Consent",
"grant_again": "Consent Again",
"required_label": "Required",
"required_hint": "This item is required for service operation and cannot be revoked.",
"revoke_confirm_title": "Revoke Consent",
"revoke_confirm_message": "Are you sure you want to revoke this consent? Revoking consent may limit features related to that category.",
"no_consents": "No active consent items.",
"needs_renewal": "The cookie policy has been updated. Please renew your consent.",
"needs_renewal_with_version": "The cookie policy has been updated to version {version}. Please review the optional consent items.",
"needs_renewal_hint": "Strictly necessary cookies are essential for site operation and remain unchanged regardless of policy updates.",
"btn_renew_all": "Renew Current Consent",
"btn_renew_this_item": "Renew Policy",
"renew_all_success": "Renewed {renewed} active item(s) to the new policy version.",
"operator_and_storage_meta": "This site is operated by {entity}, and data is stored in {location}.",
"operator_only_meta": "This site is operated by {entity}.",
"storage_only_meta": "User data is stored in {location}."
}
},
"common": {
"loading": "Loading...",
"refresh": "Refresh"
},
"banner": {
"re_appear_reason": "The cookie policy has been updated to version {version}. Please review your consent.",
"consent_required_hint": "Features related to declined categories may be limited.",
"btn_renew_consent": "Renew Current Consent",
"title": "Cookie Notice",
"description": "This site uses cookies to improve user experience and for analytics. You can choose which cookies to allow.",
"accept_all": "Accept All",
"reject_all": "Necessary Only",
"preferences": "Preferences",
"save": "Save Preferences",
"policy_link": "Privacy Policy",
"preferences_title": "Cookie Preferences",
"preferences_description": "You can grant or revoke each category individually. Required categories cannot be turned off.",
"category_required_badge": "Required",
"view_policy_label": "View updated policy",
"operator_and_storage_line": "This site is operated by {entity}, and data is stored in {location}.",
"operator_only_line": "This site is operated by {entity}.",
"storage_only_line": "User data is stored in {location}."
}
}
@@ -0,0 +1,310 @@
{
"editor": {
"data_source": {
"gdprConsentLog": "동의 로그",
"gdprMeConsents": "내 동의 내역",
"gdprMyConsent": "내 동의",
"gdprMyConsents": "내 동의 내역",
"gdprPolicyVersionCurrent": "현재 정책 버전",
"gdprPolicyVersionHistory": "정책 버전 이력",
"gdprPolicyVersionSnapshot": "정책 버전 스냅샷",
"gdprPublicSettings": "GDPR 공개 설정",
"gdprSettings": "GDPR 설정"
}
},
"consent": {
"granted": "동의가 저장되었습니다.",
"granted_again": "동의가 갱신되었습니다.",
"revoked": "동의가 철회되었습니다.",
"renew_all_success": "활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.",
"granting": "동의 중...",
"revoking": "철회 중...",
"renewing": "갱신 중...",
"already_same_state": "이미 동일한 상태입니다.",
"required_cannot_revoke": "필수 항목은 철회할 수 없습니다.",
"invalid_key": "유효하지 않은 동의 항목입니다.",
"category_cookie_necessary": "필수 쿠키",
"category_cookie_functional": "기능 쿠키",
"category_cookie_analytics": "분석 쿠키",
"category_cookie_marketing": "마케팅 쿠키"
},
"blocked_domains": {
"invalid_format": "올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)",
"invalid_format_functional": "기능 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)",
"invalid_format_analytics": "분석 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)",
"invalid_format_marketing": "마케팅 카테고리 — 올바른 도메인 형식이 아닙니다. (FQDN 만 지원, *.example.com 와일드카드 가능)",
"too_long": "도메인은 253자를 초과할 수 없습니다.",
"too_long_functional": "기능 카테고리 — 도메인은 253자를 초과할 수 없습니다.",
"too_long_analytics": "분석 카테고리 — 도메인은 253자를 초과할 수 없습니다.",
"too_long_marketing": "마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.",
"must_be_array": "카테고리별 도메인 목록은 배열이어야 합니다."
},
"settings": {
"saved": "설정이 저장되었습니다.",
"title": "GDPR 설정",
"description": "쿠키 동의 배너와 마이페이지 동의 관리 카드의 동작을 운영자가 제어합니다.",
"section": {
"operator": "운영 주체 \/ 개인정보처리방침",
"operator_desc": "쿠키 배너·정책 페이지에 표시될 운영 주체, 데이터 저장 위치, 정책 페이지 슬러그를 등록합니다.",
"cookie_banner": "쿠키 동의 배너",
"cookie_banner_desc": "방문자에게 표시되는 쿠키 동의 배너의 노출\/위치\/카테고리\/정책 버전을 한 곳에서 설정합니다.",
"cookie_banner_settings": "배너 표시 설정",
"cookie_categories": "쿠키 카테고리",
"cookie_policy_version": "쿠키 정책 버전",
"auto_blocking": "자동 차단 정책",
"auto_blocking_desc": "쿠키 배너가 노출되면 카테고리별 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다."
},
"nav": {
"operator": "운영 정보",
"cookie_banner": "쿠키 배너",
"auto_blocking_policy": "자동 차단 정책"
},
"status": {
"enabled": "활성",
"disabled": "비활성",
"visible": "노출",
"hidden": "숨김"
},
"fields": {
"cookie_policy_version": {
"label": "정책 버전",
"hint": "정책 본문, 카테고리 의미, 위탁자·데이터 보관 정보가 바뀌면 「+ 새 버전 발행」을 누르세요. 발행 즉시 모든 회원이 다음 방문 시 재동의를 진행합니다."
},
"privacy_policy_slug": {
"label": "개인정보처리방침 페이지 슬러그",
"hint": "페이지 플러그인에 등록된 처리방침 페이지의 슬러그. 비어 있으면 쿠키 배너의 정책 링크가 표시되지 않습니다.",
"placeholder": "예: privacy-policy"
},
"legal_entity_name": {
"label": "법인명 \/ 운영 주체",
"hint": "쿠키 배너와 마이페이지에 표시되는 사이트 운영 주체 이름입니다.",
"placeholder": "예: (주)회사명"
},
"data_storage_location": {
"label": "데이터 저장 위치",
"hint": "사용자에게 안내할 데이터 저장 국가를 입력하세요. 예: \"대한민국\", \"미국 (AWS)\". IP 주소, 클라우드 리전 코드(예: ap-northeast-2), 데이터센터 주소는 보안상 입력하지 마세요.",
"placeholder": "예: 대한민국 (자체 데이터센터) \/ 미국 (AWS)"
},
"banner_enabled": {
"label": "쿠키 배너 노출",
"hint": "활성 시 쿠키 배너 노출, 동의 전 외부 추적 자동 차단, 마이페이지 동의 관리 카드가 함께 시작됩니다."
},
"banner_position": {
"label": "배너 위치",
"hint": "쿠키 배너가 화면에 표시되는 위치를 선택합니다.",
"option_bottom_bar": "하단 (전체 폭)",
"option_bottom_left_popup": "좌하단 팝업",
"option_bottom_right_popup": "우하단 팝업",
"option_centered_modal": "중앙 모달"
},
"blocked_domains": {
"warnings_title": "안내",
"auto_blocking_off_hint": "자동 차단은 쿠키 배너 노출이 ON 일 때만 작동합니다. 「쿠키 배너」 카드에서 쿠키 배너 노출 토글을 먼저 켜세요.",
"warning_visual_break": "외부 폰트·스타일시트는 분류에 주의하세요. 잘못 차단하면 페이지가 조용히 깨질 수 있습니다.",
"warning_policy_bump": "차단 도메인을 변경한 후 쿠키 정책 버전을 올리세요. 기존 동의자가 새 도메인에도 동의한 것으로 처리되는 문제를 막습니다.",
"warning_domain_format": "도메인 형식: example.com, *.example.com (와일드카드) 가능. localhost 처럼 점이 없는 이름이나 한글 도메인은 지원하지 않습니다.",
"tag_input_placeholder": "도메인 입력 후 Enter (예: google-analytics.com, *.hotjar.com)",
"tag_no_options": "추천 도메인이 없습니다. 직접 입력하세요.",
"category": {
"functional": "기능 카테고리 차단 도메인",
"analytics": "분석 카테고리 차단 도메인",
"marketing": "마케팅 카테고리 차단 도메인"
}
},
"cookie_categories": {
"description": "쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.",
"category_required_badge": "필수",
"category_optional_badge": "선택",
"info": {
"expand": "정보 펼치기",
"collapse": "정보 접기",
"description_label": "사용자 안내 문구",
"scope_label": "자동 차단 대상",
"tools_label": "대표 도구 예시",
"necessary": {
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정 등 (별도 설정 불필요)"
},
"functional": {
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 다크모드·통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
"tools": "고객지원 챗봇 (Crisp, Intercom, Tawk.to), 다국어 자동 번역 위젯, 사용자 설정 동기화 서비스 등"
},
"analytics": {
"scope": "「자동 차단 정책」 탭의 분석 카테고리 도메인 목록에 등록된 외부 분석 스크립트·임베드·트래킹 픽셀이 동의 전까지 자동 차단됩니다. 동의하면 즉시 복원되어 작동합니다.",
"tools": "Google Analytics 4 (GA4), Hotjar, Microsoft Clarity, Mixpanel, Matomo, 네이버 프리미엄 로그분석 등"
},
"marketing": {
"scope": "「자동 차단 정책」 탭의 마케팅 카테고리 도메인 목록에 등록된 외부 광고·임베드·트래킹 픽셀이 동의 전까지 자동 차단됩니다. 동의하면 즉시 복원되어 작동합니다.",
"tools": "Meta Pixel (Facebook), Google Ads 리마케팅, 카카오 픽셀, YouTube 영상 임베드 등"
}
}
}
},
"save_success": "설정이 저장되었습니다.",
"save_error": "설정 저장 중 오류가 발생했습니다.",
"validation_error": "입력 값을 확인해주세요.",
"unsaved_changes": "저장되지 않은 변경사항이 있습니다.",
"policy_version": {
"current_label": "현재 버전",
"published_at_label": "발행 일시",
"publisher_label": "발행자",
"publisher_system": "시스템",
"memo_label": "변경 사유",
"publish_button": "새 버전 발행",
"history_toggle_show": "이력 보기",
"history_toggle_hide": "이력 닫기",
"history_empty": "발행된 정책 버전이 없습니다.",
"publish_success": "새 정책 버전이 발행되었습니다. 모든 회원이 다음 방문 시 재동의를 진행합니다.",
"publishing": "발행 중...",
"guide_title": "언제 새 버전을 발행하나요?",
"guide_body": "차단 도메인 변경, UI 문구 수정 같은 운영상 변경은 새 버전 발행이 필요하지 않습니다.",
"change_type": {
"material": "재동의 트리거",
"non_material": "단순 변경",
"initial": "초기 발행"
},
"history": {
"col": {
"version": "버전",
"created_at": "발행 일시",
"publisher": "발행자",
"change_type": "종류",
"memo": "사유",
"snapshot_action": "본문"
}
},
"snapshot_view_short": "보기",
"material_modal": {
"title": "새 정책 버전 발행 + 재동의 트리거",
"body": "「새 버전 발행」 을 누르면 즉시 발행되며, 모든 사용자가 다음 방문 시 재동의해야 합니다.",
"effect_bump": "정책 버전 단조 증가 (예: v2 → v3)",
"effect_reconsent": "모든 회원이 다음 방문 시 재동의 화면을 보게 됩니다",
"effect_history": "이력 표에 새 버전 row 가 추가됩니다",
"memo_label": "변경 사유 (필수)",
"memo_placeholder": "예: 약관 페이지 본문 수정 / 위탁 운영자 변경",
"confirm": "새 버전 발행"
},
"snapshot_modal": {
"editor_label": "정책 버전 본문 보기",
"title": "정책 버전 v{version} 본문",
"description": "발행 시점 settings 의 카테고리 목록·정책 본문 슬러그·차단 도메인 등 전체 본문입니다. DPO 가 동의 분쟁 시 입증 자료로 활용합니다.",
"section_categories": "쿠키 카테고리",
"section_privacy_policy": "정책 본문 슬러그",
"section_blocked_domains": "차단 도메인 카탈로그",
"category_required_badge": "필수",
"no_categories": "등록된 카테고리가 없습니다.",
"no_blocked_domains": "등록된 차단 도메인이 없습니다.",
"view_button": "v{version} 본문 보기",
"close": "닫기",
"open_policy_page": "본문 페이지 열기",
"no_policy_slug": "등록된 정책 본문 페이지가 없습니다."
},
"not_found": "해당 정책 버전을 찾을 수 없습니다."
}
},
"admin": {
"consent_log": {
"title": "GDPR 동의 이력",
"description": "회원·게스트의 동의\/철회 변경 이력을 조회합니다. IP·User-Agent 등 감사 정보가 함께 노출됩니다.",
"refresh": "새로고침",
"empty": "조회된 동의 이력이 없습니다.",
"clear_filters": "필터 초기화",
"guest_label": "게스트",
"view_member_tooltip": "회원 상세 페이지로 이동",
"search_placeholder": "이메일 또는 세션 ID 로 검색",
"search_button": "검색",
"search_type": {
"email": "이메일",
"session_id": "세션 ID"
},
"filter": {
"consent_key_label": "동의 항목",
"action_label": "액션",
"source_label": "출처",
"option_all": "전체"
},
"action": {
"granted": "동의",
"revoked": "철회"
},
"source": {
"banner": "배너",
"preference_center": "환경설정",
"mypage": "마이페이지"
},
"col": {
"created_at": "시점",
"subject": "주체",
"consent_key": "동의 항목",
"action": "액션",
"source": "출처",
"policy_version": "정책 버전",
"ip": "IP"
},
"detail": {
"session_id_label": "세션 ID",
"user_agent_label": "User-Agent",
"categories_label": "카테고리 스냅샷",
"categories_hint": "동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.",
"snapshot_granted": "동의",
"snapshot_revoked": "거부"
}
}
},
"mypage": {
"privacy": {
"title": "쿠키 동의 관리",
"description": "사이트가 사용 중인 쿠키 항목에 대한 동의를 변경하거나 철회할 수 있습니다.",
"section_consents": "내 동의 현황",
"section_consents_hint": "동의\/철회 시점에 따라 즉시 반영됩니다. 필수 항목은 철회할 수 없습니다.",
"col_consent_key": "동의 항목",
"col_consented": "상태",
"col_consented_at": "동의일",
"col_revoked_at": "철회일",
"col_policy_version": "정책 버전",
"col_action": "작업",
"consented": "동의",
"revoked": "철회",
"revoke": "철회",
"grant": "동의",
"grant_again": "다시 동의",
"required_label": "필수",
"required_hint": "서비스 제공에 필수적이라 철회할 수 없습니다.",
"revoke_confirm_title": "동의 철회 확인",
"revoke_confirm_message": "이 항목을 철회하시겠습니까? 동의를 철회하면 해당 카테고리 관련 기능이 제한될 수 있습니다.",
"no_consents": "활성 동의 항목이 없습니다.",
"needs_renewal": "쿠키 정책이 변경되었습니다. 동의를 갱신해주세요.",
"needs_renewal_with_version": "쿠키 정책이 버전 {version} 으로 변경되었습니다. 선택 항목의 동의를 다시 확인해주세요.",
"needs_renewal_hint": "필수 쿠키는 사이트 운영에 꼭 필요한 항목이므로 정책 변경과 무관하게 항상 적용됩니다.",
"btn_renew_all": "현재 동의 갱신",
"btn_renew_this_item": "정책 갱신",
"renew_all_success": "활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.",
"operator_and_storage_meta": "본 사이트는 {entity}이(가) 운영하며, 데이터는 {location}에 저장됩니다",
"operator_only_meta": "본 사이트는 {entity}이(가) 운영합니다",
"storage_only_meta": "사용자 데이터는 {location}에 저장됩니다"
}
},
"common": {
"loading": "불러오는 중...",
"refresh": "새로고침"
},
"banner": {
"re_appear_reason": "쿠키 정책이 버전 {version} 로 변경되어 동의를 다시 확인합니다.",
"consent_required_hint": "거부한 항목과 관련된 기능이 제한될 수 있습니다.",
"btn_renew_consent": "현재 동의 갱신",
"title": "쿠키 사용 안내",
"description": "본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.",
"accept_all": "모두 동의",
"reject_all": "필수만 사용",
"preferences": "환경설정",
"save": "선택 동의",
"policy_link": "개인정보처리방침",
"preferences_title": "쿠키 환경설정",
"preferences_description": "카테고리별 쿠키 사용을 개별 동의\/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.",
"category_required_badge": "필수",
"view_policy_label": "변경된 정책 본문 보기",
"operator_and_storage_line": "본 사이트는 {entity}이(가) 운영하며, 데이터는 {location}에 저장됩니다",
"operator_only_line": "본 사이트는 {entity}이(가) 운영합니다",
"storage_only_line": "사용자 데이터는 {location}에 저장됩니다"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,414 @@
{
"meta": {
"editor_label": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.editor_label"
},
"id": "policy_version_snapshot_modal",
"comment": "정책 버전 snapshot 모달 — admin 권한자가 v배지/이력 표 행 클릭 시 노출. 부모 layout 의 gdprPolicyVersionSnapshot data_source 가 _global.viewingPolicyVersion 정수를 endpoint path 에 보간하여 그 시점 settings snapshot (cookie_categories / privacy_policy_slug / blocked_domains) 을 표시. DPO 가 회원 분쟁 시 입증 자료로 활용 (GDPR Art.7(1)). Partial 파일에 data_sources 정의 금지 규정 — 부모 layout 에서 정의하고 본 partial 은 컴포넌트만 참조.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.title|version={{_global.viewingPolicyVersion ?? '?'}}",
"width": "min(720px, 95vw)",
"closeOnBackdropClick": true
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"comment": "안내문 — DPO 입증 자료 활용 안내",
"type": "basic",
"name": "P",
"props": {
"className": "text-secondary text-xs leading-relaxed"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.description"
},
{
"comment": "메타 — 발행일시 + 발행자 + 변경사유 + 변경 종류 (4개를 grid 2열).",
"type": "basic",
"name": "Div",
"if": "{{gdprPolicyVersionSnapshot?.data?.data}}",
"props": {
"className": "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-xs border border-gray-200 dark:border-gray-700 rounded-md p-3 bg-gray-50 dark:bg-gray-900"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.published_at_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-900 dark:text-gray-100"
},
"text": "{{gdprPolicyVersionSnapshot?.data?.data?.created_at ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.publisher_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-900 dark:text-gray-100"
},
"text": "{{gdprPolicyVersionSnapshot?.data?.data?.publisher?.name ? (gdprPolicyVersionSnapshot?.data?.data?.publisher?.email ? gdprPolicyVersionSnapshot.data.data.publisher.name + ' (' + gdprPolicyVersionSnapshot.data.data.publisher.email + ')' : gdprPolicyVersionSnapshot.data.data.publisher.name) : (gdprPolicyVersionSnapshot?.data?.data?.publisher?.email ?? '$t:sirsoft-gdpr.settings.policy_version.publisher_system')}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.history.col.change_type"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-900 dark:text-gray-100"
},
"text": "{{gdprPolicyVersionSnapshot?.data?.data?.change_type === 'material' ? '$t:sirsoft-gdpr.settings.policy_version.change_type.material' : (gdprPolicyVersionSnapshot?.data?.data?.change_type === 'initial' ? '$t:sirsoft-gdpr.settings.policy_version.change_type.initial' : '$t:sirsoft-gdpr.settings.policy_version.change_type.non_material')}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.memo_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-900 dark:text-gray-100 truncate"
},
"text": "{{gdprPolicyVersionSnapshot?.data?.data?.memo ?? '-'}}"
}
]
}
]
},
{
"comment": "정책 본문 슬러그 섹션 — 가장 위로 (정보 위계: 큰 것 → 작은 것). 슬러그가 있으면 평문 + 새 탭 열기 링크 Button, 없으면 안내문.",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.section_privacy_policy"
},
{
"type": "basic",
"name": "Div",
"if": "{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}",
"props": {
"className": "flex-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded flex-1 min-w-0 break-all"
},
"text": "{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? '-'}}"
},
{
"comment": "본문 페이지 새 탭 열기 — 슬러그를 그대로 path 로 사용 (예: 'privacy-policy' → '/privacy-policy'). 운영자가 검증한 슬러그라 사용자 입력 검증 불요.",
"type": "basic",
"name": "A",
"props": {
"href": "/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}",
"target": "_blank",
"rel": "noopener",
"className": "text-info-soft inline-flex items-center gap-1 px-3 py-2 font-medium bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors flex-shrink-0"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.open_policy_page"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{!gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}",
"props": {
"className": "text-tertiary italic px-3 py-2 border border-dashed border-gray-200 dark:border-gray-700 rounded"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.no_policy_slug"
}
]
},
{
"comment": "쿠키 카테고리 섹션 — snapshot.cookie_categories[] iteration. 다국어 객체 (ko/en) / plain 문자열 양쪽 호환.",
"type": "basic",
"name": "Div",
"if": "{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.cookie_categories}}",
"props": {
"className": "row-stack"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.section_categories"
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprPolicyVersionSnapshot?.data?.data?.snapshot?.cookie_categories ?? []).length === 0}}",
"props": {
"className": "text-tertiary italic px-3 py-2 border border-dashed border-gray-200 dark:border-gray-700 rounded"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.no_categories"
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprPolicyVersionSnapshot?.data?.data?.snapshot?.cookie_categories ?? []).length > 0}}",
"props": {
"className": "divider-list border border-gray-200 dark:border-gray-700 rounded-md"
},
"iteration": {
"source": "gdprPolicyVersionSnapshot?.data?.data?.snapshot?.cookie_categories ?? []",
"item_var": "category"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-3 py-2 text-xs space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono text-gray-900 dark:text-gray-100 font-semibold"
},
"text": "{{category?.key ?? '?'}}"
},
{
"comment": "라벨 — 다국어 객체 (ko/en) 또는 plain 문자열 양쪽 호환. 객체일 때 ko 우선 추출, 아니면 그대로.",
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "{{(category?.label && typeof category.label === 'object') ? (category.label.ko ?? category.label.en ?? '-') : (category?.label ?? '-')}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{category?.required === true}}",
"props": {
"className": "ml-auto px-2 py-0.5 text-[10px] font-medium bg-amber-100 dark:bg-amber-900/40 text-warning-soft rounded"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.category_required_badge"
}
]
},
{
"comment": "설명 — 다국어 객체 (ko/en) 또는 plain 문자열 양쪽 호환.",
"type": "basic",
"name": "P",
"if": "{{category?.description}}",
"props": {
"className": "text-gray-600 dark:text-gray-300 leading-relaxed"
},
"text": "{{(category?.description && typeof category.description === 'object') ? (category.description.ko ?? category.description.en ?? '') : (category?.description ?? '')}}"
}
]
}
]
}
]
},
{
"comment": "차단 도메인 카탈로그 섹션 — 백엔드가 변환한 blocked_domains_list 배열 사용. [{category, domains[]}] 형태. snapshot.blocked_domains 객체를 G7 엔진이 직접 iteration 못하므로 백엔드에서 변환.",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.section_blocked_domains"
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprPolicyVersionSnapshot?.data?.data?.blocked_domains_list ?? []).length === 0}}",
"props": {
"className": "text-tertiary italic px-3 py-2 border border-dashed border-gray-200 dark:border-gray-700 rounded"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.no_blocked_domains"
},
{
"type": "basic",
"name": "Div",
"if": "{{(gdprPolicyVersionSnapshot?.data?.data?.blocked_domains_list ?? []).length > 0}}",
"props": {
"className": "divider-list border border-gray-200 dark:border-gray-700 rounded-md"
},
"iteration": {
"source": "gdprPolicyVersionSnapshot?.data?.data?.blocked_domains_list ?? []",
"item_var": "blockedGroup"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-3 py-2 text-xs space-y-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "font-mono text-gray-900 dark:text-gray-100 font-semibold"
},
"text": "{{blockedGroup?.category ?? '?'}}"
},
{
"type": "basic",
"name": "Ul",
"props": {
"className": "list-disc list-inside text-gray-700 dark:text-gray-300 space-y-0.5"
},
"iteration": {
"source": "blockedGroup?.domains ?? []",
"item_var": "domain"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "font-mono break-all"
},
"text": "{{domain}}"
}
]
}
]
}
]
}
]
},
{
"comment": "닫기 버튼 — 일반 dismiss 모달이지만 명시 닫기 버튼 추가 (UX)",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end pt-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-xs font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.close"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,253 @@
{
"meta": {
"is_partial": true,
"description": "운영자 수동 정책 버전 발행 모달 — memo 입력 + POST /admin/policy-versions"
},
"id": "policy_version_publish_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-gdpr.settings.policy_version.material_modal.title",
"size": "medium"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3 text-sm text-gray-700 dark:text-gray-200"
},
"children": [
{
"comment": "사용자 영향 안내 — amber 좌측 색띠 + circle-exclamation — 「발행 시 즉시 적용 + 전 회원 재동의」 라는 점을 명확히 강조",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-amber-50 dark:bg-gray-900 border border-amber-200 dark:border-gray-700 border-l-4 border-l-amber-500 dark:border-l-amber-400 rounded-lg p-3 flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-800 dark:text-gray-100 leading-relaxed"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.body"
}
]
},
{
"type": "basic",
"name": "Ul",
"props": {
"className": "list-disc list-inside text-xs text-gray-600 dark:text-gray-300 space-y-1 pl-1"
},
"children": [
{
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.effect_bump"
},
{
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.effect_reconsent"
},
{
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.effect_history"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "pt-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
},
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.memo_label"
},
{
"type": "basic",
"name": "Textarea",
"props": {
"name": "publish_memo",
"rows": 3,
"placeholder": "$t:sirsoft-gdpr.settings.policy_version.material_modal.memo_placeholder",
"value": "{{_local.publishMemo ?? ''}}",
"className": "text-primary w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"publishMemo": "{{$event?.target?.value ?? ''}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2 pt-3 border-t border-gray-200 dark:border-gray-700 mt-3"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_local.isPublishing}}",
"className": "px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"comment": "발행 확정 — isPublishing 플래그로 발행 동안 스피너 노출 + 양쪽 버튼 disabled. closeModal 을 sequence 마지막에서 onSuccess 로 이동 (이전: 클릭 즉시 닫혀서 사용자가 발행 진행 상태 파악 불가). 발행 실패 시 모달 유지 → memo 수정 후 재시도 가능.",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{!(_local.publishMemo ?? '').trim() || _local.isPublishing}}",
"className": "px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isPublishing": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-gdpr/admin/policy-versions",
"params": {
"method": "POST",
"body": "{{({memo: _local.publishMemo ?? ''})}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isPublishing": false,
"publishMemo": ""
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "gdprPolicyVersionCurrent"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "gdprPolicyVersionHistory"
}
},
{
"handler": "closeModal"
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-gdpr.settings.policy_version.publish_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isPublishing": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{Object.values(error.errors ?? {})[0]?.[0] ?? '$t:sirsoft-gdpr.settings.save_error'}}"
}
}
]
}
]
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isPublishing}}",
"props": {
"name": "spinner",
"size": "sm",
"spin": true,
"className": "mr-1"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isPublishing}}",
"text": "$t:sirsoft-gdpr.settings.policy_version.material_modal.confirm"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isPublishing}}",
"text": "$t:sirsoft-gdpr.settings.policy_version.publishing"
}
]
}
]
}
]
}
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,14 @@
{
"version": "1.0.0",
"routes": [
{
"path": "*/admin/plugins/sirsoft-gdpr/consent-log",
"layout": "gdpr_consent_log",
"auth_required": true,
"meta": {
"title": "$t:sirsoft-gdpr.admin.consent_log.title",
"permission": "sirsoft-gdpr.privacy.view"
}
}
]
}
@@ -0,0 +1,70 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Enums;
enum CookieCategory: string
{
case Necessary = 'cookie_necessary';
case Functional = 'cookie_functional';
case Analytics = 'cookie_analytics';
case Marketing = 'cookie_marketing';
/**
* 현재 locale 에 맞는 카테고리 라벨을 반환합니다.
*
* @return string 다국어 카테고리 라벨 (예: "필수 쿠키", "기능 쿠키")
*/
public function label(): string
{
return match ($this) {
self::Necessary => __('sirsoft-gdpr::messages.consent.category_cookie_necessary'),
self::Functional => __('sirsoft-gdpr::messages.consent.category_cookie_functional'),
self::Analytics => __('sirsoft-gdpr::messages.consent.category_cookie_analytics'),
self::Marketing => __('sirsoft-gdpr::messages.consent.category_cookie_marketing'),
};
}
/**
* 필수 카테고리 여부를 반환합니다.
*
* @return bool Necessary 카테고리이면 true, 그 외 false
*/
public function isRequired(): bool
{
return $this === self::Necessary;
}
/**
* consent_key 문자열에서 Enum 인스턴스를 반환합니다.
*
* @param string $key consent_key 값 (예: "cookie_functional")
* @return self|null 매칭되는 Enum case, 없으면 null
*/
public static function fromConsentKey(string $key): ?self
{
return self::tryFrom($key);
}
/**
* 모든 카테고리의 consent_key 목록을 반환합니다.
*
* @return array<int, string> consent_key 문자열 배열 (예: ["cookie_necessary", "cookie_functional", ...])
*/
public static function allKeys(): array
{
return array_column(self::cases(), 'value');
}
/**
* 철회 가능한 카테고리 목록을 반환합니다.
*
* Necessary 카테고리는 ePrivacy Art.5(3) 면제 대상이라 철회 불가.
* 그 외 (Functional / Analytics / Marketing) 만 반환합니다.
*
* @return array<int, self> 철회 가능한 Enum case 배열
*/
public static function revocable(): array
{
return array_filter(self::cases(), fn (self $c) => ! $c->isRequired());
}
}
@@ -0,0 +1,53 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Enums;
/**
* GDPR 정책 버전 변경 종류
*
* - material: 카테고리 추가/삭제/key 변경, 카테고리 description 변경, privacy_policy_slug 변경
* → 모든 회원 재동의 트리거 (GDPR Art.6 — 새 처리에 옛 동의 사용 금지)
* - non_material: 도메인 추가/삭제, label 변경, hint 텍스트 정정 등
* → 재동의 트리거 안 함 (같은 목적 내 부수 정비)
* - initial: 최초 발행 (마이그레이션 시드)
*/
enum GdprPolicyChangeType: string
{
case Material = 'material';
case NonMaterial = 'non_material';
case Initial = 'initial';
/**
* 사용자 친화 라벨을 반환합니다.
*
* @return string lang 키 (locale 자동 반영)
*/
public function label(): string
{
return match ($this) {
self::Material => __('sirsoft-gdpr::messages.settings.policy_version.change_type.material'),
self::NonMaterial => __('sirsoft-gdpr::messages.settings.policy_version.change_type.non_material'),
self::Initial => __('sirsoft-gdpr::messages.settings.policy_version.change_type.initial'),
};
}
/**
* 재동의 트리거 여부.
*
* @return bool material/initial 시 true, non_material 시 false
*/
public function triggersReconsent(): bool
{
return $this === self::Material || $this === self::Initial;
}
/**
* 모든 케이스의 string 값 목록.
*
* @return array<int, string>
*/
public static function allValues(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,97 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Http\Resources\GdprConsentLogResource;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentLogService;
/**
* GDPR 관리자 동의 로그 컨트롤러
*
* GET /api/plugins/sirsoft-gdpr/admin/consent-log
*
* 권한: consent-log.read (라우트 미들웨어에서 검증)
*
* `gdpr_user_consent_histories` 테이블의 페이지네이션·필터 조회를 제공합니다.
* DPO 감사용 IP/User-Agent까지 노출됩니다.
*/
class GdprAdminConsentLogController extends AdminBaseController
{
/**
* GdprAdminConsentLogController 생성자
*
* @param GdprConsentLogService $consentLogService 동의 로그 서비스
*/
public function __construct(
private readonly GdprConsentLogService $consentLogService,
) {
parent::__construct();
}
/**
* 동의 로그 페이지네이션 응답을 반환합니다.
*
* 쿼리 파라미터:
* - email: 회원 이메일 부분 일치
* - session_id: 게스트 세션 ID 부분 일치 (DataGrid 가 앞 8자만 표시하므로 prefix/중간 모두 허용)
* - consent_keys[]: 동의 항목 키 배열 (whereIn)
* - actions[]: granted|revoked 배열
* - sources[]: banner|preference_center|mypage 배열
* - per_page: 페이지 크기 (1~100, 기본 20)
*
* @param Request $request HTTP 요청
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
$filters = [
'email' => $request->query('email'),
'session_id' => $request->query('session_id'),
'consent_keys' => $this->normalizeArrayParam($request->query('consent_keys')),
'actions' => $this->normalizeArrayParam($request->query('actions')),
'sources' => $this->normalizeArrayParam($request->query('sources')),
];
$perPage = max(1, min(100, (int) $request->query('per_page', 20)));
$paginator = $this->consentLogService->paginateForAdmin($filters, $perPage);
return ResponseHelper::success('messages.success', [
'data' => GdprConsentLogResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
],
]);
}
/**
* 쿼리 파라미터 배열을 정규화합니다.
*
* `?consent_keys[]=a&consent_keys[]=b` → ['a', 'b']
* `?consent_keys=a` → ['a'] (단일 값도 배열로 정규화)
* `null` 또는 빈 값 → []
*
* @param mixed $value 쿼리 값
* @return array<int, string>
*/
private function normalizeArrayParam(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
if (is_array($value)) {
return array_values(array_filter(array_map('strval', $value), fn ($v) => $v !== ''));
}
return [(string) $value];
}
}
@@ -0,0 +1,136 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Http\Requests\PublishPolicyVersionRequest;
use Plugins\Sirsoft\Gdpr\Http\Resources\GdprPolicyVersionDetailResource;
use Plugins\Sirsoft\Gdpr\Http\Resources\GdprPolicyVersionResource;
use Plugins\Sirsoft\Gdpr\Services\GdprPolicyVersionService;
use Plugins\Sirsoft\Gdpr\Services\GdprSettingsService;
/**
* GDPR 관리자 정책 버전 컨트롤러
*
* - GET /api/plugins/sirsoft-gdpr/admin/policy-versions — 이력 페이지네이션
* - GET /api/plugins/sirsoft-gdpr/admin/policy-versions/current — 현재 발행된 최신 버전
* - POST /api/plugins/sirsoft-gdpr/admin/policy-versions — 운영자 수동 정책 버전 발행
*
* 권한: sirsoft-gdpr.privacy.view (조회) / sirsoft-gdpr.privacy.update (발행)
*
* GDPR Art.7(1) 동의 입증 책임 + Art.30 처리 기록 의무를 충족합니다.
*/
class GdprAdminPolicyVersionController extends AdminBaseController
{
/**
* GdprAdminPolicyVersionController 생성자
*
* @param GdprPolicyVersionService $service 정책 버전 서비스
* @param GdprSettingsService $settingsService 현재 settings snapshot 캡처용
*/
public function __construct(
private readonly GdprPolicyVersionService $service,
private readonly GdprSettingsService $settingsService,
) {
parent::__construct();
}
/**
* 정책 버전 이력을 페이지네이션 형태로 반환합니다 (version DESC).
*
* 쿼리 파라미터:
* - per_page: 페이지 크기 (1~100, 기본 20)
*
* @param Request $request HTTP 요청
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
$perPage = max(1, min(100, (int) $request->query('per_page', 20)));
$paginator = $this->service->paginate($perPage);
return ResponseHelper::success('messages.success', [
'data' => GdprPolicyVersionResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
],
]);
}
/**
* 운영자 수동 정책 버전 발행.
*
* 자동 감지 영역 (카테고리 key/description 변경, slug 변경) 밖의 변경 — 예:
* - 정책 본문 페이지(sirsoft-page) 외부 수정
* - 법인명 변경 후 의도적 재동의 트리거
* - 운영 위탁자 변경 등 Art.13 정보 변경
*
* 호출 시 현재 settings snapshot 을 자동 캡처하여 새 정책 버전 발행
* (settings 자체는 변경되지 않음 — 발행만 수행).
*
* @param PublishPolicyVersionRequest $request memo 필수 검증
* @return JsonResponse
*/
public function store(PublishPolicyVersionRequest $request): JsonResponse
{
$memo = (string) $request->validated('memo');
$snapshot = $this->settingsService->getCurrentSnapshot();
$version = $this->service->publishManually($snapshot, $memo, $request->user()?->id);
return ResponseHelper::success('sirsoft-gdpr::messages.settings.policy_version.publish_success', [
'data' => new GdprPolicyVersionResource($version->load('createdBy')),
]);
}
/**
* 현재 발행된 최신 정책 버전을 반환합니다.
*
* 발행 row 가 없으면 data 가 null.
*
* @return JsonResponse
*/
public function current(): JsonResponse
{
$current = $this->service->getCurrent(loadCreatedBy: true);
return ResponseHelper::success('messages.success', [
'data' => $current !== null ? new GdprPolicyVersionResource($current) : null,
]);
}
/**
* 특정 정책 버전의 detail (snapshot 본문 포함) 을 반환합니다.
*
* admin 동의 이력 / 정책 버전 이력 화면에서 행 클릭 시 호출되어
* 그 시점의 settings snapshot (cookie_categories / privacy_policy_slug / blocked_domains)
* 을 모달로 표시. DPO 가 회원 분쟁 시 즉시 그 시점 정책 본문 확인 가능
* (GDPR Art.7(1) 입증 책임 충족).
*
* 권한: sirsoft-gdpr.privacy.view (라우트 미들웨어에서 검증).
*
* @param int $version 조회할 정책 버전 정수 (URL path param)
* @return JsonResponse 200 + detail / 404 (버전 부재)
*/
public function show(int $version): JsonResponse
{
$policyVersion = $this->service->getByVersion($version);
if ($policyVersion === null) {
return ResponseHelper::error('sirsoft-gdpr::messages.settings.policy_version.not_found', 404);
}
return ResponseHelper::success('messages.success', [
'data' => new GdprPolicyVersionDetailResource($policyVersion),
]);
}
}
@@ -0,0 +1,109 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Http\Requests\UpdateAdminSettingsRequest;
use Plugins\Sirsoft\Gdpr\Services\GdprSettingsService;
/**
* GDPR 관리자 설정 컨트롤러
*
* - GET /api/plugins/sirsoft-gdpr/admin/settings : 전체 설정 조회 (3탭)
* - PUT /api/plugins/sirsoft-gdpr/admin/settings : 설정 저장 (정책 버전 발행 없음 — 수동 발행 전용)
*
* 권한: settings.manage (라우트 미들웨어에서 검증)
*
* 정책 버전 발행은 본 컨트롤러의 책임이 아닙니다. 운영자가 「+ 새 버전 발행」
* (POST /admin/policy-versions) 으로 명시 트리거합니다 — GdprAdminPolicyVersionController.
*/
class GdprAdminSettingsController extends AdminBaseController
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-gdpr';
/**
* GdprAdminSettingsController 생성자
*
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용)
* @param GdprSettingsService $settingsService GDPR 설정 저장 서비스
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
private readonly GdprSettingsService $settingsService,
) {
parent::__construct();
}
/**
* 현재 GDPR 플러그인 설정 전체를 반환합니다 (관리자 화면 폼 바인딩용).
*
* @return JsonResponse
*/
public function show(): JsonResponse
{
$settings = $this->pluginSettings->get(self::PLUGIN_ID);
$settings = $this->normalizeJsonFields(is_array($settings) ? $settings : []);
return ResponseHelper::success('messages.success', [
'settings' => $settings,
]);
}
/**
* GDPR 플러그인 설정을 저장합니다.
*
* 정책 버전은 자동 발행되지 않습니다. 운영자가 별도로
* 「+ 새 버전 발행」 을 클릭해야 발행됩니다.
*
* @param UpdateAdminSettingsRequest $request 검증된 요청
* @return JsonResponse
*/
public function update(UpdateAdminSettingsRequest $request): JsonResponse
{
$validated = $request->validated();
// 동적 스키마 fallback (validation.md "동적 스키마 기반 FormRequest 패턴")
if (empty($validated)) {
$validated = $request->all();
}
// 옛 자동 발행 흐름의 change_memo 필드는 더 이상 사용하지 않음 — 정책 버전 발행은 별도 엔드포인트로 일원화
unset($validated['change_memo']);
$result = $this->settingsService->saveAdminSettings($validated);
return ResponseHelper::success('sirsoft-gdpr::messages.settings.saved', [
'settings' => $this->normalizeJsonFields($result['settings']),
]);
}
/**
* 응답에서 JSON 필드는 디코드하여 객체/배열로 노출합니다.
*
* @param array<string, mixed> $settings 설정 배열
* @return array<string, mixed>
*/
private function normalizeJsonFields(array $settings): array
{
foreach (['cookie_categories'] as $jsonKey) {
if (! array_key_exists($jsonKey, $settings)) {
continue;
}
$value = $settings[$jsonKey];
if (is_string($value)) {
$decoded = json_decode($value, true);
$settings[$jsonKey] = is_array($decoded) ? $decoded : [];
}
}
return $settings;
}
}
@@ -0,0 +1,170 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Public;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Http\Requests\StoreCookieConsentRequest;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
/**
* GDPR 공개 쿠키 동의 컨트롤러
*
* - POST /api/plugins/sirsoft-gdpr/consent/cookie : 게스트/회원 쿠키 동의 저장
* - GET /api/plugins/sirsoft-gdpr/consent/cookie/status : 게스트/회원 쿠키 동의 현황 조회
*
* 인증 없이 접근 가능. 회원이 sanctum 토큰으로 호출하면 user_id 기반으로 저장.
*/
class GdprCookieConsentController extends PublicBaseController
{
/**
* GdprCookieConsentController 생성자
*
* @param GdprConsentService $consentService GDPR 동의 서비스
*/
public function __construct(
private readonly GdprConsentService $consentService,
) {
parent::__construct();
}
/**
* 게스트/회원 쿠키 동의를 저장합니다.
*
* 회원이면 user_id 기반 status upsert + history INSERT,
* 게스트면 session_id 기반 history INSERT.
*
* @param StoreCookieConsentRequest $request 검증된 요청
* @return JsonResponse
*/
public function store(StoreCookieConsentRequest $request): JsonResponse
{
$consents = (array) $request->validated('consents');
$source = (string) $request->validated('source');
$userId = $request->user()?->id;
$sessionId = null;
$issuedSessionCookie = null;
if ($userId === null) {
$sessionId = $this->resolveGuestSessionId($request);
if ($sessionId === null) {
$sessionId = $this->issueGuestSessionId();
$issuedSessionCookie = $sessionId;
}
}
$this->consentService->updateConsents($userId, $sessionId, $consents, $source);
$response = ResponseHelper::success('sirsoft-gdpr::messages.consent.granted', [
'user_id' => $userId,
'session_id' => $sessionId,
'consents' => $consents,
]);
if ($issuedSessionCookie !== null) {
$this->attachGuestSessionCookie($response, $issuedSessionCookie);
}
// Phase 2 단순화: 동의 철회 시 cookie 파기는 클라이언트 functionalCleaner 가 단독 담당.
// 서버는 후속 응답에서 CookieConsentMiddleware 가 strictly necessary allowlist 외
// 모든 Set-Cookie 를 제거 (EDPB §16) — 본 컨트롤러는 cookie 추가 발송 안 함.
return $response;
}
/**
* 현재 방문자(회원/게스트)의 쿠키 동의 상태를 반환합니다.
*
* 배너 표시 여부 결정용. has_consented = 현재 정책 버전으로 동의 완료 여부.
* 회원이 sanctum 토큰으로 호출하면 user_id 기반, 미인증이면 session_id 기반.
*
* @param \Illuminate\Http\Request $request 요청
* @return JsonResponse
*/
public function status(\Illuminate\Http\Request $request): JsonResponse
{
$userId = $request->user()?->id;
$sessionId = $userId === null ? $this->resolveGuestSessionId($request) : null;
$hasConsented = $this->consentService->hasCurrentCookieConsent($userId, $sessionId);
$consents = $this->consentService->getCurrentCookieConsents($userId, $sessionId);
$currentPolicyVersion = $this->consentService->getCurrentPolicyVersion();
// needs_renewal: 옛 동의가 1건 이상 존재하는데 현재 정책 버전 미동의 (즉 옛 동의가 신정책에
// 그대로 쓰이고 있는 상태). 신규 게스트 (동의 row 0건) 는 has_consented=false 지만
// needs_renewal=false (재확인할 옛 동의 자체가 없음 — 일반 배너만 노출).
// GDPR Art.6 보수적 차단 트리거 + A-6 배너 사유 안내 트리거 용도.
$needsRenewal = ! $hasConsented
&& $this->consentService->hasAnyConsentHistory($userId, $sessionId);
return ResponseHelper::success('messages.success', [
'has_consented' => $hasConsented,
'consents' => $consents,
'needs_renewal' => $needsRenewal,
'current_policy_version' => $currentPolicyVersion,
// 회원/게스트 식별 SSoT — 배너 layout 이 회원 한정 keep_consent 버튼 분기에 사용.
// optional.sanctum 미들웨어 통과 후 $request->user() 가 토큰 보유 회원이면 인스턴스 반환, 게스트면 null.
'is_member' => $userId !== null,
]);
}
/**
* 게스트 세션 ID를 결정합니다.
*
* 클라이언트가 쿠키 또는 헤더로 전달한 session_id가 있으면 사용,
* 없으면 Laravel session ID를 fallback으로 사용합니다.
*
* @param \Illuminate\Http\Request $request 요청
* @return string|null
*/
private function resolveGuestSessionId($request): ?string
{
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
}
try {
$sessionId = $request->hasSession() ? $request->session()->getId() : null;
} catch (\Throwable) {
$sessionId = null;
}
return $sessionId !== null ? substr($sessionId, 0, 100) : null;
}
/**
* 신규 게스트 세션 ID 를 발급합니다 (UUID v4 기반).
*
* @return string
*/
private function issueGuestSessionId(): string
{
return (string) \Illuminate\Support\Str::uuid();
}
/**
* 응답에 게스트 세션 쿠키 (gdpr_session) 를 첨부합니다.
*
* 1년 유효, path=/, SameSite=Lax. HTTPS 환경에서는 Secure 자동 적용.
*
* @param JsonResponse $response 응답
* @param string $sessionId 발급된 세션 ID
* @return void
*/
private function attachGuestSessionCookie(JsonResponse $response, string $sessionId): void
{
$response->cookie(
'gdpr_session',
$sessionId,
60 * 24 * 365,
'/',
null,
request()->isSecure(),
true,
false,
'lax'
);
}
}
@@ -0,0 +1,119 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Public;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Plugin;
use Plugins\Sirsoft\Gdpr\Services\CookieCategoryService;
use Plugins\Sirsoft\Gdpr\Services\GdprPolicyVersionService;
/**
* GDPR 공개 설정 컨트롤러
*
* 게스트도 접근 가능한 공개 설정을 반환합니다.
* 배너·마이페이지 카드 렌더링에 필요한 expose 필드만 제공합니다.
*
* GET /api/plugins/sirsoft-gdpr/settings
*/
class GdprSettingsController extends PublicBaseController
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-gdpr';
/**
* GdprSettingsController 생성자
*
* @param CookieCategoryService $categoryService 쿠키 카테고리 서비스
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스
* @param GdprPolicyVersionService $policyVersionService 정책 버전 서비스 (응답의 cookie_policy_version 출처)
*/
public function __construct(
private readonly CookieCategoryService $categoryService,
private readonly PluginSettingsService $pluginSettings,
private readonly GdprPolicyVersionService $policyVersionService,
) {
parent::__construct();
}
/**
* 공개 설정 응답을 반환합니다.
*
* @return JsonResponse
*/
public function show(): JsonResponse
{
$privacySlug = (string) $this->pluginSettings->get(self::PLUGIN_ID, 'privacy_policy_slug', 'privacy');
$data = [
// 정책 버전은 gdpr_policy_versions 테이블이 SSoT — 최신 row 의 version 정수를 string 으로 노출.
// 회원/게스트 동의 시점 비교 (배너 재노출 판정) + 마이페이지의 자신의 동의 버전 vs 현재 비교에 사용.
'cookie_policy_version' => (string) $this->policyVersionService->getCurrentVersion(),
'privacy_policy_slug' => $privacySlug !== '' ? $privacySlug : null,
'privacy_policy_available' => $privacySlug !== '',
'legal_entity_name' => (string) $this->pluginSettings->get(self::PLUGIN_ID, 'legal_entity_name', ''),
'data_storage_location' => (string) $this->pluginSettings->get(self::PLUGIN_ID, 'data_storage_location', ''),
// 쿠키 배너 + 자동 차단 (F-01 / F-02) — banner_enabled 단일 토글로 통합 제어.
// 차단 엔진(클라이언트 blocker.ts) 의 활성 조건도 banner_enabled === true 단일.
'banner_enabled' => (bool) $this->pluginSettings->get(self::PLUGIN_ID, 'banner_enabled', true),
'banner_position' => (string) $this->pluginSettings->get(self::PLUGIN_ID, 'banner_position', 'bottom_bar'),
'cookie_categories' => $this->categoryService->getCategories(),
// F-02 도메인 기반 차단 — 게스트도 차단 동작해야 하므로 공개 응답에 노출.
// 본 컨트롤러가 응답 노출 SSoT (defaults.json 의 frontend_schema 는 보조).
'blocked_domains' => $this->normalizeBlockedDomains(
$this->pluginSettings->get(self::PLUGIN_ID, 'blocked_domains', Plugin::DEFAULT_BLOCKED_DOMAINS_CATALOG)
),
'default_blocked_domains_preview' => Plugin::DEFAULT_BLOCKED_DOMAINS_CATALOG,
];
return ResponseHelper::success('messages.success', $data);
}
/**
* blocked_domains 응답 형식을 정규화합니다.
*
* 저장 형식이 native array 임을 가정하지만, JSON 문자열로 저장되어 있는 환경
* (구버전 호환) 도 안전하게 처리합니다.
*
* @param mixed $value 설정 저장값
* @return array<string, array<int, string>> 카테고리 → 도메인 패턴 배열
*/
private function normalizeBlockedDomains(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [];
}
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $category => $domains) {
if (! is_string($category)) {
continue;
}
if (! is_array($domains)) {
$normalized[$category] = [];
continue;
}
$normalized[$category] = array_values(array_filter(
array_map(
fn ($d) => is_string($d) ? trim($d) : '',
$domains,
),
fn ($d) => $d !== '',
));
}
return $normalized;
}
}
@@ -0,0 +1,166 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Controllers\User;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AuthBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Http\Requests\GrantConsentRequest;
use Plugins\Sirsoft\Gdpr\Http\Requests\RevokeConsentRequest;
use Plugins\Sirsoft\Gdpr\Http\Resources\GdprUserConsentHistoryResource;
use Plugins\Sirsoft\Gdpr\Http\Resources\GdprUserConsentResource;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
/**
* GDPR 회원 동의 컨트롤러
*
* - GET /api/plugins/sirsoft-gdpr/consent/me : 본인 활성 동의 동기화
* - GET /api/plugins/sirsoft-gdpr/consent/history : 본인 동의 이력
* - POST /api/plugins/sirsoft-gdpr/consent/revoke : 본인 동의 철회
*/
class GdprConsentController extends AuthBaseController
{
/**
* GdprConsentController 생성자
*
* @param GdprConsentService $consentService GDPR 동의 서비스
*/
public function __construct(
private readonly GdprConsentService $consentService,
) {
parent::__construct();
}
/**
* 본인 활성 동의 목록 (디바이스 간 동기화용).
*
* @return JsonResponse
*/
public function me(): JsonResponse
{
$user = $this->getCurrentUser();
if ($user === null) {
return $this->unauthorized();
}
// 카탈로그의 모든 카테고리 ∪ 회원 status 매트릭스 (Art.7(3) 대칭성: 철회/재동의/신규 동의 한 화면 처리).
// 마이페이지 「내 동의 현황」 표는 활성 동의만이 아니라 모든 카테고리를 노출하고
// 상태별로 액션(철회/다시 동의/동의/필수 안내) 을 분기.
$consents = $this->consentService->getMyConsentMatrix($user->id);
return ResponseHelper::success('messages.success', [
'user_id' => $user->id,
'needs_renewal' => $this->consentService->needsRenewal($user->id),
'current_policy_version' => $this->consentService->getCurrentPolicyVersion(),
'consents' => GdprUserConsentResource::collection($consents),
]);
}
/**
* 본인 동의 이력 (마이페이지 표시용).
*
* @return JsonResponse
*/
public function history(): JsonResponse
{
$user = $this->getCurrentUser();
if ($user === null) {
return $this->unauthorized();
}
$histories = $this->consentService->getHistories($user->id);
return ResponseHelper::success('messages.success', [
'histories' => GdprUserConsentHistoryResource::collection($histories),
]);
}
/**
* 본인 동의 철회.
*
* @param RevokeConsentRequest $request 검증된 요청
* @return JsonResponse
*/
public function revoke(RevokeConsentRequest $request): JsonResponse
{
$user = $this->getCurrentUser();
if ($user === null) {
return $this->unauthorized();
}
$consentKey = (string) $request->validated('consent_key');
$this->consentService->updateConsent(
userId: $user->id,
sessionId: null,
consentKey: $consentKey,
value: false,
source: 'mypage',
);
return ResponseHelper::success('sirsoft-gdpr::messages.consent.revoked', [
'consent_key' => $consentKey,
]);
}
/**
* 본인 동의 부여 (재동의 / 신규 동의).
*
* Art.7(3) 자유 변경권의 부여 방향 — 마이페이지 「내 동의 현황」 에서
* 「다시 동의」 / 「동의」 버튼 클릭 시 호출. 화이트리스트 검사는 FormRequest 가 수행.
*
* @param GrantConsentRequest $request 검증된 요청
* @return JsonResponse
*/
public function grant(GrantConsentRequest $request): JsonResponse
{
$user = $this->getCurrentUser();
if ($user === null) {
return $this->unauthorized();
}
$consentKey = (string) $request->validated('consent_key');
$this->consentService->updateConsent(
userId: $user->id,
sessionId: null,
consentKey: $consentKey,
value: true,
source: 'mypage',
);
return ResponseHelper::success('sirsoft-gdpr::messages.consent.granted_again', [
'consent_key' => $consentKey,
]);
}
/**
* 정책 버전 bump 후 활성 선택형 동의를 일괄 새 버전으로 갱신합니다 (전체 항목 다시 동의 — #19).
*
* 필수 쿠키와 철회 상태는 대상 외. 의사 변경은 없으나 *재동의 의사 표명* 으로 처리되어
* history 에 action=granted 행이 N개 누적됨 (각 갱신 항목당 1행, Art.7(1) 입증 트레일).
*
* @return JsonResponse
*/
public function renewAll(): JsonResponse
{
$user = $this->getCurrentUser();
if ($user === null) {
return $this->unauthorized();
}
$renewed = $this->consentService->renewAllForCurrentPolicy($user->id);
// 시그니처: success($messageKey, $data, $statusCode, $messageParams, $domain).
// renewed 카운트는 메시지 보간 (4번째 messageParams) 과 응답 페이로드 (2번째 data) 두 곳에
// 모두 필요 — 프론트 toast 는 lang 의 {renewed} placeholder 를 토큰으로 직접 보간하지만,
// 다른 클라이언트 (모바일/API 직접 호출) 가 응답 message 만 사용할 가능성 대비.
return ResponseHelper::success(
'sirsoft-gdpr::messages.consent.renew_all_success',
['renewed' => $renewed],
200,
['renewed' => $renewed],
);
}
}
@@ -0,0 +1,147 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Middleware;
use Closure;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
use Symfony\Component\HttpFoundation\Response;
/**
* Functional Cookie 동의 게이팅 미들웨어 (Phase 2 단순화 버전)
*
* EDPB Guidelines 2/2023 §16 (사전 차단) 충족:
* - functional 미동의 시 응답 Set-Cookie 헤더에서 strictly necessary allowlist 외 모든 cookie 제거
* - 운영자가 등록한 functional cookie 목록은 사용하지 않음 — GDPR 원칙은
* "strictly necessary 외 비-필수는 동의 전 차단" 이므로 등록 표 불필요
*
* 파기 cookie (cleared) 는 항상 통과 — EDPB §117 (철회 즉시 파기) 와 본 §16 차단이 충돌하지 않도록.
*
* 등록 시점: `GdprServiceProvider::boot()` 에서 Laravel 11+ Kernel::prependMiddlewareToGroup('web'|'api') 호출.
* Kernel.php:347 `array_search` 중복 방지로 매 요청 호출되어도 1회만 등록됨.
*
* @since 1.0.0-beta.1 (Phase 2)
*/
class CookieConsentMiddleware
{
/**
* CookieConsentMiddleware 생성자
*
* @param GdprConsentService $consentService 동의 서비스 (현재 방문자 functional 동의 상태 조회)
*/
public function __construct(
private readonly GdprConsentService $consentService,
) {}
/**
* 요청 처리 — 응답 직전 functional cookie 게이팅.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response HTTP 응답 (functional cookie 게이팅 적용)
*/
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
// 1. functional 동의 여부 조회
//
// 자기 자신 (sirsoft-gdpr) 제거 응답 race condition 안전 통과:
// 본 미들웨어는 라우트 진입 시점에 GdprServiceProvider::boot() 가 web/api 그룹에
// prepend 한다. 운영자가 본 플러그인을 제거하는 요청이면 컨트롤러 단계에서 autoload
// 갱신·활성 디렉토리 삭제가 발생하므로, 응답이 본 미들웨어로 돌아올 때 의존 클래스
// (GdprPolicyVersionService 등) 로딩이 실패할 수 있다. 이 경우 cookie 게이팅을
// 포기하고 응답을 그대로 통과시킨다 — 운영자의 "제거 성공" 흐름이 500 으로 깨지지
// 않도록 한다. 그 외 일반 요청에서는 의존 클래스가 정상 로드되므로 동작 변경 없음.
try {
$hasConsent = $this->hasFunctionalConsent($request);
} catch (BindingResolutionException) {
return $response;
}
if ($hasConsent) {
return $response; // 동의 — 통과
}
// 2. functional 미동의: 응답 Set-Cookie 중 strictly necessary allowlist 외 모두 제거
foreach ($response->headers->getCookies() as $cookie) {
$name = $cookie->getName();
// strictly necessary allowlist — 통과 (백엔드 cookie 4종)
if ($this->isStrictlyNecessary($name)) {
continue;
}
// 파기 cookie (cleared) 는 통과 — §117 (철회 즉시 파기) 와 §16 (사전 차단) 충돌 회피.
// GdprCookieConsentController 가 발송하는 Max-Age=0 cookie 도 본 분기로 통과 (현재는 미사용이나 안전 보장).
if ($cookie->isCleared()) {
continue;
}
// 응답에서 Set-Cookie 제거 (EDPB §16 — 동의 전 신규 저장 금지)
$response->headers->removeCookie($name, $cookie->getPath(), $cookie->getDomain());
}
return $response;
}
/**
* 현재 방문자 (회원/게스트) 의 functional 동의 상태를 반환합니다.
*
* @param Request $request HTTP 요청
* @return bool functional 동의 시 true
*/
private function hasFunctionalConsent(Request $request): bool
{
$userId = $request->user()?->id;
$sessionId = $this->resolveGuestSessionId($request);
$consents = $this->consentService->getCurrentCookieConsents($userId, $sessionId);
return ($consents['functional'] ?? false) === true;
}
/**
* 게스트 세션 식별자를 추출합니다 (gdpr_session cookie 또는 Laravel session ID).
*
* @param Request $request HTTP 요청
* @return string|null 세션 식별자 (회원이거나 식별 불가 시 null)
*/
private function resolveGuestSessionId(Request $request): ?string
{
if ($request->user()) {
return null;
}
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
}
try {
return $request->hasSession() ? substr($request->session()->getId(), 0, 100) : null;
} catch (\Throwable) {
return null;
}
}
/**
* Strictly Necessary cookie 인지 판정합니다.
*
* ePrivacy Art.5(3) 면제 항목 — XSRF/세션/유지보수/GDPR 동의 관리 cookie 는 게이팅 대상 외.
*
* @param string $name cookie 이름
* @return bool strictly necessary 시 true
*/
private function isStrictlyNecessary(string $name): bool
{
return in_array($name, [
'XSRF-TOKEN',
(string) config('session.cookie', 'laravel_session'),
'laravel_maintenance',
'gdpr_session',
], true);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Plugins\Sirsoft\Gdpr\Services\CookieCategoryService;
/**
* 회원 동의 부여(재동의/신규 동의) 요청 검증
*
* POST /api/plugins/sirsoft-gdpr/consent/grant
*
* 마이페이지 「내 동의 현황」 에서 「다시 동의」 / 「동의」 버튼 클릭 시 사용.
* Art.7(3) 자유 변경권의 부여 방향 — 이미 철회한 항목을 다시 부여하거나, 카탈로그에 있지만
* user_consents 에 row 없는 항목을 신규로 동의.
*
* 필수(strictly necessary) 카테고리는 부여 의미가 없지만(이미 항상 활성), 차단까지는 안 함 —
* Art.7(3) 의 "자유 부여" 권리를 임의 차단하지 않기 위함. 결과적으로 noop 처리됨.
*/
class GrantConsentRequest extends FormRequest
{
/**
* GrantConsentRequest 생성자
*
* @param CookieCategoryService $categoryService 쿠키 카테고리 설정 서비스
*/
public function __construct(private readonly CookieCategoryService $categoryService)
{
parent::__construct();
}
/**
* 권한 확인 (sanctum 인증은 미들웨어에서 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'consent_key' => ['required', 'string', 'max:50'],
];
}
/**
* 검증 메시지 정의
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'consent_key.required' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consent_key.string' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consent_key.max' => __('sirsoft-gdpr::messages.consent.invalid_key'),
];
}
/**
* 검증 후 동의 키 화이트리스트 검사.
*
* @param \Illuminate\Validation\Validator $validator
* @return void
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Validation\Validator $validator) {
$consentKey = (string) $this->input('consent_key', '');
if ($consentKey === '') {
return;
}
$allowedKeys = $this->categoryService->getAllConsentKeys();
if (! in_array($consentKey, $allowedKeys, true)) {
$validator->errors()->add(
'consent_key',
__('sirsoft-gdpr::messages.consent.invalid_key')
);
}
});
}
}
@@ -0,0 +1,50 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 운영자 수동 정책 버전 발행 요청 검증
*
* POST /api/plugins/sirsoft-gdpr/admin/policy-versions
*
* 운영자가 정책 본문 외부 수정 등 *자동 감지 영역 밖* 의 변경을 인지하고
* 명시적으로 새 정책 버전을 발행할 때 사용. memo 필수 (감사 추적).
*/
class PublishPolicyVersionRequest extends FormRequest
{
/**
* 권한 확인 (permission 미들웨어에서 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'memo' => ['required', 'string', 'min:1', 'max:500'],
];
}
/**
* 검증 메시지의 :attribute placeholder 사용자 친화 라벨.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'memo' => __('sirsoft-gdpr::messages.settings.policy_version.material_modal.memo_label'),
];
}
}
@@ -0,0 +1,97 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Plugins\Sirsoft\Gdpr\Services\CookieCategoryService;
/**
* 회원 동의 철회 요청 검증
*
* POST /api/plugins/sirsoft-gdpr/consent/revoke
*
* 마이페이지에서 본인 동의를 철회할 때 사용. 필수 카테고리는 422로 거부.
*/
class RevokeConsentRequest extends FormRequest
{
/**
* RevokeConsentRequest 생성자
*
* @param CookieCategoryService $categoryService 쿠키 카테고리 설정 서비스
*/
public function __construct(private readonly CookieCategoryService $categoryService)
{
parent::__construct();
}
/**
* 권한 확인 (sanctum 인증은 미들웨어에서 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'consent_key' => ['required', 'string', 'max:50'],
];
}
/**
* 검증 메시지 정의
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'consent_key.required' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consent_key.string' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consent_key.max' => __('sirsoft-gdpr::messages.consent.invalid_key'),
];
}
/**
* 검증 후 동의 키 화이트리스트 + 필수 항목 차단.
*
* @param \Illuminate\Validation\Validator $validator
* @return void
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Validation\Validator $validator) {
$consentKey = (string) $this->input('consent_key', '');
if ($consentKey === '') {
return;
}
$allowedKeys = $this->categoryService->getAllConsentKeys();
if (! in_array($consentKey, $allowedKeys, true)) {
$validator->errors()->add(
'consent_key',
__('sirsoft-gdpr::messages.consent.invalid_key')
);
return;
}
if ($this->categoryService->isRequired($consentKey)) {
$validator->errors()->add(
'consent_key',
__('sirsoft-gdpr::messages.consent.required_cannot_revoke')
);
}
});
}
}
@@ -0,0 +1,99 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Plugins\Sirsoft\Gdpr\Services\CookieCategoryService;
/**
* 쿠키 동의 저장 요청 검증
*
* POST /api/plugins/sirsoft-gdpr/consent/cookie
*
* 게스트(미인증) / 회원(sanctum) 모두 호출 가능한 공개 엔드포인트.
* 게스트는 session_id 기반 history INSERT, 회원은 status upsert + history INSERT.
*/
class StoreCookieConsentRequest extends FormRequest
{
/**
* StoreCookieConsentRequest 생성자
*
* @param CookieCategoryService $categoryService 쿠키 카테고리 설정 서비스
*/
public function __construct(private readonly CookieCategoryService $categoryService)
{
parent::__construct();
}
/**
* 권한 확인 (공개 엔드포인트 — 인증 불필요)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'consents' => ['required', 'array', 'min:1'],
'consents.*' => ['required', 'boolean'],
'source' => ['required', 'string', 'in:banner,preference_center,register,mypage'],
];
}
/**
* 검증 메시지 정의
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'consents.required' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consents.array' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'consents.*.boolean' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'source.required' => __('sirsoft-gdpr::messages.consent.invalid_key'),
'source.in' => __('sirsoft-gdpr::messages.consent.invalid_key'),
];
}
/**
* 검증 후 동의 키 화이트리스트 + 필수 항목 false 차단.
*
* @param \Illuminate\Validation\Validator $validator
* @return void
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Validation\Validator $validator) {
$consents = (array) $this->input('consents', []);
$allowedKeys = $this->categoryService->getAllConsentKeys();
foreach ($consents as $key => $value) {
if (! in_array($key, $allowedKeys, true)) {
$validator->errors()->add(
"consents.{$key}",
__('sirsoft-gdpr::messages.consent.invalid_key')
);
continue;
}
if ($value === false && $this->categoryService->isRequired($key)) {
$validator->errors()->add(
"consents.{$key}",
__('sirsoft-gdpr::messages.consent.required_cannot_revoke')
);
}
}
});
}
}
@@ -0,0 +1,265 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 관리자 GDPR 설정 저장 요청 검증
*
* PUT /api/plugins/sirsoft-gdpr/admin/settings
*
* 운영자가 설정 화면에서 정책 메타데이터·쿠키 배너·마이페이지 카드 토글을
* 저장할 때 사용. plugin.php::getSettingsSchema() 와 키 일치를 보장합니다.
*/
class UpdateAdminSettingsRequest extends FormRequest
{
/**
* 배너 위치 허용 값
*/
private const BANNER_POSITIONS = [
'bottom_bar',
'bottom_left_popup',
'bottom_right_popup',
'centered_modal',
];
/**
* 차단 도메인 정규식
*
* - FQDN 만 허용 (점 1개 이상 — RFC 1035 기반)
* - 와일드카드 prefix `*.` 허용 (예: *.hotjar.com)
* - 하이픈은 라벨 시작/끝 금지 (RFC 1035)
* - 대소문자 무관
* - 숫자만으로 구성된 라벨 허용 (예: 1.2.3.4 는 IPv4 처럼 보이지만 정규식상 통과 — 실제 DNS 동작은 CDN/PaaS 에 의존)
* - punycode·localhost·단일 라벨 도메인 미지원 (운영자 안내 박스 hint 명시)
*/
private const DOMAIN_REGEX = '/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i';
/**
* 권한 확인 (permission 미들웨어에서 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 정의
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// 정책 메타데이터 — cookie_policy_version 은 gdpr_policy_versions 테이블이 SSoT.
// 운영자가 직접 입력하지 않으므로 본 FormRequest 에서 검증하지 않음 (전달되어도 stripped).
'privacy_policy_slug' => ['nullable', 'string', 'max:100', 'regex:/^[a-z0-9\-]*$/'],
'legal_entity_name' => ['nullable', 'string', 'max:200'],
// 데이터 저장 위치 — GDPR Art.13(1)(f) / PIPA 28조의8 은 "국가 단위" 표기를 요구.
// 운영자가 실수로 보안 민감 식별자 (IP / CIDR / AWS 리전 코드) 를 입력하지 않도록
// not_regex 3종으로 차단. 통과한 일반 텍스트는 운영자 자유 입력 허용.
'data_storage_location' => [
'nullable',
'string',
'max:200',
'not_regex:/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?\b/', // IPv4 / CIDR
'not_regex:/\b[a-z]{2,}-[a-z]+(-\d+[a-z]?|\d+)\b/i', // 리전 코드: AWS ap-northeast-2, GCP asia-northeast3, Azure eastus2 패턴
'not_regex:/\b(us|eu|ap|me|af|sa|ca)-(east|west|north|south|central|northeast|southeast|northwest|southwest)\b/i', // 리전 prefix 만 입력해도 차단 (예: us-east, ap-northeast)
],
// 쿠키 배너 + 자동 차단 (F-01 / F-02) — banner_enabled 단일 토글로 통합 제어.
// auto_blocking_enabled 별도 검증 규칙 없음: 운영자가 PUT 으로 키를 보내도 stripped.
'banner_enabled' => ['nullable', 'boolean'],
'banner_position' => ['nullable', 'string', 'in:'.implode(',', self::BANNER_POSITIONS)],
// cookie_categories.*.required 는 운영자 입력 경로가 없으므로 검증하지 않음 (CookieCategory enum 이 SSoT).
'cookie_categories' => ['nullable'],
'cookie_categories.*.key' => ['required_with:cookie_categories', 'string', 'in:necessary,functional,analytics,marketing'],
'cookie_categories.*.label' => ['required_with:cookie_categories', 'array'],
'cookie_categories.*.label.ko' => ['required_with:cookie_categories', 'string', 'max:50'],
'cookie_categories.*.label.en' => ['required_with:cookie_categories', 'string', 'max:50'],
'cookie_categories.*.description' => ['nullable', 'array'],
// F-02 도메인 기반 차단
'blocked_domains' => ['nullable', 'array'],
'blocked_domains.*' => ['array'],
'blocked_domains.*.*' => ['string', 'max:253', 'regex:'.self::DOMAIN_REGEX],
];
}
/**
* 검증 메시지 정의
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'data_storage_location.not_regex' => __('sirsoft-gdpr::messages.settings.fields.data_storage_location.sensitive_format'),
'cookie_categories.*.key.in' => __('sirsoft-gdpr::messages.consent.invalid_key'),
// 카테고리별 메시지 — 운영자가 어떤 카테고리의 어느 도메인이 잘못됐는지 즉시 식별 가능.
// GDPR 4카테고리 중 necessary 는 차단 대상 아님 → functional / analytics / marketing 만 정의.
'blocked_domains.functional.*.regex' => __('sirsoft-gdpr::messages.blocked_domains.invalid_format_functional'),
'blocked_domains.functional.*.max' => __('sirsoft-gdpr::messages.blocked_domains.too_long_functional'),
'blocked_domains.analytics.*.regex' => __('sirsoft-gdpr::messages.blocked_domains.invalid_format_analytics'),
'blocked_domains.analytics.*.max' => __('sirsoft-gdpr::messages.blocked_domains.too_long_analytics'),
'blocked_domains.marketing.*.regex' => __('sirsoft-gdpr::messages.blocked_domains.invalid_format_marketing'),
'blocked_domains.marketing.*.max' => __('sirsoft-gdpr::messages.blocked_domains.too_long_marketing'),
'blocked_domains.*.array' => __('sirsoft-gdpr::messages.blocked_domains.must_be_array'),
];
}
/**
* 검증 메시지의 :attribute placeholder 를 사용자 친화 한국어/영어 라벨로 치환.
*
* Laravel 기본 :attribute 는 underscore 만 공백으로 치환한 영문 표현 (예: "privacy policy slug")
* 노출 → 사용자 친화도 낮음. 본 메서드로 각 필드에 한국어/영어 라벨 매핑.
*
* 라벨 출처는 lang/{locale}/messages.php 의 settings.fields.{field}.label 키.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
// cookie_policy_version 은 자동 발행이므로 FormRequest 가 검증하지 않음 (attributes 도 불필요).
'privacy_policy_slug' => __('sirsoft-gdpr::messages.settings.fields.privacy_policy_slug.label'),
'legal_entity_name' => __('sirsoft-gdpr::messages.settings.fields.legal_entity_name.label'),
'data_storage_location' => __('sirsoft-gdpr::messages.settings.fields.data_storage_location.label'),
'banner_enabled' => __('sirsoft-gdpr::messages.settings.fields.banner_enabled.label'),
'banner_position' => __('sirsoft-gdpr::messages.settings.fields.banner_position.label'),
'cookie_categories' => __('sirsoft-gdpr::messages.settings.section.cookie_categories'),
'blocked_domains' => __('sirsoft-gdpr::messages.settings.section.auto_blocking'),
];
}
/**
* 사전 정규화:
* 1. cookie_categories 가 JSON 문자열로 들어와도 array 로 변환
* 2. blocked_domains 의 카테고리별 값이 textarea 줄바꿈 문자열이면 배열로 변환
* (한 줄당 도메인 하나, 빈 줄 제거, trim)
* 3. cookie_categories 의 키 중 necessary 제외한 모든 키가 blocked_domains 에
* 존재하도록 빈 배열로 자동 보충 (운영자가 새 카테고리 추가 시 UI iteration
* 미렌더·검증 실패 방지)
*
* @return void
*/
protected function prepareForValidation(): void
{
$cookieCategories = $this->normalizeJsonArray($this->input('cookie_categories'));
$blockedDomains = $this->normalizeBlockedDomains(
$this->input('blocked_domains'),
is_array($cookieCategories) ? $cookieCategories : [],
);
$merge = ['cookie_categories' => $cookieCategories];
if ($blockedDomains !== null) {
$merge['blocked_domains'] = $blockedDomains;
}
$this->merge($merge);
}
/**
* blocked_domains 입력을 정규화합니다.
*
* 카테고리별 값이 string 이면 줄바꿈으로 split → trim → 빈 항목 제거.
* 이미 array 면 그대로 보존. cookie_categories 의 키 (necessary 제외) 가
* blocked_domains 에 없으면 빈 배열로 보충하여 UI iteration·검증 실패를 방지합니다.
*
* @param mixed $input blocked_domains 입력값
* @param array $cookieCategories cookie_categories 정규화된 배열
* @return array<string, array<int, string>>|null
*/
private function normalizeBlockedDomains(mixed $input, array $cookieCategories): ?array
{
if ($input === null) {
return null;
}
if (! is_array($input)) {
return null;
}
$normalized = [];
foreach ($input as $category => $value) {
if (is_string($value)) {
$normalized[$category] = collect(preg_split('/\R/', $value))
->map(fn ($line) => is_string($line) ? trim($line) : '')
->filter(fn ($line) => $line !== '')
->values()
->all();
} elseif (is_array($value)) {
$normalized[$category] = array_values(array_filter(
array_map(fn ($line) => is_string($line) ? trim($line) : '', $value),
fn ($line) => $line !== '',
));
} else {
$normalized[$category] = [];
}
}
// cookie_categories 의 키 (necessary 제외) 가 blocked_domains 에 없으면 빈 배열로 보충
foreach ($cookieCategories as $category) {
$key = is_array($category) ? ($category['key'] ?? null) : null;
if (! is_string($key) || $key === '' || $key === 'necessary') {
continue;
}
if (! array_key_exists($key, $normalized)) {
$normalized[$key] = [];
}
}
return $normalized;
}
/**
* 검증 후 cookie_categories 내 key 중복 차단.
*
* @param \Illuminate\Validation\Validator $validator
* @return void
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Validation\Validator $validator) {
$categories = (array) $this->input('cookie_categories', []);
$keys = array_column($categories, 'key');
if (count($keys) !== count(array_unique($keys))) {
$validator->errors()->add(
'cookie_categories',
__('sirsoft-gdpr::messages.consent.invalid_key')
);
}
});
}
/**
* JSON 문자열 또는 배열을 array 로 정규화합니다.
*
* @param mixed $value 입력 값
* @return array|null
*/
private function normalizeJsonArray(mixed $value): ?array
{
if ($value === null) {
return null;
}
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
}
@@ -0,0 +1,99 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
/**
* GDPR 관리자 동의 로그 API 리소스
*
* `gdpr_user_consent_histories` 행을 관리자 화면용으로 변환합니다.
* IP/User-Agent까지 노출하여 DPO 감사 조회를 지원하며,
* 사용자 정보는 whenLoaded()로 관계 로딩 시에만 포함됩니다.
*/
class GdprConsentLogResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'session_id' => $this->session_id,
'consent_key' => $this->consent_key,
'action' => $this->action,
'source' => $this->source,
'policy_version' => $this->policy_version,
'categories' => $this->categories,
'categories_snapshot' => $this->buildCategoriesSnapshot(),
'ip_address' => $this->ip_address,
'user_agent' => $this->user_agent,
'created_at' => $this->formatDateTimeStringForUser($this->created_at),
'user' => $this->whenLoaded('user', fn () => $this->user !== null ? [
'id' => $this->user->id,
'uuid' => $this->user->uuid,
'name' => $this->user->name,
'email' => $this->user->email,
] : null),
];
}
/**
* 카테고리 스냅샷 표시 순서 — 필수 → 분석 → 마케팅 (위계: 거부 불가 → 선택 항목).
*
* MySQL JSON 컬럼이 키를 알파벳 순으로 정규화 저장하므로 (analytics → marketing → necessary)
* Resource 단계에서 UX 위계 순서로 재정렬한다. 카탈로그 외 키는 본 배열의 마지막에 원래 순서로 노출.
*/
private const SNAPSHOT_PRIORITY = [
'cookie_necessary',
'cookie_analytics',
'cookie_marketing',
];
/**
* 카테고리 스냅샷을 iteration 친화 배열로 변환합니다.
*
* 저장 형식 {"cookie_necessary": true, "cookie_analytics": false} 는
* G7 layout iteration 이 객체 순회를 지원하지 않으므로 admin 화면 표시용 배열로 변환합니다.
* 라벨은 프론트가 `consent.category_{key}` 다국어 키로 해석합니다 (이미 정의된 키 재사용).
*
* @return array<int, array{key: string, label_key: string, granted: bool}>|null
*/
protected function buildCategoriesSnapshot(): ?array
{
$categories = $this->categories;
if (! is_array($categories) || $categories === []) {
return null;
}
$ordered = [];
foreach (self::SNAPSHOT_PRIORITY as $priorityKey) {
if (array_key_exists($priorityKey, $categories)) {
$ordered[$priorityKey] = $categories[$priorityKey];
}
}
foreach ($categories as $key => $granted) {
if (! array_key_exists($key, $ordered)) {
$ordered[$key] = $granted;
}
}
$snapshot = [];
foreach ($ordered as $key => $granted) {
$snapshot[] = [
'key' => (string) $key,
'label_key' => 'sirsoft-gdpr.consent.category_'.$key,
'granted' => (bool) $granted,
];
}
return $snapshot;
}
}
@@ -0,0 +1,83 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
/**
* GDPR 정책 버전 detail API 리소스 (admin snapshot 모달용)
*
* `gdpr_policy_versions` 단일 행을 admin 동의 이력 / 정책 버전 이력 화면의
* snapshot 모달용으로 변환합니다. 목록용 `GdprPolicyVersionResource` 와 달리
* `snapshot` 본문 (cookie_categories / privacy_policy_slug / blocked_domains)
* 을 포함하여 DPO 가 그 시점 정책 본문을 즉시 확인 가능 (Art.7(1) 입증 책임).
*
* 발행 운영자 정보 (id/uuid/name/email) 는 createdBy 관계가 로드된 경우만 포함됩니다.
*/
class GdprPolicyVersionDetailResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'version' => $this->version,
'change_type' => $this->change_type->value,
'memo' => $this->memo,
'created_at' => $this->formatDateTimeStringForUser($this->created_at),
// 발행 시점 settings snapshot 전체 (immutable). admin 권한 가드를 통과한 호출자만 접근.
'snapshot' => $this->snapshot,
// 차단 도메인 카탈로그를 프론트 iteration 친화 배열로 변환:
// { analytics: [...], marketing: [...] } → [{ category, domains }, ...]
// G7 표현식 엔진이 Object.entries 미지원이라 객체 직접 iteration 불가 → 백엔드에서 변환.
'blocked_domains_list' => $this->mapBlockedDomainsToArray(),
// 발행 운영자 정보 (관계 로드 시에만). raw FK (created_by) 는 노출하지 않고
// 관계 데이터 (uuid/name/email) 만 노출.
'publisher' => $this->whenLoaded('createdBy', fn () => $this->createdBy !== null ? [
'uuid' => $this->createdBy->uuid,
'name' => $this->createdBy->name,
'email' => $this->createdBy->email,
] : null),
...$this->resourceMeta($request),
];
}
/**
* snapshot.blocked_domains 의 카테고리→도메인 객체를 프론트 iteration 친화 배열로 변환.
*
* 입력: ['analytics' => ['google-analytics.com', ...], 'marketing' => [...]]
* 출력: [['category' => 'analytics', 'domains' => [...]], ['category' => 'marketing', 'domains' => [...]]]
*
* 카테고리 순서는 입력 키 순서를 보존 (PHP 연관 배열 특성).
* snapshot.blocked_domains 가 없거나 객체가 아니면 빈 배열 반환.
*
* @return array<int, array{category: string, domains: array<int, string>}>
*/
private function mapBlockedDomainsToArray(): array
{
$blockedDomains = $this->snapshot['blocked_domains'] ?? null;
if (! is_array($blockedDomains)) {
return [];
}
$result = [];
foreach ($blockedDomains as $category => $domains) {
if (! is_array($domains)) {
continue;
}
$result[] = [
'category' => (string) $category,
'domains' => array_values(array_map('strval', $domains)),
];
}
return $result;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
/**
* GDPR 정책 버전 API 리소스
*
* `gdpr_policy_versions` 단일 행을 관리자 이력 모달용으로 변환합니다.
* 발행 운영자 정보 (id/uuid/name/email) 는 createdBy 관계가 로드된 경우만 포함됩니다.
*/
class GdprPolicyVersionResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'version' => $this->version,
'change_type' => $this->change_type->value,
'memo' => $this->memo,
'created_at' => $this->formatDateTimeStringForUser($this->created_at),
// 발행 운영자 정보 (관계 로드 시에만). raw FK (created_by) 는 노출하지 않고
// 관계 데이터 (uuid/name/email) 만 노출 — 민감 정보 노출 최소화.
'publisher' => $this->whenLoaded('createdBy', fn () => $this->createdBy !== null ? [
'uuid' => $this->createdBy->uuid,
'name' => $this->createdBy->name,
'email' => $this->createdBy->email,
] : null),
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
/**
* GDPR 동의 변경 이력 API 리소스
*
* `gdpr_user_consent_histories` (immutable append-only) 단일 행을 응답으로 변환.
* 마이페이지의 동의 이력 표시 + DPO 감사 조회용.
*
* 민감 정보 처리:
* - 사용자(마이페이지) 컨텍스트는 자기 IP/UA를 그대로 보여줘도 무방
* - 관리자(consent log) 컨텍스트는 GdprConsentLogResource 별도 사용
*/
class GdprUserConsentHistoryResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'consent_key' => $this->consent_key,
'action' => $this->action,
'source' => $this->source,
'policy_version' => $this->policy_version,
'categories' => $this->categories,
'created_at' => $this->formatDateTimeStringForUser($this->created_at),
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Services\CookieCategoryService;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
/**
* GDPR 사용자 현재 동의 상태 API 리소스
*
* `gdpr_user_consents` (status, mutable) 단일 레코드를 응답 형식으로 변환.
* 회원의 활성/철회 상태를 마이페이지·로그인 직후 동기화에 노출합니다.
*
* `consent_label` 은 카탈로그(쿠키 카테고리) 다국어 라벨을 현재 locale 기준으로 변환한 값으로,
* 마이페이지 「내 동의 현황」 에서 raw key (cookie_analytics) 대신 사람 친화 표기를 노출하기 위함.
*
* `needs_renewal_this_item` 은 *선택형 항목 중 정책 버전이 옛 버전인* 행에서 true — 마이페이지
* 액션 컬럼이 "다시 동의" 대신 "최신 정책으로 갱신" 분기를 표시할 때 사용 (#21).
*/
class GdprUserConsentResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$categoryService = app(CookieCategoryService::class);
$consentService = app(GdprConsentService::class);
$consentKey = (string) $this->consent_key;
$isRequired = $categoryService->isRequired($consentKey);
$isConsented = (bool) $this->is_consented;
$currentPolicyVersion = $consentService->getCurrentPolicyVersion();
$policyVersion = (string) ($this->policy_version ?? '');
// 선택형 항목 중 정책 버전이 옛 버전인지 — 필수는 ePrivacy Art.5(3) 면제로 갱신 대상 아님.
$needsRenewalThisItem = ! $isRequired && $isConsented && $policyVersion !== '' && $policyVersion !== $currentPolicyVersion;
return [
'id' => $this->id,
'consent_key' => $consentKey,
'consent_label' => $categoryService->getLabelForKey($consentKey),
'consent_description' => $categoryService->getDescriptionForKey($consentKey),
'consent_category' => $this->consent_category,
'is_required' => $isRequired,
'is_consented' => $isConsented,
// Art.7(3) 대칭성 + ePrivacy Art.5(3) 동의 면제 패턴 — 필수 카테고리는 철회/재동의 불가, 선택형만 양방향.
'can_revoke' => ! $isRequired && $isConsented,
// 활성 동의 + 옛 버전 → 갱신 의도. 활성 동의 + 최신 버전 → 갱신 불요 (이미 최신).
// 비활성 (철회 또는 신규) → 다시 동의 (기존 동작).
'can_grant' => ! $isRequired && (! $isConsented || $needsRenewalThisItem),
'needs_renewal_this_item' => $needsRenewalThisItem,
'consented_at' => $this->formatDateTimeStringForUser($this->consented_at),
'revoked_at' => $this->formatDateTimeStringForUser($this->revoked_at),
'consent_count' => (int) $this->consent_count,
'policy_version' => $this->policy_version,
'last_source' => $this->last_source,
...$this->formatTimestamps(),
];
}
}
Loaded 100 of 3582 files, more files were not shown because too many files have changed in this diff. Show more