v7.0.0 release
This commit is contained in:
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/", "./"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+9
-9
@@ -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": [
|
||||
{
|
||||
|
||||
+10
-22
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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": {}
|
||||
|
||||
+46
@@ -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",
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
+1
-1
@@ -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": "주소 검색",
|
||||
|
||||
+31
-31
@@ -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·클라우드 리전 코드) 자동 거부
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
+53
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
+54
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
+73
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+700
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+267
@@ -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));
|
||||
}
|
||||
+402
@@ -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);
|
||||
}
|
||||
@@ -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
+414
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+253
@@ -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');
|
||||
}
|
||||
}
|
||||
+97
@@ -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];
|
||||
}
|
||||
}
|
||||
+136
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+170
@@ -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
Reference in new issue
Block a user