From ef13a1ddffcc0bfe8457629d6fc745c4d67a06b0 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 15 Jul 2026 17:49:21 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(gdpr):=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20+=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=86=A0=EA=B8=80=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STEP 1~7 (거부 기능): - 쿠키 배너에 「동의하지 않고 계속하기」 거부 버튼 추가 (intent=reject) - is_rejected/rejected_at 컬럼 + ConsentAction Enum 3분기 (동의/철회/거부) - 관리자 동의 이력에 [거부] 필터 + amber 배지 STEP A~F (토글 재설계): - 필수 카테고리를 동의 축에서 완전 제외 (status/history 미저장) — ePrivacy Art.5(3) 면제 - 마이페이지 표→토글 전환: 필수=「항상 켜짐」 잠금, 선택=on/off 스위치, 동의일 제거, 「모두 켜기」 바 - 거부 토스트 문구 정정: consent.granted → rejected_saved (배너 + 컨트롤러 응답) 테스트: 백엔드 201/201, 프론트 288/288, 시나리오 커버리지 audit 통과 Chrome MCP 실측: 토글 grant/revoke/모두켜기 + 배너 거부 토스트 + 거부 직후 마이페이지 표기 확인 --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 17 ++ plugins/_bundled/sirsoft-gdpr/composer.json | 2 +- ...01_add_rejection_to_gdpr_user_consents.php | 54 +++++ .../sirsoft-gdpr/docs/api/consent-log.md | 4 +- .../_bundled/sirsoft-gdpr/docs/api/consent.md | 23 +- .../sirsoft-gdpr/lang/en/messages.php | 11 +- .../sirsoft-gdpr/lang/ko/messages.php | 11 +- plugins/_bundled/sirsoft-gdpr/package.json | 2 +- plugins/_bundled/sirsoft-gdpr/plugin.json | 2 +- .../resources/extensions/cookie_banner.json | 147 ++++++------ .../extensions/mypage_privacy_tab.json | 186 ++++++++++----- .../layouts/admin_plugin_settings.test.tsx | 12 +- .../__tests__/layouts/cookie_banner.test.tsx | 79 ++++++- .../layouts/gdpr_consent_log.test.tsx | 34 ++- .../layouts/mypage_privacy_tab.test.tsx | 121 ++++++---- .../sirsoft-gdpr/resources/lang/en.json | 19 +- .../sirsoft-gdpr/resources/lang/ko.json | 19 +- .../layouts/admin/gdpr_consent_log.json | 51 +++- .../sirsoft-gdpr/src/Enums/ConsentAction.php | 49 ++++ .../Public/GdprCookieConsentController.php | 12 +- .../Requests/StoreCookieConsentRequest.php | 3 + .../Resources/GdprUserConsentResource.php | 3 + .../src/Models/GdprUserConsent.php | 6 + .../GdprUserConsentRepository.php | 2 + .../src/Services/GdprConsentService.php | 66 ++++-- .../GdprCookieConsentControllerTest.php | 143 +++++++++++ ...UserConsentRejectionMigrationSmokeTest.php | 90 +++++++ .../tests/Unit/Enums/ConsentActionTest.php | 44 ++++ .../Unit/Services/GdprConsentServiceTest.php | 222 +++++++++++++++++- .../tests/scenarios/cookie-rejection.yaml | 102 ++++++++ 30 files changed, 1287 insertions(+), 249 deletions(-) create mode 100644 plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php create mode 100644 plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 78208d97..8e738382 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,6 +4,23 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-14 + +### Added + +- 쿠키 배너에 「동의하지 않고 계속하기」 거부 버튼 추가 — 선택 항목(기능·분석·마케팅)에 동의하지 않고 계속 이용할 수 있습니다. (sir.kr 커뮤니티의 시린♡ 님께서 제보해주셨습니다.) +- 거부한 이력을 동의·철회와 구분해 관리자 「GDPR 동의 이력」에서 별도 배지와 [거부] 필터로 확인할 수 있습니다. +- 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다. +- 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다. + +### Changed + +- 쿠키 배너 버튼을 정리했습니다 — 「필수만 사용」을 제거하고, 「환경설정」을 「선택 동의」로, 펼침 패널의 저장 버튼을 「선택 저장」으로 변경했습니다. +- 마이페이지 「쿠키 동의 관리」를 상태 배지 + 동의/철회 버튼 방식으로 개선했습니다 — 각 항목이 「동의함/거부함/항상 적용」 상태로 표시되고, 버튼을 누르면 동의·철회 여부를 한 번 더 확인합니다. 항목명도 「기능 쿠키」처럼 알아보기 쉽게 표기합니다. +- 마이페이지 「쿠키 동의 관리」의 글자 크기·여백이 어긋나던 문제를 바로잡고, 동의한 항목에는 동의한 날짜를 함께 표시합니다. +- 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. +- 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). + ## [1.0.1] - 2026-07-16 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/composer.json b/plugins/_bundled/sirsoft-gdpr/composer.json index 7a887d75..8bb80961 100644 --- a/plugins/_bundled/sirsoft-gdpr/composer.json +++ b/plugins/_bundled/sirsoft-gdpr/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-gdpr", "description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "authors": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php new file mode 100644 index 00000000..ff52cc25 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php @@ -0,0 +1,54 @@ +boolean('is_rejected') + ->default(false) + ->after('is_consented') + ->comment('명시적 거부 여부 (선택형 카테고리를 사용자가 명시적으로 거부한 상태)'); + } + + if (! Schema::hasColumn('gdpr_user_consents', 'rejected_at')) { + $table->timestamp('rejected_at') + ->nullable() + ->after('revoked_at') + ->comment('최근 명시적 거부 일시'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('gdpr_user_consents')) { + return; + } + + Schema::table('gdpr_user_consents', function (Blueprint $table) { + if (Schema::hasColumn('gdpr_user_consents', 'rejected_at')) { + $table->dropColumn('rejected_at'); + } + + if (Schema::hasColumn('gdpr_user_consents', 'is_rejected')) { + $table->dropColumn('is_rejected'); + } + }); + } +}; diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md index 305824e1..8e019ebf 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md @@ -46,7 +46,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | user_id | integer | `130` | user 식별자 (연관 리소스 참조) | | session_id | string | `c50916f6-58ee-42a6-a046-60a0c9ecfad2` | session 식별자 (연관 리소스 참조) | | consent_key | string | `cookie_marketing` | 변경된 동의 항목 키 (쿠키 카테고리 키). 어떤 동의 항목의 부여/철회가 기록된 행인지 나타냅니다 | -| action | string | `revoked` | 이 이력 행의 변경 유형 (`granted`=동의 부여, `revoked`=철회, `acknowledged`=확인). Art.7(1) 입증 트레일의 행위 구분입니다 | +| action | string | `revoked` | 이 이력 행의 변경 유형 (`granted`=동의 부여, `revoked`=철회, `rejected`=명시적 거부). `rejected`는 배너에서 "동의하지 않고 계속하기"로 선택형 카테고리를 거부한 행위이며 관리자 화면에 amber 배지로 표시됩니다. Art.7(1) 입증 트레일의 행위 구분입니다 | | source | string | `banner` | 동의 변경이 발생한 경로 (`banner`=쿠키 배너, `preference_center`=환경설정 센터, `mypage`=마이페이지, 그 외 `register`/`order`/`withdraw`) | | policy_version | string | `10` | 변경 시점의 정책 버전 문자열. 해당 동의가 어느 정책 버전 기준으로 표명되었는지 기록해 정책 갱신 후 재동의 판정과 감사에 사용됩니다 | | categories | object | `{"cookie_analytics":false,"cookie_marketing":false,"cooki…` | 변경 시점 전체 카테고리별 동의 여부 스냅샷 (키→boolean). MySQL JSON 컬럼이라 키가 알파벳 순으로 정규화 저장됩니다 | @@ -89,6 +89,6 @@ HTTP/1.1 200 -**설명** 관리자 감사 화면에서 `gdpr_user_consent_histories` 테이블의 동의 로그를 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `email`, `session_id`, `consent_keys[]`, `actions[]`(granted|revoked), `sources[]`(banner|preference_center|mypage), `per_page`(1~100, 기본 20) 쿼리 필터를 지원합니다. DPO 감사 용도로 IP 주소와 User-Agent까지 노출되는 조회 전용 엔드포인트입니다. +**설명** 관리자 감사 화면에서 `gdpr_user_consent_histories` 테이블의 동의 로그를 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `email`, `session_id`, `consent_keys[]`, `actions[]`(granted|revoked|rejected), `sources[]`(banner|preference_center|mypage), `per_page`(1~100, 기본 20) 쿼리 필터를 지원합니다. DPO 감사 용도로 IP 주소와 User-Agent까지 노출되는 조회 전용 엔드포인트입니다. diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md index fafef2f0..90a5047b 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -23,9 +23,13 @@ - **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Public\GdprCookieConsentController@store` - **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) -**요청 파라미터** +**요청 파라미터** (Body) -_요청 파라미터 없음._ +| 파라미터 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| consents | object | 예 | `{ "cookie_<카테고리키>": bool }` 형태의 동의 매트릭스. 최소 1개 이상. 필수 카테고리에 `false`를 보내면 거부됩니다(422) | +| source | string | 예 | 변경 경로. `banner` / `preference_center` / `register` / `mypage` 중 하나 | +| intent | string | 아니오 | 명시적 거부 신호. `reject` 만 허용합니다. 전송 시 선택형 미동의 항목(`false`)을 `is_rejected=true`로 저장하고 history `action=rejected`로 기록합니다. 미전송 시 미동의는 `revoked`로 기록됩니다(기존 동작). 배너의 "동의하지 않고 계속하기" 버튼이 전송합니다 | **요청 예시** @@ -33,7 +37,18 @@ _요청 파라미터 없음._ POST /api/plugins/sirsoft-gdpr/consent/cookie HTTP/1.1 Host: api.example.com Accept: application/json +Content-Type: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) + +{ + "consents": { + "cookie_necessary": true, + "cookie_analytics": false, + "cookie_marketing": false + }, + "source": "banner", + "intent": "reject" +} ``` **응답 필드** (`data` 내부) @@ -176,7 +191,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| histories | array | `[]` | 회원 본인의 동의 변경 이력 배열. 각 항목은 `consent_key`, `action`(granted/revoked), `source`, `policy_version`, `categories`, `created_at`로 구성되며 부여/철회 기록을 시간순으로 제공합니다 | +| histories | array | `[]` | 회원 본인의 동의 변경 이력 배열. 각 항목은 `consent_key`, `action`(granted/revoked/rejected), `source`, `policy_version`, `categories`, `created_at`로 구성되며 부여/철회/거부 기록을 시간순으로 제공합니다. `rejected`는 배너에서 명시적으로 거부(동의하지 않고 계속하기)한 항목입니다 | **응답 예시** @@ -233,7 +248,7 @@ _단건 응답: `data` 객체의 필드._ | user_id | integer | `166` | user 식별자 (연관 리소스 참조) | | needs_renewal | boolean | `false` | 회원의 활성 동의 중 옛 정책 버전인 항목이 있어 재동의가 필요한지 여부. 마이페이지가 「전체 다시 동의」 안내를 노출할지 판단합니다 | | current_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열. 각 동의 항목의 `policy_version`과 비교해 항목별 갱신 필요 여부를 계산하는 기준입니다 | -| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다 | +| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 명시적 거부 여부(`is_rejected`)와 거부 일시(`rejected_at`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다. `is_rejected=true`는 마이페이지에서 「거부」 배지로 표시됩니다 | **응답 예시** diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php index 207734d5..47ea17c5 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php @@ -4,6 +4,7 @@ return [ 'consent' => [ 'granted' => 'Consent has been saved.', 'granted_again' => 'Consent has been renewed.', + 'rejected_saved' => 'Continuing without consent. Optional items will not be used.', 'revoked' => 'Consent has been revoked.', 'renew_all_success' => '{renewed} active consent item(s) renewed to the new policy version.', 'granting' => 'Granting...', @@ -189,6 +190,7 @@ return [ 'action' => [ 'granted' => 'Granted', 'revoked' => 'Revoked', + 'rejected' => 'Rejected', ], 'source' => [ 'banner' => 'Banner', @@ -210,7 +212,7 @@ return [ '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', + 'snapshot_revoked' => 'Not Consented', ], ], ], @@ -228,6 +230,7 @@ return [ 'col_action' => 'Action', 'consented' => 'Consented', 'revoked' => 'Revoked', + 'rejected_label' => 'Rejected', 'revoke' => 'Revoke', 'grant' => 'Consent', 'grant_again' => 'Consent Again', @@ -255,9 +258,9 @@ return [ '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', + 'reject_continue' => 'Continue without consent', + 'preferences' => 'Select Consent', + 'save' => 'Save Selection', 'policy_link' => 'Privacy Policy', 'preferences_title' => 'Cookie Preferences', 'preferences_description' => 'You can grant or revoke each category individually. Required categories cannot be turned off.', diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php index fd170659..f5329dfa 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php @@ -4,6 +4,7 @@ return [ 'consent' => [ 'granted' => '동의가 저장되었습니다.', 'granted_again' => '동의가 갱신되었습니다.', + 'rejected_saved' => '동의하지 않고 계속합니다. 선택 항목은 사용되지 않습니다.', 'revoked' => '동의가 철회되었습니다.', 'renew_all_success' => '활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.', 'granting' => '동의 중...', @@ -189,6 +190,7 @@ return [ 'action' => [ 'granted' => '동의', 'revoked' => '철회', + 'rejected' => '거부', ], 'source' => [ 'banner' => '배너', @@ -210,7 +212,7 @@ return [ 'categories_label' => '카테고리 스냅샷', 'categories_hint' => '동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.', 'snapshot_granted' => '동의', - 'snapshot_revoked' => '거부', + 'snapshot_revoked' => '미동의', ], ], ], @@ -228,6 +230,7 @@ return [ 'col_action' => '작업', 'consented' => '동의', 'revoked' => '철회', + 'rejected_label' => '거부', 'revoke' => '철회', 'grant' => '동의', 'grant_again' => '다시 동의', @@ -255,9 +258,9 @@ return [ 'title' => '쿠키 사용 안내', 'description' => '본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.', 'accept_all' => '모두 동의', - 'reject_all' => '필수만 사용', - 'preferences' => '환경설정', - 'save' => '선택 동의', + 'reject_continue' => '동의하지 않고 계속하기', + 'preferences' => '선택 동의', + 'save' => '선택 저장', 'policy_link' => '개인정보처리방침', 'preferences_title' => '쿠키 환경설정', 'preferences_description' => '카테고리별 쿠키 사용을 개별 동의/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.', diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index e4cea08b..31c6df25 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -1,6 +1,6 @@ { "name": "@plugins/sirsoft-gdpr", - "version": "1.0.1", + "version": "1.0.2", "private": true, "type": "module", "scripts": { diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index 7ad4cd8f..04d9368c 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -5,7 +5,7 @@ "ko": "GDPR (일반 데이터 보호 규정)", "en": "GDPR (General Data Protection Regulation)" }, - "version": "1.0.1", + "version": "1.0.2", "description": { "ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.", "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json index af112652..b7e8057a 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json @@ -150,7 +150,78 @@ }, "children": [ { - "comment": "환경설정 토글 — 배너 내부 인라인 펼침 영역을 열고 닫음", + "comment": "동의하지 않고 계속하기 (거부, 이슈 #430) — 텍스트/링크형(배경 없음). required 카테고리만 true 로 저장 + intent:'reject' 신호로 선택형 미동의 항목을 is_rejected=true 로 기록 후 배너 닫기. 기존 '필수만 사용'(reject_all) 버튼을 대체. 식별자 reject 로 정리.", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!!_global.gdprBannerSubmittingAction}}", + "className": "px-2 py-2 text-sm text-gray-500 dark:text-gray-400 underline hover:no-underline hover:text-gray-700 dark:hover:text-gray-200 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center bg-transparent" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "reject" } }, + { + "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', intent: 'reject' }}}" + }, + "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" + }, + { + "comment": "거부 전용 토스트 (이슈 #430) — 거부인데 '동의가 저장' 문구가 뜨던 오표기 수정.", + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-gdpr.consent.rejected_saved" + } + } + ], + "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'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } }, + { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'reject'}}", "text": "$t:sirsoft-gdpr.banner.reject_continue" }, + { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'reject'}}", "text": "$t:sirsoft-gdpr.consent.granting" } + ] + }, + { + "comment": "선택 동의 토글 (이슈 #430 라벨 변경: 환경설정→선택 동의) — 배너 내부 인라인 펼침 영역을 열고 닫음. 버튼 순서: 거부 다음, 모두 동의 앞 (제보자 예시/업계 관행 — 거부 → 선택 → 전체). banner.preferences 키 유지, 값만 '선택 동의' 로 변경(STEP6).", "id": "gdpr_preferences_toggle_button", "type": "basic", "name": "Button", @@ -177,77 +248,7 @@ ] }, { - "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 명확한 긍정 행위 부합). 위치: 환경설정/필수만 사용 다음, 모두 동의 앞.", + "comment": "현재 동의 갱신 (회원) — needs_renewal=true 한정. 회원은 status 테이블에 동의가 영속되므로 renew-all 로 활성 동의의 policy_version 만 신정책으로 bump (사용자 의사 변경 없음, Art.7(3) 자유 변경권 + Recital 32 명확한 긍정 행위 부합). 위치: 선택 동의 다음, 모두 동의 앞.", "id": "gdpr_keep_consent_button", "type": "basic", "name": "Button", @@ -369,7 +370,7 @@ "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" + "className": "mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 text-tertiary" }, "children": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json index cae23066..1f5d46a0 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json @@ -347,98 +347,165 @@ "text": "$t:sirsoft-gdpr.mypage.privacy.no_consents" }, { + "comment": "「모두 켜기」 일괄 동의 바 — 선택형(is_required=false) 중 꺼진(is_consented=false) 항목이 하나라도 있을 때만 노출. 필수는 대상 외. renew-all 이 아니라 신규/재동의 축이므로 grant 를 항목별로 반복 호출하는 대신, 서버 renew-all 은 '이미 활성 항목의 정책 갱신' 전용이라 여기서는 각 꺼진 선택형 항목을 grant 로 순차 켠다. 다만 레이아웃 반복 호출은 표현이 어렵기에, 배너의 '모두 동의' 와 동일하게 consent/cookie(intent 없음, 전체 true) 로 한 번에 처리.", + "type": "basic", + "name": "Div", + "if": "{{(gdprMeConsents?.data?.consents ?? []).filter(c => c?.is_required !== true && c?.is_consented !== true).length > 0}}", + "props": { "className": "flex items-center justify-between gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-sm text-blue-800 dark:text-blue-200" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.enable_all_hint" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!!_local.enablingAll}}", + "className": "shrink-0 px-3 py-1.5 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", "enablingAll": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-gdpr/consent/cookie", + "params": { + "method": "POST", + "body": "{{{ source: 'mypage', consents: (gdprMeConsents?.data?.consents ?? []).reduce((acc, c) => (c?.is_required === true ? acc : { ...acc, [c.consent_key]: true }), {}) }}}" + }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "enablingAll": null } }, + { "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.enable_all_success" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "enablingAll": null } }, + { "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "if": "{{!!_local.enablingAll}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.enable_all" } + ] + } + ] + }, + { + "comment": "토글 리스트 (이슈 #430 재설계) — 업계 표준 CMP(OneTrust/Cookiebot) 패턴: 필수=잠긴 '항상 켜짐', 선택=on/off 스위치. 동의일 컬럼은 사용자 화면에서 제거(감사 로그/관리자 화면에만 존재). 스위치는 Checkbox/Toggle 컴포넌트 부재로 클릭 가능한 Div 로 구현 — 필수는 클릭 액션 없이 시각적 잠금.", "type": "basic", "name": "Div", "if": "{{(gdprMeConsents?.data?.consents ?? []).length > 0}}", + "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": "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" - }, + "props": { "className": "flex items-center justify-between gap-4 px-4 py-3" }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "grid grid-cols-12 gap-2 px-4 py-2 items-center text-sm" }, + "comment": "라벨 + 사용자 친화 1줄 설명 (raw key 대신 카테고리 의미 노출).", + "props": { "className": "min-w-0 flex-1" }, "children": [ { "type": "basic", "name": "Div", - "comment": "라벨 + 사용자 친화 1줄 설명. 회원이 영문 식별자(cookie_analytics) 대신 카테고리 의미를 즉시 이해할 수 있도록 카탈로그 description (CookieCategoryService::getDescriptionForKey) 을 함께 노출.", - "props": { "className": "col-span-6" }, + "props": { "className": "flex items-center gap-2" }, "children": [ { "type": "basic", "name": "Span", - "props": { "className": "text-primary block font-medium" }, + "props": { "className": "text-primary font-medium text-sm" }, "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'}}" + "if": "{{consent?.is_required === true}}", + "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.required_label" } ] }, { "type": "basic", "name": "Span", - "props": { "className": "text-secondary col-span-2 text-xs" }, - "text": "{{consent?.consented_at ?? '-'}}" - }, + "if": "{{!!consent?.consent_description}}", + "props": { "className": "block text-tertiary mt-0.5 text-xs leading-relaxed" }, + "text": "{{consent?.consent_description ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "shrink-0 flex items-center gap-2" }, + "children": [ { - "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 라 클라이언트는 단순 분기.", + "comment": "필수 카테고리 — 잠긴 '항상 켜짐' 스위치. 클릭 액션 없음(철회 불가, ePrivacy Art.5(3) 면제). 파란 채움 + 오른쪽 knob + not-allowed 커서로 잠금 표현.", "type": "basic", "name": "Div", - "props": { "className": "flex-center col-span-2 justify-center gap-2" }, + "if": "{{consent?.is_required === true}}", + "props": { "className": "flex items-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" + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.always_on" }, { + "type": "basic", + "name": "Div", + "props": { "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600/60 dark:bg-blue-500/60 cursor-not-allowed" }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "lock", "size": "xs", "className": "absolute left-1.5 text-white/80" } }, + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } + ] + } + ] + }, + { + "comment": "선택형 카테고리 — on/off 스위치. 켜짐(is_consented=true)이면 녹색+revoke 확인 모달, 꺼짐이면 회색+grant 즉시 호출. grantingKey 로 해당 행만 처리 중 표시.", + "type": "basic", + "name": "Div", + "if": "{{consent?.is_required !== true}}", + "props": { "className": "flex items-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium {{consent?.is_consented ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'}}" }, + "text": "{{consent?.is_consented ? '$t:sirsoft-gdpr.mypage.privacy.toggle_on' : '$t:sirsoft-gdpr.mypage.privacy.toggle_off'}}" + }, + { + "comment": "켜짐 상태 스위치 — 클릭 시 철회 확인 모달. 녹색 채움 + knob 오른쪽.", "type": "basic", "name": "Button", - "if": "{{consent?.can_revoke === true}}", + "if": "{{consent?.is_consented === 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" + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-green-500 dark:bg-green-600 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-1" }, "actions": [ { @@ -446,7 +513,6 @@ "handler": "sequence", "actions": [ { - "comment": "모달은 별도 컨텍스트라 부모 _local 미해석 — _global 사용 필수. revokeTargetKey/Label/Description 3개를 동시 세팅하여 모달이 라벨 + 친화 설명을 표시할 수 있게 함 (raw key 노출은 사용자에게 무의미하므로 description 으로 대체)", "handler": "setState", "params": { "target": "global", @@ -460,18 +526,19 @@ } ], "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.revoke" } + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } ] }, { + "comment": "꺼짐 상태 스위치 — 클릭 시 즉시 grant. 회색 채움 + knob 왼쪽. 정책 갱신 항목도 별도 색 분기 없이 grant 로 통합(정책 갱신도 결국 동의 표명, grant 는 항상 현재 정책 버전 기록).", "type": "basic", "name": "Button", - "if": "{{consent?.can_grant === true}}", - "comment": "3분기 (#21) — needs_renewal_this_item: 최신 정책으로 갱신 / consent_count>0: 다시 동의 / 그 외: 신규 동의. needs_renewal 분기는 다른 버튼 색 (amber) 으로 시각적 구분. grantingKey 비교로 클릭한 해당 행만 spinner 노출 + 다른 행 클릭은 자동 차단.", + "if": "{{consent?.is_consented !== true}}", "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'}}" + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "disabled": "{{_local.grantingKey === consent?.consent_key}}", + "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-wait" }, "actions": [ { @@ -503,14 +570,7 @@ } ], "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" } + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-1 rounded-full bg-white shadow transition-transform" } } ] } ] diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index f7a3a964..e177dc81 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -274,8 +274,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).toContain('border-l-4'); expect(serialized).toContain('border-l-blue-500'); expect(serialized).toContain('circle-info'); - // 한 줄 + 중간 정렬 - expect(serialized).toContain('flex items-center'); + // 한 줄 + 중간 정렬 (시맨틱 flex-center 로 통일 — #399) + expect(serialized).toContain('flex-center'); // 다크모드 가독성: 카드(gray-800)보다 한 단계 어두운 gray-900 expect(serialized).toContain('dark:bg-gray-900'); @@ -298,8 +298,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au 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'); + // 타이틀 강조 — 시맨틱 text-error-strong 로 제목 강조 (#399 시맨틱 통일) + expect(serialized).toContain('text-error-strong'); // 옛 badge 키는 lang 과 박스에서 모두 제거됨 expect(serialized).not.toContain('blocked_domains.warnings_badge'); // 기존 옛 항목 키 미사용 @@ -427,8 +427,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au 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'); + // 에러 시 시맨틱 input-error 로 통일 (#399 시맨틱 통일 — 옛 border-red-500/focus:ring-red-500 원시 클래스 대체) + expect(serialized).toContain('input-error'); // 필드 아래 에러 Span — if 가드 + 첫 메시지 text (코어 단순 패턴) expect(serialized).toContain(`_local.errors?.['${formField}']?.[0]`); // 에러 Span 시각 시맨틱 (form-error) — 텍스트 톤 + 줄바꿈을 한 결로 통일 (#399) diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index 70f8bbfc..8cfc2227 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -228,10 +228,61 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(text).toContain('!(gdprMyConsent?.data?.is_member === true && gdprMyConsent?.data?.needs_renewal === true)'); }); - it('"필수만 사용" 시 required 카테고리만 true (cookie_ prefix)', () => { + it('"동의하지 않고 계속하기"(거부) 시 required 카테고리만 true (cookie_ prefix)', () => { + // 이슈 #430 — 기존 '필수만 사용'(reject_all) 을 거부 버튼으로 대체. body 는 필수만 true 로 재활용. expect(text).toContain("['cookie_' + c.key]: c.required === true"); }); + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_reject_button_sends_intent_reject + */ + it('거부 버튼은 POST body 에 intent:\'reject\' 신호를 실어 보낸다 (이슈 #430)', () => { + // 서버가 선택형 미동의 항목을 is_rejected=true 로 저장하도록 하는 명시적 거부 신호. + expect(text).toContain("intent: 'reject'"); + }); + + it('거부 버튼 라벨은 banner.reject_continue, 기존 banner.reject_all 은 제거됨', () => { + expect(text).toContain('sirsoft-gdpr.banner.reject_continue'); + expect(text).not.toContain('sirsoft-gdpr.banner.reject_all'); + }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_reject_toast_uses_rejected_saved + */ + it('거부 버튼 성공 토스트는 consent.rejected_saved 를 쓴다 (거부인데 "동의가 저장" 오표기 정정, 이슈 #430)', () => { + // 거부 버튼 apiCall 노드를 격리해 검증 — 배너 전체 text 에는 다른 버튼의 consent.granted 도 있으므로 + // intent:'reject' 를 body 에 가진 apiCall 서브트리 안에서만 rejected_saved 를 확인한다. + const findRejectApiCall = (node: unknown): Record | null => { + if (!node || typeof node !== 'object') return null; + const obj = node as Record; + const body = (obj.params as { body?: string } | undefined)?.body; + if (obj.handler === 'apiCall' && typeof body === 'string' && body.includes("intent: 'reject'")) { + return obj; + } + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + for (const item of value) { + const found = findRejectApiCall(item); + if (found) return found; + } + } else if (value && typeof value === 'object') { + const found = findRejectApiCall(value); + if (found) return found; + } + } + return null; + }; + + const rejectApiCall = findRejectApiCall(banner); + expect(rejectApiCall).toBeTruthy(); + const rejectSubtree = serializeForSearch(rejectApiCall); + expect(rejectSubtree).toContain('sirsoft-gdpr.consent.rejected_saved'); + // 거부 버튼 서브트리에는 "동의가 저장"(consent.granted) 토스트가 남아있지 않아야 함 + expect(rejectSubtree).not.toContain('$t:sirsoft-gdpr.consent.granted"'); + }); + it('POST body 키는 백엔드 검증 규칙(StoreCookieConsentRequest)과 일치 — consents + source 사용', () => { // 회귀 가드: 백엔드는 consents (객체, cookie_ prefix 키) + source ("banner") 를 요구한다. // 과거 categories / policy_version 으로 잘못 보내서 422 "유효하지 않은 동의 항목입니다." 반환됨. @@ -283,9 +334,10 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 회귀 가드: 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어 // 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만 // 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지. - // 4 action 식별자: accept_all / reject_all / save_selection / renew_consent + // 4 action 식별자: accept_all / reject / save_selection / renew_consent + // (이슈 #430 — reject_all 식별자를 reject 로 정리) expect(text).toContain('"gdprBannerSubmittingAction": "accept_all"'); - expect(text).toContain('"gdprBannerSubmittingAction": "reject_all"'); + expect(text).toContain('"gdprBannerSubmittingAction": "reject"'); expect(text).toContain('"gdprBannerSubmittingAction": "save_selection"'); expect(text).toContain('"gdprBannerSubmittingAction": "renew_consent"'); @@ -295,7 +347,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 스피너 if 가드는 자기 식별자와 정확히 일치할 때만 노출 expect(text).toContain("_global.gdprBannerSubmittingAction === 'accept_all'"); - expect(text).toContain("_global.gdprBannerSubmittingAction === 'reject_all'"); + expect(text).toContain("_global.gdprBannerSubmittingAction === 'reject'"); expect(text).toContain("_global.gdprBannerSubmittingAction === 'save_selection'"); expect(text).toContain("_global.gdprBannerSubmittingAction === 'renew_consent'"); @@ -308,6 +360,25 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 옛 단일 boolean 플래그 패턴 회귀 차단 expect(text).not.toContain('"gdprBannerSubmitting": true'); }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_button_order_reject_accept_preferences + */ + it('버튼 순서 (이슈 #430): 거부 → 선택 동의 토글 → 모두 동의', () => { + // 제보자 예시/업계 관행 (거부 → 선택 → 전체) 순서. + // reject_continue(거부) → preferences(선택 동의 토글) → accept_all(모두 동의) 순으로 등장해야 한다. + const rejectIdx = text.indexOf('sirsoft-gdpr.banner.reject_continue'); + const prefIdx = text.indexOf('sirsoft-gdpr.banner.preferences'); + const acceptIdx = text.indexOf('sirsoft-gdpr.banner.accept_all'); + + expect(rejectIdx).toBeGreaterThanOrEqual(0); + expect(prefIdx).toBeGreaterThanOrEqual(0); + expect(acceptIdx).toBeGreaterThanOrEqual(0); + + expect(rejectIdx).toBeLessThan(prefIdx); + expect(prefIdx).toBeLessThan(acceptIdx); + }); }); describe('환경설정 인라인 펼침 패널', () => { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx index 89d81463..b8072610 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx @@ -120,12 +120,23 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `consent_keys[${idx}]`)); }); - it('action 필터의 체크박스 3개 모두 옵션 C 패턴', () => { + it('action 필터의 체크박스 4개 모두 옵션 C 패턴 (이슈 #430 거부 추가)', () => { const checkboxes = findCheckboxesIn('action_filter'); - expect(checkboxes.length, '체크박스 3개 (전체 / granted / revoked)').toBe(3); + // 전체 / granted / revoked / rejected — 이슈 #430 에서 거부(rejected) 필터 추가 + expect(checkboxes.length, '체크박스 4개 (전체 / granted / revoked / rejected)').toBe(4); checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `action[${idx}]`)); }); + /** + * @scenario entry=reject, subject=member, category=optional + * @effects admin_log_filter_rejected_checkbox + */ + it('action 필터에 거부(rejected) 체크박스가 존재한다 (이슈 #430)', () => { + const layoutJson = JSON.stringify(root); + expect(layoutJson).toContain("'rejected'"); + expect(layoutJson).toContain('sirsoft-gdpr.admin.consent_log.action.rejected'); + }); + it('source 필터의 체크박스 4개 모두 옵션 C 패턴', () => { const checkboxes = findCheckboxesIn('source_filter'); expect(checkboxes.length, '체크박스 4개 (전체 / banner / preference_center / mypage)').toBe(4); @@ -264,4 +275,23 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 expect(layoutJson).not.toContain('dark:bg-green-900/40'); }); }); + + describe('action 배지 — 거부(rejected) 케이스 (이슈 #430)', () => { + const layoutJson = JSON.stringify(root); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects admin_log_action_badge_rejected_amber + */ + it('action 배지 className 분기에 rejected → amber 색이 존재한다', () => { + // granted=green / rejected=amber / revoked=red / 그 외=gray + expect(layoutJson).toContain("row.action === 'rejected'"); + expect(layoutJson).toContain('bg-amber-100'); + }); + + it('action 배지 텍스트는 동적으로 action 값에 따라 lang 키를 해석한다 (rejected 포함)', () => { + // {{$t('sirsoft-gdpr.admin.consent_log.action.' + (row.action ?? 'granted'))}} + expect(layoutJson).toContain('sirsoft-gdpr.admin.consent_log.action.'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx index 5a1173b7..6fba4b7f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx @@ -265,66 +265,99 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); }); - describe('동의 현황 표 컬럼 구성', () => { + describe('토글 UI 구조 (이슈 #430 재설계 — 업계 표준 CMP 패턴)', () => { const section = card ? findById(card, 'gdpr_section_consents') : null; - it('표 헤더에 col_consent_key / col_consented / col_consented_at / col_action 컬럼이 있다', () => { + /** + * @scenario entry=reject, subject=member, category=required + * @effects mypage_no_consent_date_column_for_user + */ + it('사용자 화면에 동의일 컬럼이 없다 (감사/관리자 전용) — col_consented_at / col_action 헤더 제거', () => { + // 재설계 회귀 가드: 표 헤더(col_consent_key/col_consented/col_consented_at/col_action) 전면 제거. + // 동의일은 GDPR 감사 로그·관리자 화면에만 존재하고 사용자 토글 화면엔 노출하지 않음. 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'); + expect(text).not.toContain('mypage.privacy.col_consented_at'); + expect(text).not.toContain('mypage.privacy.col_action'); + // 행 단위 consented_at 바인딩도 사용자 화면에서 제거 + expect(text).not.toContain('consent?.consented_at'); }); - }); - describe('동의 매트릭스 액션 4분기 (Art.7(3) 대칭성 + ePrivacy Art.5(3))', () => { - const section = card ? findById(card, 'gdpr_section_consents') : null; - - it('필수 카테고리 행 — 「필수」 라벨 노출 (철회/재동의 둘 다 무의미)', () => { + /** + * @scenario entry=reject, subject=member, category=required + * @effects mypage_required_shows_locked_always_on_toggle + */ + it('필수 카테고리 — 잠긴 「항상 켜짐」 토글 (액션 없음, lock 아이콘 + not-allowed)', () => { const text = serializeForSearch(section); expect(text).toContain('consent?.is_required === true'); + expect(text).toContain('mypage.privacy.always_on'); + // 잠금 시각 표현: lock 아이콘 + not-allowed 커서 + expect(text).toContain('"name": "lock"'); + expect(text).toContain('cursor-not-allowed'); + // 필수 행에는 required_label 배지도 노출 expect(text).toContain('mypage.privacy.required_label'); }); - it('선택형 활성 행 — can_revoke=true 일 때 「철회」 빨간 버튼', () => { + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_optional_shows_on_off_toggle + */ + it('선택형 켜짐(is_consented) — 녹색 토글 + 「켜짐」 라벨, 클릭 시 철회 확인 모달', () => { const text = serializeForSearch(section); - expect(text).toContain('consent?.can_revoke === true'); - expect(text).toContain('bg-red-600'); + expect(text).toContain('consent?.is_consented === true'); + expect(text).toContain('mypage.privacy.toggle_on'); + // 켜짐 토글은 녹색 + expect(text).toContain('bg-green-500'); + // 클릭 시 철회 확인 모달 오픈 (즉시 철회 아님 — 오조작 방지) + expect(text).toContain('"target": "revoke_confirm_modal"'); }); - 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 + 토스트', () => { + it('선택형 꺼짐(!is_consented) — 회색 토글 + 「꺼짐」 라벨, 클릭 시 즉시 grant', () => { const text = serializeForSearch(section); + expect(text).toContain('consent?.is_consented !== true'); + expect(text).toContain('mypage.privacy.toggle_off'); + // 꺼짐 토글은 회색 + expect(text).toContain('bg-gray-300'); + // 클릭 시 POST /consent/grant 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 쓰기가 - // 통과됨. 누락 시 새로고침 전까지 차단 유지 (동의 즉시성 약화). + it('grant onSuccess 에서 refetch + syncConsent + 토스트 호출', () => { + // 회귀 가드: 꺼짐→켜짐 시 interceptor functionalConsented 즉시 갱신 (동의 즉시성). const text = serializeForSearch(section); - // 액션 컬럼 안의 grant 버튼이 syncConsent 호출하는지 검증. - // 섹션에는 revoke 버튼도 있어 toContain 중복 일치 가능 — match 회수로 정확히 검증. - // 액션 컬럼 sequence 가 모달을 열도록 setState 만 한 revoke 버튼과 달리, - // grant 버튼은 직접 apiCall 하므로 onSuccess 가 명시되어 있음. + expect(text).toContain('"dataSourceId": "gdprMeConsents"'); 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 (전역 진행 중 표시). + it('꺼짐 토글에 grantingKey 비교 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'); + }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_enable_all_bar_when_optional_off + */ + it('꺼진 선택 항목이 있을 때만 「모두 켜기」 바 노출 + POST /consent/cookie 로 일괄 동의', () => { + const text = serializeForSearch(section); + // 노출 조건: 선택형(is_required !== true) 중 꺼진(is_consented !== true) 항목 존재 + expect(text).toContain("c?.is_required !== true && c?.is_consented !== true"); + expect(text).toContain('mypage.privacy.enable_all'); + expect(text).toContain('mypage.privacy.enable_all_hint'); + // 일괄 동의는 배너 「모두 동의」와 동일한 consent/cookie 경로 (필수 제외, prefixed key) + expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/cookie"'); + expect(text).toContain('c.consent_key'); + expect(text).toContain('mypage.privacy.enable_all_success'); + }); + + it('제거된 표 배지/버튼 구조가 부활하지 않는다 (회귀 가드)', () => { + // 재설계로 상태 배지(bg-amber-100)·철회 빨간 버튼(bg-red-600 버튼)·can_grant/can_revoke 분기가 + // 토글로 대체됨. can_grant/can_revoke 메타는 서버 Resource 에 남아있으나 레이아웃은 미참조. + const text = serializeForSearch(section); + expect(text).not.toContain('consent?.can_revoke === true'); + expect(text).not.toContain('consent?.can_grant === true'); + expect(text).not.toContain('mypage.privacy.rejected_label'); + expect(text).not.toContain('needs_renewal_this_item'); }); }); @@ -386,17 +419,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); }); - 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'); - }); - }); + // 「최신 정책으로 갱신」 항목별 amber 분기(#21)는 토글 재설계(#430)로 제거됨. + // 정책 갱신은 카드 상단 amber 박스(gdpr_needs_renewal_banner)의 「전체 항목 다시 동의」로 통합되고, + // 개별 항목은 단순 on/off 토글로 켜면 최신 정책 버전으로 grant 된다. (grant 는 항상 현재 정책 버전 기록) }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index 95930133..ae46dac7 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -15,6 +15,7 @@ "consent": { "granted": "Consent has been saved.", "granted_again": "Consent has been renewed.", + "rejected_saved": "Continuing without consent. Optional items will not be used.", "revoked": "Consent has been revoked.", "renew_all_success": "{renewed} active consent item(s) renewed to the new policy version.", "granting": "Granting...", @@ -225,7 +226,8 @@ }, "action": { "granted": "Granted", - "revoked": "Revoked" + "revoked": "Revoked", + "rejected": "Rejected" }, "source": { "banner": "Banner", @@ -247,7 +249,7 @@ "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" + "snapshot_revoked": "Not Consented" } } }, @@ -265,9 +267,16 @@ "col_action": "Action", "consented": "Consented", "revoked": "Revoked", + "rejected_label": "Rejected", "revoke": "Revoke", "grant": "Consent", "grant_again": "Consent Again", + "always_on": "Always On", + "toggle_on": "On", + "toggle_off": "Off", + "enable_all": "Enable All", + "enable_all_hint": "You have optional items turned off. Enable them all at once.", + "enable_all_success": "All optional items have been enabled.", "required_label": "Required", "required_hint": "This item is required for service operation and cannot be revoked.", "revoke_confirm_title": "Revoke Consent", @@ -295,9 +304,9 @@ "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", + "reject_continue": "Continue without consent", + "preferences": "Select Consent", + "save": "Save Selection", "policy_link": "Privacy Policy", "preferences_title": "Cookie Preferences", "preferences_description": "You can grant or revoke each category individually. Required categories cannot be turned off.", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index fcec964c..e31291fb 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -15,6 +15,7 @@ "consent": { "granted": "동의가 저장되었습니다.", "granted_again": "동의가 갱신되었습니다.", + "rejected_saved": "동의하지 않고 계속합니다. 선택 항목은 사용되지 않습니다.", "revoked": "동의가 철회되었습니다.", "renew_all_success": "활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.", "granting": "동의 중...", @@ -225,7 +226,8 @@ }, "action": { "granted": "동의", - "revoked": "철회" + "revoked": "철회", + "rejected": "거부" }, "source": { "banner": "배너", @@ -247,7 +249,7 @@ "categories_label": "카테고리 스냅샷", "categories_hint": "동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.", "snapshot_granted": "동의", - "snapshot_revoked": "거부" + "snapshot_revoked": "미동의" } } }, @@ -265,9 +267,16 @@ "col_action": "작업", "consented": "동의", "revoked": "철회", + "rejected_label": "거부", "revoke": "철회", "grant": "동의", "grant_again": "다시 동의", + "always_on": "항상 켜짐", + "toggle_on": "켜짐", + "toggle_off": "꺼짐", + "enable_all": "모두 켜기", + "enable_all_hint": "동의하지 않은 선택 항목이 있습니다. 한 번에 모두 켤 수 있습니다.", + "enable_all_success": "선택 항목을 모두 켰습니다.", "required_label": "필수", "required_hint": "서비스 제공에 필수적이라 철회할 수 없습니다.", "revoke_confirm_title": "동의 철회 확인", @@ -295,9 +304,9 @@ "title": "쿠키 사용 안내", "description": "본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.", "accept_all": "모두 동의", - "reject_all": "필수만 사용", - "preferences": "환경설정", - "save": "선택 동의", + "reject_continue": "동의하지 않고 계속하기", + "preferences": "선택 동의", + "save": "선택 저장", "policy_link": "개인정보처리방침", "preferences_title": "쿠키 환경설정", "preferences_description": "카테고리별 쿠키 사용을 개별 동의\/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json index 99096059..ba88ddf5 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json @@ -215,7 +215,7 @@ "name": "Icon", "props": { "name": "fa-solid fa-arrows-rotate", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -769,6 +769,53 @@ "text": "$t:sirsoft-gdpr.admin.consent_log.action.revoked" } ] + }, + { + "comment": "거부 필터 (이슈 #430) — action=rejected 행만 조회. 백엔드 actions[] whereIn 이 rejected 를 자동 지원하므로 백엔드 무수정.", + "type": "basic", + "name": "Label", + "props": { + "className": "inline-clickable" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "checkbox", + "className": "checkbox", + "checked": "{{(_local.filter.actions || []).includes('rejected')}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter.actions": "{{$event.target.checked ? [...(_local.filter.actions || []).filter(a => a !== 'rejected'), 'rejected'] : (_local.filter.actions || []).filter(a => a !== 'rejected')}}" + } + }, + { + "actionRef": "searchConsentLogs" + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label" + }, + "text": "$t:sirsoft-gdpr.admin.consent_log.action.rejected" + } + ] } ] } @@ -1257,7 +1304,7 @@ "type": "basic", "name": "Span", "props": { - "className": "{{row.action === 'granted' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : (row.action === 'revoked' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300')}}" + "className": "{{row.action === 'granted' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : (row.action === 'rejected' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : (row.action === 'revoked' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'))}}" }, "text": "{{$t('sirsoft-gdpr.admin.consent_log.action.' + (row.action ?? 'granted'))}}" } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php new file mode 100644 index 00000000..921251ff --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php @@ -0,0 +1,49 @@ + + */ + public static function allValues(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php index 69435b3f..d73c39be 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php @@ -42,6 +42,8 @@ class GdprCookieConsentController extends PublicBaseController { $consents = (array) $request->validated('consents'); $source = (string) $request->validated('source'); + // 명시적 거부 신호 (이슈 #430) — 'reject' 이면 선택형 미동의 항목을 is_rejected=true 로 저장. + $isRejection = $request->validated('intent') === 'reject'; $userId = $request->user()?->id; $sessionId = null; @@ -54,9 +56,15 @@ class GdprCookieConsentController extends PublicBaseController } } - $this->consentService->updateConsents($userId, $sessionId, $consents, $source); + $this->consentService->updateConsents($userId, $sessionId, $consents, $source, $isRejection); - $response = ResponseHelper::success('sirsoft-gdpr::messages.consent.granted', [ + // 거부(intent=reject)와 일반 동의 저장의 응답 메시지를 구분 (이슈 #430) — + // 거부인데 "동의가 저장되었습니다" 로 응답하던 오표기 정정. 프론트 토스트도 동일 키 사용. + $messageKey = $isRejection + ? 'sirsoft-gdpr::messages.consent.rejected_saved' + : 'sirsoft-gdpr::messages.consent.granted'; + + $response = ResponseHelper::success($messageKey, [ 'user_id' => $userId, 'session_id' => $sessionId, 'consents' => $consents, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php index ac2f1f46..8846f945 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php @@ -46,6 +46,9 @@ class StoreCookieConsentRequest extends FormRequest 'consents' => ['required', 'array', 'min:1'], 'consents.*' => ['required', 'boolean'], 'source' => ['required', 'string', 'in:banner,preference_center,register,mypage'], + // 명시적 거부 신호 (이슈 #430). 배너 "동의하지 않고 계속하기" 버튼이 전송. + // 미전송(null) 시 일반 저장(기존 동작). 값은 'reject' 만 허용. + 'intent' => ['sometimes', 'nullable', 'string', 'in:reject'], ]; } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php index a1ebc4fe..05b51579 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php @@ -47,6 +47,9 @@ class GdprUserConsentResource extends BaseApiResource 'consent_category' => $this->consent_category, 'is_required' => $isRequired, 'is_consented' => $isConsented, + // 이슈 #430 — 명시적 거부 상태. 마이페이지 배지 3분화(동의/철회/거부) 에 사용. + 'is_rejected' => (bool) $this->is_rejected, + 'rejected_at' => $this->formatDateTimeStringForUser($this->rejected_at), // Art.7(3) 대칭성 + ePrivacy Art.5(3) 동의 면제 패턴 — 필수 카테고리는 철회/재동의 불가, 선택형만 양방향. 'can_revoke' => ! $isRequired && $isConsented, // 활성 동의 + 옛 버전 → 갱신 의도. 활성 동의 + 최신 버전 → 갱신 불요 (이미 최신). diff --git a/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php b/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php index 0f97b49c..fcfc3c94 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php @@ -18,8 +18,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $consent_key * @property string|null $consent_category * @property bool $is_consented + * @property bool $is_rejected * @property \Carbon\Carbon|null $consented_at * @property \Carbon\Carbon|null $revoked_at + * @property \Carbon\Carbon|null $rejected_at * @property int $consent_count * @property string|null $policy_version * @property string|null $last_source @@ -46,8 +48,10 @@ class GdprUserConsent extends Model 'consent_key', 'consent_category', 'is_consented', + 'is_rejected', 'consented_at', 'revoked_at', + 'rejected_at', 'consent_count', 'policy_version', 'last_source', @@ -62,8 +66,10 @@ class GdprUserConsent extends Model { return [ 'is_consented' => 'boolean', + 'is_rejected' => 'boolean', 'consented_at' => 'datetime', 'revoked_at' => 'datetime', + 'rejected_at' => 'datetime', 'consent_count' => 'integer', ]; } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php index f8c133a6..1af4bb23 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php @@ -64,9 +64,11 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface $row->consent_key = $consentKey; $row->consent_category = $consentCategory; $row->is_consented = false; + $row->is_rejected = false; $row->consent_count = 0; $row->consented_at = null; $row->revoked_at = null; + $row->rejected_at = null; $row->policy_version = null; $row->last_source = null; diff --git a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php index 038f11c3..714c81e7 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php @@ -6,6 +6,7 @@ use App\Extension\HookManager; use App\Services\PluginSettingsService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Plugins\Sirsoft\Gdpr\Enums\ConsentAction; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsentHistory; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; @@ -73,6 +74,7 @@ class GdprConsentService * @param bool $value 동의 여부 * @param string $source 변경 경로 (banner/preference_center/register/mypage/order/withdraw) * @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시) + * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @return void */ public function updateConsent( @@ -82,29 +84,49 @@ class GdprConsentService bool $value, string $source, ?array $categories = null, + bool $isRejection = false, ): void { $policyVersion = $this->getCurrentPolicyVersion(); $now = now(); + // 항목별 거부 여부 — 값이 false 이면서 거부 신호가 온 경우만 거부. + // 필수 항목은 value=true 로 전달되므로 자동으로 거부 대상에서 제외된다. + $newRejected = $isRejection && ! $value; + $consent = null; - if ($userId !== null) { + // 필수 카테고리는 "동의 축"에서 제외한다 (이슈 #430 후속 — 업계 표준 정합). + // 필수 쿠키는 ePrivacy Art.5(3) 면제로 동의 대상이 아니므로, "현재 상태"(status) 테이블에 + // 동의/거부로 저장하지 않는다. 저장하면 "동의하지 않았는데 동의로 표시"되는 모순이 생긴다. + // 단, history(이력)에는 "이 시점에 배너에서 결정을 내렸다"는 사실을 남긴다 (GDPR Art.7(1) 입증). + // 배너 표시 조건(hasCurrentCookieConsent)은 선택형 status 행만 있어도 충족되고, + // 자동 차단 엔진(blocker)은 필수를 status 무관하게 항상 허용하므로 필수 status 부재가 안전하다. + $isRequiredCategory = $this->categoryService->isRequired($consentKey); + + if ($userId !== null && ! $isRequiredCategory) { $existing = $this->statusRepository->findByUserAndKey($userId, $consentKey); // 동일 상태 중복 처리 방지 (이력 중복 INSERT 방지). // policy_version 동일 조건 추가 — 정책 bump 후 같은 값 재요청은 신정책 적용 // (status.policy_version 갱신 + history INSERT) 으로 이어져야 needs_renewal 이 해소되고 // GDPR Art.7(1) 입증 책임 (신정책 동의 기록) 도 충족됨. + // is_rejected 변화 조건 추가 (이슈 #430) — 미동의(false) 상태에서 거부 신호가 오면 + // is_consented 는 그대로 false 라 기존 가드가 early-return 하여 is_rejected 저장이 누락됐다. + // 거부(rejected)와 일반 미동의(revoked)를 데이터로 구분하려면 이 조건이 필수. if ($existing && (bool) $existing->is_consented === $value + && (bool) $existing->is_rejected === $newRejected && (string) $existing->policy_version === $policyVersion) { return; } $data = [ 'is_consented' => $value, + 'is_rejected' => $newRejected, 'consented_at' => $value ? $now : ($existing?->consented_at), 'revoked_at' => $value ? null : $now, + // 거부 시각은 거부일 때만 갱신, 재동의(value=true) 시 null 로 해제. + 'rejected_at' => $newRejected ? $now : null, 'policy_version' => $policyVersion, 'last_source' => $source, 'consent_category' => $this->resolveCategory($consentKey), @@ -120,23 +142,28 @@ class GdprConsentService $consent = $this->statusRepository->upsert($userId, $consentKey, $data); } - // 호출자가 매트릭스를 명시 전달하지 않은 경우 (마이페이지 grant/revoke 등 단건 변경), - // 변경 직후 시점의 회원 동의 매트릭스를 자동 구성하여 history 에 보존합니다. - // GDPR Art.7(1) 입증 책임 — 모든 동의 변경 시점에 카테고리 전체 의사를 immutable 기록. - $snapshotCategories = $categories ?? ($userId !== null ? $this->buildCategoriesSnapshotForUser($userId) : null); + // 필수 카테고리는 동의 축에서 제외되므로 history(동의 이력)에도 기록하지 않는다. + // 필수는 "동의/거부"라는 의사표시 대상이 아니라 항상 적용되는 항목이므로, + // "동의 부여/철회/거부" 이력 자체가 성립하지 않는다. 관리자 동의이력에도 필수 행은 남지 않는다. + if (! $isRequiredCategory) { + // 호출자가 매트릭스를 명시 전달하지 않은 경우 (마이페이지 grant/revoke 등 단건 변경), + // 변경 직후 시점의 회원 동의 매트릭스를 자동 구성하여 history 에 보존합니다. + // GDPR Art.7(1) 입증 책임 — 모든 동의 변경 시점에 카테고리 전체 의사를 immutable 기록. + $snapshotCategories = $categories ?? ($userId !== null ? $this->buildCategoriesSnapshotForUser($userId) : null); - // 회원·게스트 모두 history INSERT (불변 append-only) - $this->historyRepository->record([ - 'user_id' => $userId, - 'session_id' => $sessionId, - 'consent_key' => $consentKey, - 'action' => $value ? 'granted' : 'revoked', - 'source' => $source, - 'policy_version' => $policyVersion, - 'categories' => $snapshotCategories, - 'ip_address' => request()->ip(), - 'user_agent' => substr((string) request()->userAgent(), 0, 500), - ]); + // 회원·게스트 모두 history INSERT (불변 append-only) + $this->historyRepository->record([ + 'user_id' => $userId, + 'session_id' => $sessionId, + 'consent_key' => $consentKey, + 'action' => ConsentAction::fromDecision($value, $isRejection)->value, + 'source' => $source, + 'policy_version' => $policyVersion, + 'categories' => $snapshotCategories, + 'ip_address' => request()->ip(), + 'user_agent' => substr((string) request()->userAgent(), 0, 500), + ]); + } $hookName = $value ? self::PLUGIN_ID . '.consent.granted' @@ -151,14 +178,15 @@ class GdprConsentService * @param string|null $sessionId 게스트 세션 ID * @param array $consents 동의 데이터 [key => bool] * @param string $source 변경 경로 + * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @return void */ - public function updateConsents(?int $userId, ?string $sessionId, array $consents, string $source): void + public function updateConsents(?int $userId, ?string $sessionId, array $consents, string $source, bool $isRejection = false): void { $categories = $consents; foreach ($consents as $consentKey => $value) { - $this->updateConsent($userId, $sessionId, $consentKey, (bool) $value, $source, $categories); + $this->updateConsent($userId, $sessionId, $consentKey, (bool) $value, $source, $categories, $isRejection); } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php index 3e814841..6d973e3a 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php @@ -456,4 +456,147 @@ class GdprCookieConsentControllerTest extends PluginTestCase $this->assertNotContains($name, $cookieNames, "응답에 functional cookie ({$name}) 가 발송되면 안 됨 — Phase 2 단순화"); } } + + // ===== 이슈 #430 — intent:'reject' 거부 저장 (컨트롤러 경로) ===== + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects member_rejection_intent_marks_optional_as_rejected, required_category_excluded_from_status, reject_controller_response_uses_rejected_saved_message + */ + public function test_member_rejection_intent_marks_optional_as_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + // 거부 응답 메시지 = rejected_saved (거부인데 "동의가 저장" 오표기 정정, 이슈 #430) + $response->assertJsonPath('message', __('sirsoft-gdpr::messages.consent.rejected_saved')); + + // 필수 → 동의 축 제외: status 에 저장되지 않음 (이슈 #430 후속) + $this->assertDatabaseMissing('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_necessary', + ]); + + // 선택형 → is_rejected=true, history action=rejected + $this->assertDatabaseHas('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => true, + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * @scenario entry=reject, subject=guest, category=optional + * @effects guest_rejection_intent_records_history_as_rejected + */ + public function test_guest_rejection_intent_records_history_as_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + + $this->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'consent_key' => 'cookie_marketing', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * @scenario entry=reject, subject=guest, category=required + * @effects rejection_intent_invalid_value_is_rejected_by_validation + */ + public function test_rejection_intent_invalid_value_is_rejected_by_validation(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + + // intent 는 'reject' 만 허용 — 다른 값은 422 + $this->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => ['cookie_necessary' => true], + 'source' => 'banner', + 'intent' => 'accept', + ])->assertStatus(422); + } + + /** + * @scenario entry=save_selection, subject=member, category=optional + * @effects without_intent_regression_stores_revoked_not_rejected + */ + public function test_without_intent_regression_stores_revoked_not_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + // intent 미전달 (기존 배너 동작) — 미동의는 revoked 로 기록, is_rejected=false + $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + ], + 'source' => 'banner', + ])->assertOk(); + + $this->assertDatabaseHas('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => false, + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'revoked', + ]); + } + + /** + * 거부 후 배너 미재출현 고정 — 거부해도 현재 정책 버전으로 동의(필수) 이력이 남아 + * has_consented=true 가 되어 배너가 다시 뜨지 않는다 (이슈 요구: 쿠키 배너 띄우지 않음). + * + * @scenario entry=reject, subject=member, category=required + * @effects rejection_makes_banner_not_reappear_for_member + */ + public function test_rejection_makes_banner_not_reappear_for_member(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + $status = $this->actingAs($user)->getJson('/api/plugins/sirsoft-gdpr/consent/cookie/status'); + $status->assertOk(); + $status->assertJsonPath('data.has_consented', true); + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php new file mode 100644 index 00000000..a6f12c2c --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php @@ -0,0 +1,90 @@ +assertTrue( + Schema::hasColumn('gdpr_user_consents', $column), + "gdpr_user_consents.{$column} column should exist" + ); + } + } + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects is_rejected_defaults_to_false + */ + public function test_is_rejected_defaults_to_false(): void + { + $user = \App\Models\User::factory()->create(); + + $consent = \Plugins\Sirsoft\Gdpr\Models\GdprUserConsent::create([ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'consent_category' => 'cookie', + 'is_consented' => false, + ]); + + $this->assertFalse($consent->fresh()->is_rejected, 'is_rejected 기본값은 false 여야 함'); + $this->assertNull($consent->fresh()->rejected_at, 'rejected_at 기본값은 null 이어야 함'); + } + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_migration_is_reversible + */ + public function test_rejection_migration_is_reversible(): void + { + $migration = require base_path( + 'plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php' + ); + + // down: 컬럼 제거 + $migration->down(); + + $this->assertFalse( + Schema::hasColumn('gdpr_user_consents', 'is_rejected'), + 'down 후 is_rejected 컬럼이 제거되어야 함' + ); + $this->assertFalse( + Schema::hasColumn('gdpr_user_consents', 'rejected_at'), + 'down 후 rejected_at 컬럼이 제거되어야 함' + ); + + // up: 컬럼 재추가 (왕복 안전성 — hasColumn 가드로 중복 추가 방지) + $migration->up(); + + $this->assertTrue( + Schema::hasColumn('gdpr_user_consents', 'is_rejected'), + 'up 재실행 후 is_rejected 컬럼이 복원되어야 함' + ); + $this->assertTrue( + Schema::hasColumn('gdpr_user_consents', 'rejected_at'), + 'up 재실행 후 rejected_at 컬럼이 복원되어야 함' + ); + + // up 재호출 시 hasColumn 가드로 중복 추가 예외가 발생하지 않아야 함 (idempotent) + $migration->up(); + $this->assertTrue(Schema::hasColumn('gdpr_user_consents', 'is_rejected')); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php new file mode 100644 index 00000000..8bdbc034 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php @@ -0,0 +1,44 @@ +assertSame( + ['granted', 'revoked', 'rejected'], + ConsentAction::allValues() + ); + } + + /** + * @scenario entry=accept_all, subject=member, category=required + * @effects consent_action_from_decision_granted_revoked_rejected + */ + public function test_from_decision_returns_granted_when_value_true(): void + { + // 동의 시 거부 신호와 무관하게 항상 granted (필수 항목 등) + $this->assertSame(ConsentAction::Granted, ConsentAction::fromDecision(true, false)); + $this->assertSame(ConsentAction::Granted, ConsentAction::fromDecision(true, true)); + } + + public function test_from_decision_returns_rejected_when_value_false_and_rejection(): void + { + $this->assertSame(ConsentAction::Rejected, ConsentAction::fromDecision(false, true)); + } + + public function test_from_decision_returns_revoked_when_value_false_without_rejection(): void + { + // 명시적 거부 신호가 없는 미동의는 철회로 취급 (기존 동작 유지) + $this->assertSame(ConsentAction::Revoked, ConsentAction::fromDecision(false, false)); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php index 046a2767..8647ea75 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php @@ -202,6 +202,10 @@ class GdprConsentServiceTest extends PluginTestCase /** * v1.5.0 자동 차단 엔진 SSoT 회귀 가드: * 회원의 카테고리별 동의 상태가 cookie_ 접두사 제거된 카테고리 키로 매핑되어야 함. + * + * 이슈 #430 후속 — 필수 카테고리는 동의 축에서 제외되어 status 에 저장되지 않으므로 + * 이 맵에도 등장하지 않는다. 자동 차단 엔진(blocker.ts)은 necessary 를 맵 유무와 + * 무관하게 항상 허용하므로(category==='necessary' → true) 필수 부재가 안전하다. */ public function test_get_current_cookie_consents_returns_category_map_for_member(): void { @@ -214,11 +218,13 @@ class GdprConsentServiceTest extends PluginTestCase $consents = $this->service->getCurrentCookieConsents($user->id, null); + // 필수(necessary)는 status 미저장 → 맵에서 제외. 선택형만 등장. $this->assertEqualsCanonicalizing( - ['necessary' => true, 'analytics' => true, 'marketing' => false], + ['analytics' => true, 'marketing' => false], $consents ); - $this->assertCount(3, $consents); + $this->assertCount(2, $consents); + $this->assertArrayNotHasKey('necessary', $consents); } /** @@ -331,4 +337,216 @@ class GdprConsentServiceTest extends PluginTestCase $this->assertSame(1, GdprUserConsentHistory::where('user_id', $user->id)->count()); $this->assertSame(1, GdprUserConsent::where('user_id', $user->id)->first()->consent_count); } + + // ===== 이슈 #430 — 쿠키 거부 저장 ===== + + /** + * 선택형 카테고리 거부 시 is_rejected=true, rejected_at 기록, history action=rejected. + * + * STEP3-3 중복방지 가드 결함 회귀 가드: 가드가 is_consented+policy_version 만 비교하면 + * 거부(false)와 일반 미동의(false)를 동일 취급 → is_rejected=true 저장이 누락된다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_marks_optional_consent_as_rejected + */ + public function test_rejection_marks_optional_consent_as_rejected(): void + { + $user = User::factory()->create(); + + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertNotNull($consent); + $this->assertFalse($consent->is_consented); + $this->assertTrue((bool) $consent->is_rejected, '선택형 거부는 is_rejected=true 여야 함'); + $this->assertNotNull($consent->rejected_at, 'rejected_at 이 기록되어야 함'); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * 거부 가드 결함 직접 재현: 이미 status row 가 미동의(false)로 존재해도 + * 거부 신호가 오면 is_rejected 플래그가 저장되어야 한다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_persists_even_when_status_already_false + */ + public function test_rejection_persists_even_when_status_already_false(): void + { + $user = User::factory()->create(); + + // 1) 먼저 철회로 미동의(false) 상태를 만든다 (is_rejected=false). + $this->service->updateConsent($user->id, null, 'cookie_analytics', true, 'banner'); + $this->service->updateConsent($user->id, null, 'cookie_analytics', false, 'mypage'); + + $before = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertFalse((bool) $before->is_rejected, '사전조건: 아직 거부 아님'); + + // 2) 이제 명시적 거부 신호. is_consented 는 여전히 false 지만 is_rejected 는 true 로 바뀌어야 함. + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + + $after = GdprUserConsent::where('user_id', $user->id)->first()->fresh(); + $this->assertTrue((bool) $after->is_rejected, '가드 결함: is_rejected 저장 누락 (가드에 is_rejected 비교 필요)'); + $this->assertNotNull($after->rejected_at); + } + + /** + * 필수 카테고리는 동의 축에서 제외 — status·history 모두 저장하지 않는다 (이슈 #430 후속). + * + * 필수 쿠키는 ePrivacy Art.5(3) 면제로 동의 대상이 아니다. 거부·동의 어떤 신호가 와도 + * "현재 상태"(status)나 "동의 이력"(history)에 기록하지 않는다. 필수를 저장하면 + * "동의하지 않았는데 동의로 표시"되는 모순이 생기기 때문이다. + * + * @scenario entry=reject, subject=member, category=required + * @effects required_category_excluded_from_status + */ + public function test_required_category_is_excluded_from_status_and_history(): void + { + $user = User::factory()->create(); + + // 필수에 대해 거부 신호를 포함해 호출해도 필수는 저장되지 않아야 함. + $this->service->updateConsent( + $user->id, null, 'cookie_necessary', true, 'banner', null, true + ); + + // status 미저장 + $this->assertNull( + GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_necessary')->first(), + '필수는 status 에 저장되지 않아야 함' + ); + + // history 미기록 + $this->assertDatabaseMissing('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_necessary', + ]); + } + + /** + * 거부 후 재동의 시 is_rejected 플래그 자동 해제 + rejected_at null. + * history 는 append-only 라 rejected/granted 둘 다 보존. + * + * @scenario entry=reject_then_reaccept, subject=member, category=optional + * @effects re_consent_clears_rejection_flag_but_keeps_history + */ + public function test_re_consent_clears_rejection_flag_but_keeps_history(): void + { + $user = User::factory()->create(); + + // 1) 거부 + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + // 2) 재동의 + $this->service->updateConsent($user->id, null, 'cookie_analytics', true, 'banner'); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertTrue($consent->is_consented); + $this->assertFalse((bool) $consent->is_rejected, '재동의 시 거부 플래그 자동 해제'); + $this->assertNull($consent->rejected_at, '재동의 시 rejected_at null 로 해제'); + + // history 는 rejected + granted 둘 다 보존 + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'granted', + ]); + } + + /** + * 게스트 거부는 status 미사용, history 에만 action=rejected 로 기록. + * + * @scenario entry=reject, subject=guest, category=optional + * @effects guest_rejection_records_history_only + */ + public function test_guest_rejection_records_history_only(): void + { + $sessionId = 'guest-reject-xyz'; + + $this->service->updateConsent( + null, $sessionId, 'cookie_marketing', false, 'banner', null, true + ); + + $this->assertDatabaseCount('gdpr_user_consents', 0); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'session_id' => $sessionId, + 'consent_key' => 'cookie_marketing', + 'action' => 'rejected', + ]); + } + + /** + * intent 미전달(기본값 false) 시 기존 동작 회귀 무영향 — 미동의는 revoked 로 기록. + * + * @scenario entry=save_selection, subject=member, category=optional + * @effects without_rejection_intent_false_is_revoked_not_rejected + */ + public function test_without_rejection_intent_false_is_revoked_not_rejected(): void + { + $user = User::factory()->create(); + + $this->service->updateConsent($user->id, null, 'cookie_analytics', false, 'mypage'); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertFalse((bool) $consent->is_rejected, '거부 신호 없으면 is_rejected=false'); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'action' => 'revoked', + ]); + } + + /** + * updateConsents 일괄 경로에도 거부 신호가 전달되어 선택형만 rejected 로 저장. + * (배너 "동의하지 않고 계속하기" 실제 호출 경로) + * + * 이슈 #430 후속 — 필수는 동의 축에서 제외되어 status 에 저장되지 않는다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects update_consents_batch_applies_rejection_to_optional_only, required_category_excluded_from_status + */ + public function test_update_consents_batch_applies_rejection_to_optional_only(): void + { + $user = User::factory()->create(); + + // 배너 거부: 필수=true, 선택형=false + isRejection=true + $this->service->updateConsents( + $user->id, + null, + [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'banner', + true + ); + + $necessary = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_necessary')->first(); + $analytics = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_analytics')->first(); + $marketing = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_marketing')->first(); + + // 필수는 동의 축에서 제외 → status 행 자체가 생성되지 않음 + $this->assertNull($necessary, '필수는 status 에 저장되지 않아야 함 (동의 축 제외)'); + + // 선택형만 거부(is_rejected=true) 로 저장 + $this->assertFalse($analytics->is_consented); + $this->assertTrue((bool) $analytics->is_rejected, '선택형은 거부'); + $this->assertFalse($marketing->is_consented); + $this->assertTrue((bool) $marketing->is_rejected, '선택형은 거부'); + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml new file mode 100644 index 00000000..099238ec --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml @@ -0,0 +1,102 @@ +feature: GDPR 쿠키 거부 (명시적 reject) 저장 및 표기 + +description: | + 이슈 #430 — 쿠키 배너에서 "동의하지 않고 계속하기"(거부) 를 명시적 상태로 저장하고 + 마이페이지·관리자 화면에 표기한다. + + 기존 시스템은 거부·철회·미동의를 모두 is_consented=false 로만 저장하여 구분이 불가능했다. + is_rejected 컬럼 + history action='rejected' 를 도입하여 "명시적 거부" 를 데이터로 분리한다. + + 저장 규칙: + - 선택형(기능/분석/마케팅) 거부 → is_consented=false, is_rejected=true, rejected_at=now, history action='rejected' + - 필수(necessary) → 거부 신호와 무관하게 is_consented=true, is_rejected=false, history action='granted' + (Request 가 필수 false 를 차단하므로 항상 value=true 로 전달됨) + - 재동의(모두 동의/선택 동의) → is_consented=true 로 덮어쓰고 is_rejected=false, rejected_at=null 자동 해제 + - history 는 append-only 라 rejected/granted 이력을 둘 다 보존 (GDPR Art.7(1) 입증 책임) + + 거부 신호 전달: POST /api/plugins/sirsoft-gdpr/consent/cookie body 에 intent:'reject'. + 미전달(null) 시 기존 동작(미동의는 revoked) 유지. + +axes: + entry: [reject, accept_all, save_selection, reject_then_reaccept] + subject: [member, guest] + category: [required, optional] + +exclusions: + # 재동의(reject_then_reaccept) — 게스트는 status 미사용(history-only)이라 플래그 해제 개념 부재 + - { entry: reject_then_reaccept, subject: guest, category: optional, reason: "게스트는 status 미사용 — 재동의 플래그 해제 대상 아님 (history append 만)" } + - { entry: reject_then_reaccept, subject: guest, category: required, reason: "게스트 status 미사용 + 필수는 재동의 개념 부재" } + - { entry: reject_then_reaccept, subject: member, category: required, reason: "필수는 ePrivacy Art.5(3) 면제 — 항상 granted, 거부→재동의 전이 자체가 발생하지 않음" } + + # 모두 동의(accept_all) — 거부 신호 없음(intent 미전달). is_rejected 항상 false, 이 기능(거부)의 범위 밖. + # granted 판정은 Enum 테스트(consent_action_from_decision_granted_revoked_rejected)가 커버. + - { entry: accept_all, subject: member, category: optional, reason: "모두 동의는 거부 신호 없음 — is_rejected=false 기존 동작, 거부 기능 범위 밖 (granted 는 Enum 테스트 커버)" } + - { entry: accept_all, subject: member, category: required, reason: "모두 동의 + 필수 → 항상 granted, 거부 무관" } + - { entry: accept_all, subject: guest, category: optional, reason: "게스트 모두 동의 — 거부 신호 없음, 거부 기능 범위 밖" } + - { entry: accept_all, subject: guest, category: required, reason: "게스트 모두 동의 + 필수 → granted, 거부 무관" } + + # 선택 저장(save_selection) — 필수는 저장 경로와 무관하게 항상 granted + - { entry: save_selection, subject: member, category: required, reason: "필수는 저장 경로 무관 항상 granted — 선택 저장 축은 optional 조합만 유효" } + - { entry: save_selection, subject: guest, category: required, reason: "게스트 + 필수 → granted, 저장 경로 무관" } + - { entry: save_selection, subject: guest, category: optional, reason: "게스트 선택 저장은 history-only — 회원 save_selection(without_intent 회귀 테스트)이 대표 커버, 게스트는 guest_rejection 이 history 경로 커버" } + +effects: + # 거부 저장 (Service / Controller) + - rejection_marks_optional_consent_as_rejected # 선택형 거부 → is_rejected=true + rejected_at + history rejected + - rejection_persists_even_when_status_already_false # 가드 결함 회귀: 이미 false 여도 is_rejected 저장 (핵심 수정) + - required_category_excluded_from_status # 필수는 status/history 양쪽에서 제외 (재설계 #430 — 동의 축 아님) + - guest_rejection_records_history_only # 게스트 거부는 status 미사용, history action=rejected + - member_rejection_intent_marks_optional_as_rejected # 컨트롤러 경로(회원): intent:'reject' → 선택형 rejected + - guest_rejection_intent_records_history_as_rejected # 컨트롤러 경로(게스트): history rejected + - reject_controller_response_uses_rejected_saved_message # 컨트롤러 거부 응답 메시지 = rejected_saved (동의 저장 오표기 정정) + + # 재동의 플래그 해제 (이력 보존) + - re_consent_clears_rejection_flag_but_keeps_history # 재동의 시 is_rejected=false + rejected_at=null, 이력은 rejected/granted 둘 다 + + # 회귀 무영향 (intent 미전달) + - without_rejection_intent_false_is_revoked_not_rejected # 신호 없으면 미동의는 revoked (기존 동작) + - without_intent_regression_stores_revoked_not_rejected # 컨트롤러 경로 회귀 가드 + - rejection_intent_invalid_value_is_rejected_by_validation # intent 는 'reject' 만 허용 (그 외 422) + + # 일괄 경로 (배너 실제 호출) + - update_consents_batch_applies_rejection_to_optional_only # updateConsents 일괄: 필수 granted + 선택형 rejected + + # 배너 미재출현 (이슈 요구: 쿠키 배너 띄우지 않음) + - rejection_makes_banner_not_reappear_for_member # 거부 후 has_consented=true → 배너 재출현 안 함 + + # DB 컬럼 (마이그레이션) + - rejection_columns_exist # is_rejected / rejected_at 컬럼 존재 + - is_rejected_defaults_to_false # 기본값 false / rejected_at null + - rejection_migration_is_reversible # up→down→up 왕복 안전 + + # Enum 판정 + - consent_action_from_decision_granted_revoked_rejected # value/isRejection → granted/revoked/rejected 3분기 + + # 화면 표기 (레이아웃 렌더링) + - mypage_required_shows_locked_always_on_toggle # 마이페이지 필수 = 잠긴 '항상 켜짐' 토글 (액션/동의일 없음) + - mypage_optional_shows_on_off_toggle # 마이페이지 선택 = on/off 토글 (켜짐 녹색→철회, 꺼짐 회색→동의) + - mypage_no_consent_date_column_for_user # 사용자 화면에 동의일 컬럼 미노출 (감사/관리자 전용) + - mypage_enable_all_bar_when_optional_off # 꺼진 선택 항목 있을 때 '모두 켜기' 바 노출 + - admin_log_action_badge_rejected_amber # 관리자 동의이력 rejected 배지(amber) + - admin_log_filter_rejected_checkbox # 관리자 [거부] 필터 체크박스 + - banner_reject_button_sends_intent_reject # 배너 거부 버튼이 intent:'reject' 전송 + - banner_reject_toast_uses_rejected_saved # 배너 거부 성공 토스트 = rejected_saved (동의 저장 오표기 정정) + - banner_button_order_reject_accept_preferences # 버튼 순서: 거부 > 모두 동의 > 선택 동의 토글 + +test_files: + # PHPUnit (백엔드) + - plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php + + # Vitest (프론트엔드 레이아웃 렌더링) + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx + +limitations: + - "마이페이지는 회원 전용(consent/me auth_required) 이라 게스트 거부는 화면 표기 없음 — 이슈 요구와 일치 (게스트 거부는 history 에만 기록)." + - "거부는 선택형(non-required) 카테고리에만 의미가 있다. 필수 카테고리는 ePrivacy Art.5(3) 면제로 거부 대상 아님." + - "재동의 플래그 해제는 회원 status 에만 적용. 게스트는 status 미사용이라 history append 만 이뤄진다." + - "admin 동의이력 [거부] 필터는 백엔드 actions[] whereIn 을 그대로 사용 — rejected 값이 화이트리스트 검증 없이 통과(백엔드 무수정)." From 1c6217f90d1f72b5da3d65a75945f3da68b29e4f Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 10:46:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(gdpr):=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BF=A0=ED=82=A4=20=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=B2=84=ED=8A=BC=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토글 스위치 → 상태 배지 + 동의/철회 버튼으로 재설계 (user 템플릿 미해석 클래스로 인한 스타일 회귀 + "켜짐/꺼짐" 표현 개선) - 동의 시에도 확인 모달 경유, 라벨에 "쿠키" 접미사, "모두 동의" 바 - 동의한 항목만 동의일 표시 (상태는 라벨 옆 배지가 표현) - 카테고리 설명을 코드 기본값에서 조회 — 관리자 설정 화면에 설명 편집 UI가 없어 라벨만 저장 시 설명이 유실되던 문제 해결 - 철회/동의 모달 버튼을 목록 버튼과 톤 맞춰 정리 (취소=테두리형) - cookie_banner 운영주체 라인 admin 전용 클래스 → 공통 클래스 치환 --- .../g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md | 5 + .../backend/ja/messages.php | 155 ++++---- .../frontend/ja.json | 207 +++++----- .../seed/manifest.json | 2 +- .../seed/permissions.json | 10 +- .../g7-plugin-sirsoft-gdpr-ja/seed/roles.json | 2 +- .../_bundled/sirsoft-gdpr/docs/api/consent.md | 2 +- .../sirsoft-gdpr/lang/en/messages.php | 14 + .../sirsoft-gdpr/lang/ko/messages.php | 14 + .../resources/extensions/cookie_banner.json | 2 +- .../extensions/mypage_privacy_tab.json | 363 ++++++++++++------ .../__tests__/layouts/cookie_banner.test.tsx | 9 +- .../layouts/mypage_privacy_tab.test.tsx | 139 +++++-- .../sirsoft-gdpr/resources/lang/en.json | 14 +- .../sirsoft-gdpr/resources/lang/ko.json | 14 +- .../Resources/GdprUserConsentResource.php | 40 ++ .../src/Services/CookieCategoryService.php | 9 +- .../Resources/GdprUserConsentResourceTest.php | 124 ++++++ .../Services/CookieCategoryServiceTest.php | 33 ++ .../tests/scenarios/cookie-rejection.yaml | 9 +- 20 files changed, 801 insertions(+), 366 deletions(-) create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md index 325a801a..f3eca31d 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md @@ -10,3 +10,8 @@ - 쿠키 동의 배너·약관·데이터 요청 등 사용자 화면 프론트엔드 텍스트 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. - GDPR 플러그인(sirsoft-gdpr)의 일본어 번들 언어팩 초기 제공 +- 쿠키 거부 기능 및 마이페이지 「쿠키 동의 관리」 재설계에 따른 신규 문구(상태 배지, 동의·철회 확인 모달, 거부 안내 등) 일본어 번역 추가 + +### Changed + +- 초기 토글 방식 설계에서 쓰였던 미사용 문구(항상 켜짐/켜짐/꺼짐) 번역 제거 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php index e39ea4b7..7aa37c5f 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php @@ -4,13 +4,14 @@ return [ 'consent' => [ 'granted' => '同意が保存されました。', 'granted_again' => '同意が更新されました。', - 'revoked' => '同意が撤回されました。', - 'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。', + 'rejected_saved' => '同意せずに続行します。選択項目は使用されません。', + 'revoked' => '同意が取り消されました。', + 'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。', 'granting' => '同意中...', - 'revoking' => '撤回中...', + 'revoking' => '取り消し中...', 'renewing' => '更新中...', 'already_same_state' => '既に同じ状態です。', - 'required_cannot_revoke' => '必須項目は撤回できません。', + 'required_cannot_revoke' => '必須項目は取り消すことができません。', 'invalid_key' => '無効な同意項目です。', 'category_cookie_necessary' => '必須クッキー', 'category_cookie_functional' => '機能クッキー', @@ -18,35 +19,35 @@ return [ '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' => 'カテゴリ別ドメイン一覧は配列である必要があります。', + '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' => 'クッキー同意バナーとマイページ同意管理カードの動作を管理者が制御します。', + 'description' => 'クッキー同意バナーと マイページ同意管理カードの動作を管理者が制御します。', 'section' => [ 'operator' => '運営主体 / プライバシーポリシー', - 'operator_desc' => 'クッキーバナー·ポリシーページに表示される運営主体、データ保存位置、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)', + 'operator_desc' => 'クッキーバナー・ポリシーページに表示される運営主体、データ保存場所、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)', 'cookie_banner' => 'クッキー同意バナー', 'cookie_banner_desc' => '訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一箇所で設定します。', 'cookie_banner_settings' => 'バナー表示設定', 'cookie_categories' => 'クッキーカテゴリ', 'cookie_policy_version' => 'クッキーポリシーバージョン', - 'auto_blocking' => '自動ブロックポリシー', - 'auto_blocking_desc' => 'クッキーバナーの表示が ON のときは、以下のカテゴリ別ブロックドメイン一覧の外部トラッキングリソースがユーザーの同意前まで自動ブロックされます。(個別トグルなし — バナー表示と共に自動作動)', + 'auto_blocking' => '自動ブロック設定', + 'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記のカテゴリごとのブロックドメインリストの外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(別途トグルなし — バナー表示と共に自動稼働)', ], 'nav' => [ 'operator' => '運営情報', 'cookie_banner' => 'クッキーバナー', - 'auto_blocking_policy' => '自動ブロックポリシー', + 'auto_blocking_policy' => '自動ブロック設定', ], 'status' => [ 'enabled' => '有効化', @@ -58,28 +59,28 @@ return [ 'cookie_policy_version' => [ 'label' => 'ポリシーバージョン', 'hint' => '管理者が直接入力せず、システムが自動管理します。 -• カテゴリ追加/削除、カテゴリ意味変更、ポリシーページ変更 → 新バージョン自動公開 + すべての会員再同意 -• ドメイン追加/削除、ラベル変更など単純変更 → 公開なし', +• カテゴリの追加/削除、カテゴリの意味の変更、ポリシーページの変更 → 新バージョン自動公開 + すべての会員に再同意 +• ドメインの追加/削除、ラベル変更など単純な変更 → 公開なし', ], 'privacy_policy_slug' => [ 'label' => 'プライバシーポリシーページスラッグ', - 'hint' => 'ページプラグインに登録されたプライバシーポリシーページのスラッグです。空の場合、クッキーバナーのポリシーリンクは表示されません。', + 'hint' => 'ページプラグインに登録されたプライバシーポリシーページのスラッグ。空の場合、クッキーバナーのポリシーリンクが表示されません。', 'placeholder' => '例: privacy-policy', ], 'legal_entity_name' => [ 'label' => '法人名 / 運営主体', - 'hint' => 'クッキーバナーとマイページに表示されるサイト運営主体名です。', - 'placeholder' => '例: ○○会社', + 'hint' => 'クッキーバナーと マイページに表示されるサイト運営主体名です。', + 'placeholder' => '例: (株)会社名', ], 'data_storage_location' => [ - 'label' => 'データ保存位置', - 'hint' => 'ユーザーに案内するデータ保存国を入力してください。例:「日本」「米国 (AWS)」。セキュリティ上、IPアドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスは入力しないでください。', - 'placeholder' => '例: 日本 (自社データセンター) / 米国 (AWS)', - 'sensitive_format' => 'データ保存位置に IPアドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 日本、米国) で表記してください。', + 'label' => 'データ保存場所', + 'hint' => 'ユーザーに通知するデータ保存国を入力してください。例: "日本"、"アメリカ (AWS)"。IP アドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上入力しないでください。', + 'placeholder' => '例: 日本 (自社データセンター) / アメリカ (AWS)', + 'sensitive_format' => 'データ保存場所に IP アドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 日本、アメリカ) で記載してください。', ], 'banner_enabled' => [ 'label' => 'クッキーバナー表示', - 'hint' => '有効にするとクッキー同意バナー表示 + 同意前外部トラッキング自動ブロック + マイページ同意管理カードが一括有効になります。(GDPR Art.6 「同意前処理禁止」の強制メカニズムであるブロックを個別トグルでオフにできないよう単一トグルに統合)', + 'hint' => '有効化すると、クッキー同意バナー表示 + 同意前の外部トラッキング自動ブロック + マイページ同意管理カードが一括有効化されます。(GDPR Art.6 "同意前処理禁止" の強制メカニズムであるブロックを個別トグルで無効化できないよう、単一トグルに統合)', ], 'banner_position' => [ 'label' => 'バナー位置', @@ -90,12 +91,12 @@ return [ 'option_centered_modal' => '中央モーダル', ], 'blocked_domains' => [ - 'warnings_title' => 'お知らせ', - 'auto_blocking_off_hint' => '自動ブロックはクッキーバナー表示が ON のときのみ作動します。「クッキーバナー」カードでクッキーバナー表示トグルをまず有効にしてください。', - 'warning_visual_break' => '外部フォント·スタイルシートは分類に注意してください。誤ってブロックするとページが静かに壊れる可能性があります。', + '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)', + 'warning_domain_format' => 'ドメイン形式: ドット 1 個以上を含むドメイン。ワイルドカード *.example.com 対応。シングルラベル(localhost) および多言語ドメイン(xn-- 変換) は未対応。', + 'tag_input_placeholder' => 'ドメイン入力後 Enter (例: google-analytics.com、*.hotjar.com)', 'tag_no_options' => '推奨ドメインはありません。直接入力してください。', 'category' => [ 'functional' => '機能カテゴリブロックドメイン', @@ -104,13 +105,13 @@ return [ ], ], 'cookie_categories' => [ - 'description' => '4種類のクッキーカテゴリの用途とブロック対象ツール例は、各カードの[詳細を表示]からご確認いただけます。', + 'description' => 'クッキーカテゴリ 4 種類の用途とブロックツール例は、各カードの [情報を展開] で確認できます。', 'category_required_badge' => '必須', - 'category_optional_badge' => '任意', + 'category_optional_badge' => '選択', ], ], 'save_success' => '設定が保存されました。', - 'save_error' => '設定の保存中にエラーが発生しました。', + 'save_error' => '設定保存中にエラーが発生しました。', 'validation_error' => '入力値を確認してください。', 'policy_version' => [ 'current_label' => '現在のバージョン', @@ -122,7 +123,7 @@ return [ 'publishing' => '公開中...', 'change_type' => [ 'material' => '再同意トリガー', - 'non_material' => '軽微な変更', + 'non_material' => '簡単な変更', 'initial' => '初期公開', ], 'history_modal' => [ @@ -142,27 +143,27 @@ return [ 'snapshot_view_short' => '表示', 'material_modal' => [ 'title' => 'ポリシーバージョン公開 + 再同意トリガー', - 'body' => '次の変更により、ポリシーバージョンが自動公開され、すべての会員の再同意トリガーが発生します。', - 'effect_bump' => 'ポリシーバージョン自動公開(単調増加)', - 'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面を表示', - 'effect_history' => '履歴モーダルに新しいバージョンの行を追加', - 'memo_label' => '変更理由(必須)', - 'memo_placeholder' => '例:分析カテゴリ新設(Hotjar導入)', + 'body' => '次の変更は、ポリシーバージョンの自動公開とすべての会員の再同意トリガーを発生させます。', + 'effect_bump' => 'ポリシーバージョンの自動公開(単調増加)', + 'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面が表示されます', + 'effect_history' => '履歴モーダルに新しいバージョン行を追加', + 'memo_label' => '変更理由(必須)', + 'memo_placeholder' => '例:分析カテゴリ新設(Hotjar導入)', 'confirm' => '新バージョン公開後に保存', ], 'snapshot_modal' => [ 'title' => 'ポリシーバージョン v:version 本文', - 'description' => '公開時点の設定のカテゴリ一覧、ポリシー本文スラッグ、ブロック対象ドメインなど全体の本文です。DPOが同意紛争時の証拠資料として活用します。', + 'description' => '公開時点での設定のカテゴリリスト・ポリシー本文スラッグ・ブロックドメイン等、全体の本文です。DPO が同意紛争時の立証資料として活用します。', 'section_categories' => 'クッキーカテゴリ', 'section_privacy_policy' => 'ポリシー本文スラッグ', - 'section_blocked_domains' => 'ブロック対象ドメインカタログ', + 'section_blocked_domains' => 'ブロックドメインカタログ', 'category_required_badge' => '必須', - 'no_categories' => '登録されたカテゴリはありません。', - 'no_blocked_domains' => '登録されたブロック対象ドメインはありません。', + 'no_categories' => '登録されたカテゴリがありません。', + 'no_blocked_domains' => '登録されたブロックドメインがありません。', 'view_button' => 'v:version 本文を表示', 'close' => '閉じる', 'open_policy_page' => '本文ページを開く', - 'no_policy_slug' => '登録されたポリシー本文ページはありません。', + 'no_policy_slug' => '登録されたポリシー本文ページがありません。', ], 'not_found' => '該当するポリシーバージョンが見つかりません。', ], @@ -170,16 +171,16 @@ return [ 'admin' => [ 'consent_log' => [ 'title' => 'GDPR同意履歴', - 'description' => '会員・ゲストの同意・撤回の変更履歴を照会します。IP、User-Agentなどの監査情報も表示されます。', + 'description' => '会員・ゲストの同意・撤回変更履歴を照会します。IP·User-Agent等の監査情報が一緒に表示されます。', 'refresh' => '更新', - 'empty' => '照会された同意履歴はありません。', + 'empty' => '照会された同意履歴がありません。', 'clear_filters' => 'フィルターをリセット', 'guest_label' => 'ゲスト', - 'view_member_tooltip' => '会員詳細ページへ移動', + 'view_member_tooltip' => '会員詳細ページに移動', 'search_placeholder' => 'メールアドレスまたはセッションIDで検索', 'search_button' => '検索', 'search_type' => [ - 'email' => 'メールアドレス', + 'email' => 'メール', 'session_id' => 'セッションID', ], 'filter' => [ @@ -191,6 +192,7 @@ return [ 'action' => [ 'granted' => '同意', 'revoked' => '撤回', + 'rejected' => '拒否', ], 'source' => [ 'banner' => 'バナー', @@ -212,7 +214,7 @@ return [ 'categories_label' => 'カテゴリスナップショット', 'categories_hint' => '同意時点で会員がカテゴリ別に示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。', 'snapshot_granted' => '同意', - 'snapshot_revoked' => '拒否', + 'snapshot_revoked' => '非同意', ], ], ], @@ -221,29 +223,42 @@ return [ 'title' => 'クッキー同意管理', 'description' => 'サイトが使用中のクッキー項目に対する同意を変更または撤回できます。', 'section_consents' => '私の同意状況', - 'section_consents_hint' => '同意・撤回時点に応じて即座に反映されます。必須項目は撤回することはできません。', + 'section_consents_hint' => '同意/撤回時点に従ってすぐに反映されます。必須項目は撤回できません。', 'col_consent_key' => '同意項目', 'col_consented' => 'ステータス', 'col_consented_at' => '同意日', 'col_revoked_at' => '撤回日', 'col_policy_version' => 'ポリシーバージョン', - 'col_action' => '操作', + 'col_action' => '作業', 'consented' => '同意', - 'revoked' => '撤回', - 'revoke' => '撤回', + 'revoked' => '取消', + 'rejected_label' => '拒否', + 'status' => [ + 'granted' => '同意', + 'consented' => '同意', + 'revoked' => '取消', + 'rejected' => '拒否', + ], + 'badge' => [ + 'required' => '常に適用', + 'consented' => '同意しました', + 'rejected' => '拒否しました', + 'unset' => '未設定', + ], + 'revoke' => '取消', 'grant' => '同意', 'grant_again' => '再度同意', 'required_label' => '必須', - 'required_hint' => 'サービス提供に必須のため撤回できません。', - 'revoke_confirm_title' => '同意撤回確認', - 'revoke_confirm_message' => 'この項目を撤回されますか?同意を撤回するとその カテゴリ関連機能が制限される可能性があります。', - 'no_consents' => '有効な同意項目がありません。', + 'required_hint' => 'サービス提供に必須であるため、取消することはできません。', + 'revoke_confirm_title' => '同意取消確認', + 'revoke_confirm_message' => 'このアイテムを取消しますか? 同意を取消すると、該当するカテゴリーの関連機能が制限される可能性があります。', + 'no_consents' => 'アクティブな同意項目がありません。', 'needs_renewal' => 'クッキーポリシーが変更されました。同意を更新してください。', 'needs_renewal_with_version' => 'クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。', - 'needs_renewal_hint' => '必須クッキーはサイト運営に不可欠な項目のため、ポリシー変更に関わらず常に適用されます。', + 'needs_renewal_hint' => '必須クッキーはサイト運営に必ず必要な項目のため、ポリシー変更の有無に関わらず常に適用されます。', 'btn_renew_all' => '現在の同意更新', 'btn_renew_this_item' => 'ポリシー更新', - 'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。', + 'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。', ], ], 'common' => [ @@ -254,15 +269,15 @@ return [ 're_appear_reason' => 'クッキーポリシーがバージョン :version に変更されたため、同意を再度確認します。', 'consent_required_hint' => '拒否した項目に関連する機能が制限される可能性があります。', 'btn_renew_consent' => '現在の同意更新', - 'title' => 'クッキー使用ガイド', - 'description' => '本サイトはユーザー体験の改善と分析のためクッキーを使用します。項目別に同意の可否を選択できます。', - 'accept_all' => 'すべて同意', - 'reject_all' => '必須のみ使用', - 'preferences' => '環境設定', - 'save' => '選択的同意', + 'title' => 'クッキー使用のお知らせ', + 'description' => '本サイトはユーザーエクスペリエンスの改善と分析のためクッキーを使用します。項目ごとに同意の有無を選択できます。', + 'accept_all' => 'すべてに同意', + 'reject_continue' => '同意しないで続ける', + 'preferences' => '選択同意', + 'save' => '選択を保存', 'policy_link' => 'プライバシーポリシー', 'preferences_title' => 'クッキー環境設定', - 'preferences_description' => 'カテゴリ別のクッキー使用を個別に同意/撤回できます。必須カテゴリは無効にできません。', + 'preferences_description' => 'カテゴリー別のクッキー使用を個別に同意・取消できます。必須カテゴリーはオフにできません。', 'category_required_badge' => '必須', 'view_policy_label' => '変更されたポリシー本文を表示', ], diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json index d1201c7e..359f2fd7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json @@ -15,6 +15,7 @@ "consent": { "granted": "同意が保存されました。", "granted_again": "同意が更新されました。", + "rejected_saved": "同意しないで続行します。選択項目は使用されません。", "revoked": "同意が撤回されました。", "renew_all_success": "アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。", "granting": "同意中...", @@ -29,30 +30,30 @@ "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 ワイルドカード可)", + "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": "カテゴリ別ドメイン一覧は配列である必要があります。" + "must_be_array": "カテゴリ別ドメインリストは配列である必要があります。" }, "settings": { "saved": "設定が保存されました。", "title": "GDPR設定", - "description": "クッキー同意バナーとマイページ同意管理カードの動作を運営者が制御します。", + "description": "クッキー同意バナーと本人ページ同意管理カードの動作を運営者が制御します。", "section": { - "operator": "運営者 / プライバシーポリシー", - "operator_desc": "クッキーバナー·ポリシーページに表示される運営者、データ保存場所、ポリシーページスラッグを登録します。", + "operator": "運営主体·プライバシーポリシー", + "operator_desc": "クッキーバナー·ポリシーページに表示される運営主体、データ保管位置、ポリシーページスラッグを登録します。", "cookie_banner": "クッキー同意バナー", - "cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示·位置·カテゴリ·ポリシーバージョンを一箇所で設定します。", + "cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一か所で設定します。", "cookie_banner_settings": "バナー表示設定", "cookie_categories": "クッキーカテゴリ", "cookie_policy_version": "クッキーポリシーバージョン", "auto_blocking": "自動ブロックポリシー", - "auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメイン一覧の外部追跡リソースがユーザー同意前まで自動ブロックされます。" + "auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースがユーザーの同意前まで自動ブロックされます。" }, "nav": { "operator": "運営情報", @@ -68,29 +69,29 @@ "fields": { "cookie_policy_version": { "label": "ポリシーバージョン", - "hint": "ポリシー本文、カテゴリの意味、委託者·データ保管情報が変わったら「+ 新しいバージョンを公開」をクリックしてください。公開と同時にすべての会員が次回訪問時に再同意を進めます。" + "hint": "ポリシー本文、カテゴリの意味、委託者·データ保管情報が変わった場合は「+ 新規バージョン公開」をクリックしてください。公開直後にすべての会員が次回訪問時に再同意を進めます。" }, "privacy_policy_slug": { "label": "プライバシーポリシーページスラッグ", - "hint": "ページプラグインに登録された処理方針ページのスラッグ。空の場合、クッキーバナーのポリシーリンクは表示されません。", + "hint": "ページプラグインに登録された処理方針ページのスラッグ。空白の場合、クッキーバナーのポリシーリンクが表示されません。", "placeholder": "例: privacy-policy" }, "legal_entity_name": { - "label": "法人名 / 運営者", - "hint": "クッキーバナーとマイページに表示されるサイト運営者名です。", + "label": "法人名·運営主体", + "hint": "クッキーバナーと本人ページに表示されるサイト運営主体の名前です。", "placeholder": "例: (株)会社名" }, "data_storage_location": { - "label": "データ保存場所", - "hint": "ユーザーに案内するデータ保存国を入力してください。例: \"大韓民国\"、\"アメリカ (AWS)\"。IPアドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上入力しないでください。", - "placeholder": "例: 大韓民国 (自社データセンター) / アメリカ (AWS)" + "label": "データ保管位置", + "hint": "ユーザーに案内するデータ保管国を入力してください。例: \"大韓民国\"、\"米国 (AWS)\"。IPアドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上入力しないでください。", + "placeholder": "例: 大韓民国 (自社データセンター) / 米国 (AWS)" }, "banner_enabled": { "label": "クッキーバナー表示", - "hint": "有効にするとクッキーバナー表示、同意前外部追跡自動ブロック、マイページ同意管理カードが一緒に開始されます。" + "hint": "有効化時にクッキーバナー表示、同意前外部追跡自動ブロック、本人ページ同意管理カードが一緒に開始されます。" }, "banner_position": { - "label": "バナーの位置", + "label": "バナー位置", "hint": "クッキーバナーが画面に表示される位置を選択します。", "option_bottom_bar": "下部 (全幅)", "option_bottom_left_popup": "左下ポップアップ", @@ -98,75 +99,75 @@ "option_centered_modal": "中央モーダル" }, "blocked_domains": { - "warnings_title": "注意", - "auto_blocking_off_hint": "自動ブロックはクッキーバナー表示が ON の場合のみ機能します。「クッキーバナー」カードでクッキーバナー表示トグルをまず有効にしてください。", + "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)", + "warning_domain_format": "ドメイン形式: example.com、*.example.com (ワイルドカード) 可能。localhostのように点がない名前や韓国語ドメインはサポートされていません。", + "tag_input_placeholder": "ドメイン入力後、Enter キー (例: google-analytics.com、*.hotjar.com)", "tag_no_options": "推奨ドメインがありません。直接入力してください。", "category": { "functional": "機能カテゴリブロックドメイン", "analytics": "分析カテゴリブロックドメイン", - "marketing": "マーケティングカテゴリブロックドメイン" + "marketing": "マーケティング カテゴリ 차단 도메인" } }, "cookie_categories": { - "description": "クッキーカテゴリ4種の用途とブロックツール例は、各カードの[情報を表示]で確認できます。", + "description": "クッキー カテゴリ 4種の用途と차단 도구 例は各カードの【情報を展開】で確認できます。", "category_required_badge": "必須", "category_optional_badge": "選択", "info": { - "expand": "情報を表示", - "collapse": "情報を非表示", - "description_label": "ユーザー案内文言", - "scope_label": "自動ブロック対象", - "tools_label": "代表的なツール例", + "expand": "情報を展開", + "collapse": "情報を閉じる", + "description_label": "ユーザー案内文句", + "scope_label": "自動차단 対象", + "tools_label": "代表 ツール例", "necessary": { - "scope": "自動ブロックしません。セッション・ログイントークン、カート識別子、ユーザーが登録時に選択した言語設定、クッキー同意記録など、サイト動作に必須の項目は常に許可されます。", - "tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定など(追加設定不要)" + "scope": "自動차단しません。セッション·ログイン トークン、カート識別子、ユーザーが会員登録 時に選択した言語 設定、クッキー 同意 記録など サイト動作に必ず必要な項目は常に許可されます。", + "tools": "セッション ID、認証 トークン、CSRF トークン、カート識別子、多言語 設定など(別途設定 不要)" }, "functional": { - "scope": "「自動ブロックポリシー」タブの機能カテゴリドメイン一覧に登録された外部リソースが同意前までブロックされます。また、ダークモード・通貨設定など、ユーザー利便性の保存も同意後のみ保管されます。", - "tools": "カスタマーサポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど" + "scope": "「自動차단 ポリシー」タブの機能 カテゴリ ドメイン リストに登録された外部リソースが同意前まで自動차断されます。また ダークモード·通貨 選好のような ユーザー 利便性 保存も同意後にのみ保管されます。", + "tools": "顧客サポート チャットボット(Crisp、Intercom、Tawk.to)、多言語 自動翻訳 ウィジェット、ユーザー設定 同期 サービスなど" }, "analytics": { - "scope": "「自動ブロックポリシー」タブの分析カテゴリドメイン一覧に登録された外部分析スクリプト・埋め込み・トラッキングピクセルが同意前までブロックされます。同意すると即座に復元され動作します。", - "tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、ネイバープレミアムログ分析など" + "scope": "「自動차단 ポリシー」タブの分析 カテゴリ ドメイン リストに登録された外部分析 スクリプト·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。", + "tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、ネイバー プレミアム ログ分析など" }, "marketing": { - "scope": "「自動ブロックポリシー」タブのマーケティングカテゴリドメイン一覧に登録された外部広告・埋め込み・トラッキングピクセルが同意前までブロックされます。同意すると即座に復元され動作します。", - "tools": "Meta Pixel(Facebook)、Google広告リマーケティング、カカオピクセル、YouTube動画埋め込みなど" + "scope": "「自動차단 ポリシー」タブのマーケティング カテゴリ ドメイン リストに登録された外部広告·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。", + "tools": "Meta ピクセル(Facebook)、Google 広告 リマーケティング、カカオ ピクセル、YouTube 動画 埋め込みなど" } } } }, "save_success": "設定が保存されました。", - "save_error": "設定保存中にエラーが発生しました。", + "save_error": "設定 保存 中 エラーが発生しました。", "validation_error": "入力値を確認してください。", - "unsaved_changes": "保存されていない変更があります。", + "unsaved_changes": "保存されていない変更事項があります。", "policy_version": { - "current_label": "現在のバージョン", - "published_at_label": "公開日時", + "current_label": "現在バージョン", + "published_at_label": "公開 日時", "publisher_label": "公開者", "publisher_system": "システム", - "memo_label": "変更理由", - "publish_button": "新しいバージョンを公開", - "history_toggle_show": "履歴を表示", + "memo_label": "変更 理由", + "publish_button": "新 バージョン 公開", + "history_toggle_show": "履歴を見る", "history_toggle_hide": "履歴を閉じる", - "history_empty": "公開されたポリシーバージョンがありません。", - "publish_success": "新しいポリシーバージョンが公開されました。すべての会員が次回訪問時に再同意を進めます。", - "publishing": "公開中...", - "guide_title": "いつ新しいバージョンを公開しますか?", - "guide_body": "ブロックドメイン変更、UI文言修正などの運用上の変更は新しいバージョン公開が必要ありません。", + "history_empty": "公開された ポリシー バージョンがありません。", + "publish_success": "新しい ポリシー バージョンが公開されました。すべての会員が次回 訪問時に再同意を進めます。", + "publishing": "公開 中...", + "guide_title": "いつ新しい バージョンを公開しますか?", + "guide_body": "차断 ドメイン 変更、UI 文句 修正のような運用上の変更は新しい バージョン 公開が必要ではありません。", "change_type": { - "material": "再同意トリガー", - "non_material": "シンプルな変更", - "initial": "初期公開" + "material": "再同意 トリガー", + "non_material": "単純 変更", + "initial": "初期 公開" }, "history": { "col": { "version": "バージョン", - "created_at": "公開日時", + "created_at": "公開 日時", "publisher": "公開者", "change_type": "種類", "memo": "理由", @@ -175,57 +176,58 @@ }, "snapshot_view_short": "表示", "material_modal": { - "title": "新しいポリシーバージョン公開 + 再同意トリガー", - "body": "「新しいバージョンを公開」をクリックすると即座に公開され、すべてのユーザーが次回訪問時に再同意する必要があります。", - "effect_bump": "ポリシーバージョン単調増加(例:v2 → v3)", - "effect_reconsent": "すべての会員が次回訪問時に再同意画面を表示します", - "effect_history": "履歴表に新しいバージョン行が追加されます", - "memo_label": "変更理由(必須)", - "memo_placeholder": "例:約款ページ本文修正 / 委託運用者変更", - "confirm": "新しいバージョンを公開" + "title": "新 ポリシー バージョン 公開 + 再同意 トリガー", + "body": "「新 バージョン 公開」を押すと即座に公開され、すべての ユーザーが次回 訪問時に再同意する必要があります。", + "effect_bump": "ポリシー バージョン 単調 増加(例:v2 → v3)", + "effect_reconsent": "すべての会員が次回 訪問時に再同意 画面を見ることになります", + "effect_history": "履歴 表に新しい バージョン row が追加されます", + "memo_label": "変更 理由(必須)", + "memo_placeholder": "例:規約 ページ 本文 修正/委託 運営者 変更", + "confirm": "新 バージョン 公開" }, "snapshot_modal": { - "title": "ポリシーバージョン v{version} 本文", - "description": "公開時点の設定のカテゴリ一覧・ポリシー本文スラッグ・ブロックドメインなど全体の本文です。DPOが同意紛争時に立証資料として活用します。", - "section_categories": "クッキーカテゴリ", - "section_privacy_policy": "ポリシー本文スラッグ", - "section_blocked_domains": "ブロックドメインカタログ", + "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} 本文を表示", + "no_blocked_domains": "登録された차단 ドメインがありません。", + "view_button": "v{version} 本文を見る", "close": "閉じる", - "open_policy_page": "本文ページを開く", - "no_policy_slug": "登録されたポリシー本文ページがありません。", - "editor_label": "ポリシーバージョンの本文を表示" + "open_policy_page": "本文 ページを開く", + "no_policy_slug": "登録されたポリシー 本文 ページがありません。" }, - "not_found": "該当するポリシーバージョンが見つかりません。" + "not_found": "該当するポリシー バージョンが見つかりません。" } }, "admin": { "consent_log": { - "title": "GDPR同意履歴", - "description": "会員・ゲストの同意/撤回変更履歴を照会します。IP・User-Agentなど監査情報が一緒に公開されます。", + "title": "GDPR 同意 履歴", + "description": "会員·ゲストの同意/撤回 変更 履歴を照会します。IP·User-Agent などの監査 情報が合わせて露出されます。", "refresh": "更新", - "empty": "照会された同意履歴がありません。", - "clear_filters": "フィルターを初期化", + "empty": "照会された同意 履歴がありません。", + "clear_filters": "フィルター 初期化", "guest_label": "ゲスト", - "view_member_tooltip": "会員詳細ページに移動", - "search_placeholder": "メールアドレスまたはセッションIDで検索", + "view_member_tooltip": "会員 詳細 ページへ移動", + "search_placeholder": "メールアドレス または セッション ID で検索", "search_button": "検索", "search_type": { "email": "メール", - "session_id": "セッションID" + "session_id": "セッション ID" }, "filter": { - "consent_key_label": "同意項目", + "consent_key_label": "同意 項目", "action_label": "アクション", "source_label": "出典", "option_all": "すべて" }, "action": { "granted": "同意", - "revoked": "撤回" + "revoked": "撤回", + "rejected": "拒否" }, "source": { "banner": "バナー", @@ -244,19 +246,19 @@ "detail": { "session_id_label": "セッション ID", "user_agent_label": "User-Agent", - "categories_label": "カテゴリスナップショット", - "categories_hint": "同意時点で会員がカテゴリ別に表示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。", + "categories_label": "カテゴリースナップショット", + "categories_hint": "同意時点で会員がカテゴリー別に表示した意思です。ポリシー本文が更新されてもこの記録は不変として保存されます。", "snapshot_granted": "同意", - "snapshot_revoked": "拒否" + "snapshot_revoked": "未同意" } } }, "mypage": { "privacy": { "title": "クッキー同意管理", - "description": "サイトが使用中のクッキー項目に対する同意を変更または撤回できます。", + "description": "サイトが使用中のクッキー項目について同意を変更または撤回できます。", "section_consents": "私の同意状況", - "section_consents_hint": "同意・撤回時点に応じて即座に反映されます。必須項目は撤回できません。", + "section_consents_hint": "同意/撤回時点に応じて即座に反映されます。必須項目は撤回できません。", "col_consent_key": "同意項目", "col_consented": "ステータス", "col_consented_at": "同意日", @@ -265,17 +267,26 @@ "col_action": "操作", "consented": "同意", "revoked": "撤回", + "rejected_label": "拒否", "revoke": "撤回", "grant": "同意", "grant_again": "再度同意", + "label_suffix": "{label} クッキー", + "locked_label": "必須", + "enable_all": "すべてに同意", + "enable_all_hint": "同意していない選択項目があります。一度にすべてに同意できます。", + "enable_all_success": "選択項目にすべて同意しました。", "required_label": "必須", "required_hint": "サービス提供に必須のため撤回できません。", + "grant_confirm_title": "クッキー同意確認", + "grant_confirm_message": "このクッキーに同意しますか?同意すると該当カテゴリーのクッキーが使用され、いつでも再度撤回できます。", "revoke_confirm_title": "同意撤回確認", - "revoke_confirm_message": "この項目を撤回しますか?同意を撤回すると、該当カテゴリ関連機能が制限される可能性があります。", + "revoke_confirm_message": "この項目を撤回しますか?同意を撤回すると該当カテゴリー関連機能が制限される可能性があります。", + "revoke_target_consented_at": "同意 · {date}", "no_consents": "有効な同意項目がありません。", "needs_renewal": "クッキーポリシーが変更されました。同意を更新してください。", "needs_renewal_with_version": "クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。", - "needs_renewal_hint": "必須クッキーはサイト運用に不可欠な項目のため、ポリシー変更に関わらず常に適用されます。", + "needs_renewal_hint": "必須クッキーはサイト運営に必ず必要な項目のためポリシー変更と無関係に常に適用されます。", "btn_renew_all": "現在の同意更新", "btn_renew_this_item": "ポリシー更新", "renew_all_success": "有効項目 {renewed} 個を新しいポリシーバージョンに更新しました。", @@ -289,18 +300,18 @@ "refresh": "更新" }, "banner": { - "re_appear_reason": "クッキーポリシーがバージョン {version} に変更されたため、同意を再度確認します。", - "consent_required_hint": "拒否した項目と関連する機能が制限される可能性があります。", + "re_appear_reason": "クッキーポリシーがバージョン {version} に変更されたため同意を再度確認します。", + "consent_required_hint": "拒否した項目に関連する機能が制限される可能性があります。", "btn_renew_consent": "現在の同意更新", - "title": "クッキー使用のご案内", - "description": "本サイトはユーザー体験の向上と分析のためクッキーを使用しています。項目別に同意の有無を選択できます。", - "accept_all": "すべて同意", - "reject_all": "必須のみ使用", - "preferences": "環境設定", - "save": "選択同意", + "title": "クッキー使用のお知らせ", + "description": "本サイトはユーザー体験の改善と分析のためクッキーを使用しています。項目ごとに同意の有無を選択できます。", + "accept_all": "すべてに同意", + "reject_continue": "同意せずに続行", + "preferences": "選択同意", + "save": "選択を保存", "policy_link": "プライバシーポリシー", "preferences_title": "クッキー環境設定", - "preferences_description": "カテゴリ別クッキー使用を個別に同意・撤回できます。必須カテゴリはオフにできません。", + "preferences_description": "カテゴリー別クッキー使用を個別に同意/撤回できます。必須カテゴリーはオフにできません。", "category_required_badge": "必須", "view_policy_label": "変更されたポリシー本文を表示", "operator_and_storage_line": "本サイトは {entity} が運営しており、データは {location} に保存されます", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json index e43f637c..ce39d6b7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json @@ -1,4 +1,4 @@ { "name": "GDPR (一般データ保護規則)", - "description": "クッキー同意バナー、データ保護責任者(DPO)情報の公開、データ閲覧・削除リクエスト処理を提供するGDPR対応プラグインです。" + "description": "クッキー同意バナー、自動ブロック、同意履歴保存、マイページ同意撤回を提供するGDPR対応プラグインです。" } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/permissions.json index d92d684e..997e9d92 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/permissions.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/permissions.json @@ -1,18 +1,18 @@ { "sirsoft-gdpr": { - "name": "GDPR(一般データ保護規則)", - "description": "GDPRプラグインが提供する権限" + "name": "GDPR (一般データ保護規則)", + "description": "GDPR プラグインが提供する権限" }, "sirsoft-gdpr.privacy": { "name": "個人情報保護", - "description": "GDPRドメイン権限(閲覧·設定)" + "description": "GDPR ドメイン権限 (閲覧·設定)" }, "sirsoft-gdpr.privacy.view": { "name": "個人情報閲覧", - "description": "同意履歴·GDPR設定閲覧(監査·モニタリング)" + "description": "同意履歴·GDPR 設定閲覧 (監査·モニタリング)" }, "sirsoft-gdpr.privacy.update": { "name": "個人情報設定変更", - "description": "クッキーカテゴリ·ポリシーバージョンなどGDPRプラグイン設定変更" + "description": "クッキーカテゴリ·ポリシーバージョンなど GDPR プラグイン設定変更" } } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json index e70c39ea..9b4c5c61 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json @@ -1,6 +1,6 @@ { "sirsoft-gdpr.privacy": { "name": "個人情報管理者", - "description": "GDPRドメイン運営権限(設定・同意履歴閲覧)" + "description": "GDPR ドメイン運用権限 (設定·同意履歴閲覧)" } } diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md index 90a5047b..e214db83 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -248,7 +248,7 @@ _단건 응답: `data` 객체의 필드._ | user_id | integer | `166` | user 식별자 (연관 리소스 참조) | | needs_renewal | boolean | `false` | 회원의 활성 동의 중 옛 정책 버전인 항목이 있어 재동의가 필요한지 여부. 마이페이지가 「전체 다시 동의」 안내를 노출할지 판단합니다 | | current_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열. 각 동의 항목의 `policy_version`과 비교해 항목별 갱신 필요 여부를 계산하는 기준입니다 | -| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 명시적 거부 여부(`is_rejected`)와 거부 일시(`rejected_at`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다. `is_rejected=true`는 마이페이지에서 「거부」 배지로 표시됩니다 | +| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 명시적 거부 여부(`is_rejected`)와 거부 일시(`rejected_at`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다. `is_rejected=true`는 마이페이지에서 「거부」 배지로 표시됩니다. 각 항목은 마이페이지 표시용 상태 파생 필드도 함께 내려줍니다 — `status`(`required`/`consented`/`revoked`/`rejected`/`none`), 상태 배지 문구(`status_badge_label`, 다국어 — 필수=항상 적용 / 동의함 / 거부함 / 미설정), 동의한 항목의 표시 동사·날짜(`status_label`·`status_at_formatted` — 동의 상태(required·consented)만 '동의' + 동의일을 내려주고, 거부·철회·미설정은 둘 다 null) | **응답 예시** diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php index 47ea17c5..965f5d4a 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php @@ -231,6 +231,20 @@ return [ 'consented' => 'Consented', 'revoked' => 'Revoked', 'rejected_label' => 'Rejected', + // Issue #430 — per-item status verb chosen by the Resource (status_label) from status. + 'status' => [ + 'granted' => 'Consented', + 'consented' => 'Consented', + 'revoked' => 'Revoked', + 'rejected' => 'Rejected', + ], + // Issue #430 (button redesign) — status badge text. Resource(status_badge_label). + 'badge' => [ + 'required' => 'Always on', + 'consented' => 'Consented', + 'rejected' => 'Declined', + 'unset' => 'Not set', + ], 'revoke' => 'Revoke', 'grant' => 'Consent', 'grant_again' => 'Consent Again', diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php index f5329dfa..4b617c90 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php @@ -231,6 +231,20 @@ return [ 'consented' => '동의', 'revoked' => '철회', 'rejected_label' => '거부', + // 이슈 #430 — 각 동의 항목 상태별 표시 동사. Resource(status_label)가 status 로 골라 내려준다. + 'status' => [ + 'granted' => '동의', + 'consented' => '동의', + 'revoked' => '철회', + 'rejected' => '거부', + ], + // 이슈 #430 (버튼 재설계) — 상태 배지 문구. Resource(status_badge_label). + 'badge' => [ + 'required' => '항상 적용', + 'consented' => '동의함', + 'rejected' => '거부함', + 'unset' => '미설정', + ], 'revoke' => '철회', 'grant' => '동의', 'grant_again' => '다시 동의', diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json index b7e8057a..ad14f89a 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json @@ -370,7 +370,7 @@ "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-tertiary" + "className": "mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400" }, "children": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json index 1f5d46a0..8f5ca815 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json @@ -54,7 +54,7 @@ { "type": "basic", "name": "P", - "props": { "className": "text-body" }, + "props": { "className": "text-sm text-gray-700 dark:text-gray-300" }, "text": "$t:sirsoft-gdpr.mypage.privacy.revoke_confirm_message" }, { @@ -73,8 +73,16 @@ "type": "basic", "name": "Span", "if": "{{!!_global.revokeTargetDescription}}", - "props": { "className": "block text-tertiary mt-0.5 leading-relaxed" }, + "props": { "className": "block text-xs text-gray-500 dark:text-gray-400 mt-0.5 leading-relaxed" }, "text": "{{_global.revokeTargetDescription ?? ''}}" + }, + { + "comment": "이슈 #430 — 철회 대상의 마지막 동의일. 사용자가 언제 동의한 것을 철회하는지 인지 (informed). status_at_formatted 를 revokeTargetDate 로 전달받음.", + "type": "basic", + "name": "Span", + "if": "{{!!_global.revokeTargetDate}}", + "props": { "className": "block text-xs text-green-600 dark:text-green-400 mt-1.5" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.revoke_target_consented_at|date={{_global.revokeTargetDate ?? ''}}" } ] }, @@ -89,7 +97,7 @@ "props": { "type": "button", "disabled": "{{_local.isRevoking}}", - "className": "btn btn-secondary disabled:opacity-50" + "className": "px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50" }, "actions": [ { "type": "click", "handler": "closeModal" } @@ -105,7 +113,7 @@ "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" + "className": "px-4 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" }, "actions": [ { @@ -158,6 +166,128 @@ ] } ] + }, + { + "comment": "동의 확인 모달 (이슈 #430 버튼 재설계) — 거부/미설정 항목의 '동의' 버튼 클릭 시. 즉시 grant 대신 한 번 더 확인. 대상은 _global.grantTarget* (모달 컨텍스트 분리).", + "id": "grant_confirm_modal", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-gdpr.mypage.privacy.grant_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-sm text-gray-700 dark:text-gray-300" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.grant_confirm_message" + }, + { + "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": "$t:sirsoft-gdpr.mypage.privacy.label_suffix|label={{_global.grantTargetLabel ?? _global.grantTargetKey ?? ''}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!_global.grantTargetDescription}}", + "props": { "className": "block text-xs text-gray-500 dark:text-gray-400 mt-0.5 leading-relaxed" }, + "text": "{{_global.grantTargetDescription ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex justify-end gap-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{_local.isGranting}}", + "className": "px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50" + }, + "actions": [ + { "type": "click", "handler": "closeModal" } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:common.cancel" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{_local.isGranting || !_global.grantTargetKey}}", + "className": "px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { "target": "local", "isGranting": true } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-gdpr/consent/grant", + "params": { + "method": "POST", + "body": "{{{ consent_key: _global.grantTargetKey }}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { "target": "local", "isGranting": false } + }, + { + "handler": "setState", + "params": { "target": "global", "grantTargetKey": null, "grantTargetLabel": null, "grantTargetDescription": 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.granted_again" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "isGranting": false } }, + { "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "if": "{{_local.isGranting}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } }, + { "type": "basic", "name": "Span", "if": "{{!_local.isGranting}}", "text": "$t:sirsoft-gdpr.mypage.privacy.grant" }, + { "type": "basic", "name": "Span", "if": "{{_local.isGranting}}", "text": "$t:sirsoft-gdpr.consent.granting" } + ] + } + ] + } + ] + } + ] } ], "injections": [ @@ -189,7 +319,7 @@ { "type": "basic", "name": "H2", - "props": { "className": "section-heading-md" }, + "props": { "className": "text-base font-semibold text-gray-900 dark:text-white" }, "text": "$t:sirsoft-gdpr.mypage.privacy.title" } ] @@ -200,7 +330,7 @@ "type": "basic", "name": "Div", "if": "{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}", - "props": { "className": "mb-4 text-tertiary" }, + "props": { "className": "mb-4 text-xs text-gray-500 dark:text-gray-400" }, "children": [ { "type": "basic", @@ -233,7 +363,7 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex-center gap-3 flex-1" }, + "props": { "className": "flex items-center gap-3 flex-1" }, "children": [ { "type": "basic", @@ -249,14 +379,14 @@ "type": "basic", "name": "P", "comment": "현재 정책 버전을 노출하여 사용자가 어느 버전에 동의하는지 인지 (#23 — informed consent). G7 표준 $t:key|param={{value}} 토큰 사용 — lang 파일의 {version} placeholder 가 치환됨.", - "props": { "className": "text-warning-soft" }, + "props": { "className": "text-xs text-yellow-700 dark:text-yellow-300" }, "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" }, + "props": { "className": "mt-1 text-xs text-yellow-700 dark:text-yellow-300" }, "text": "$t:sirsoft-gdpr.mypage.privacy.needs_renewal_hint" } ] @@ -266,7 +396,7 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex-center flex-wrap gap-2 sm:flex-shrink-0" }, + "props": { "className": "flex items-center flex-wrap gap-2 sm:flex-shrink-0" }, "children": [ { "type": "basic", @@ -317,24 +447,24 @@ "id": "gdpr_section_consents", "type": "basic", "name": "Div", - "comment": "내 동의 현황 — 회원 활성 동의 (status) 기반", - "props": { "className": "toggle-group" }, + "comment": "내 동의 현황 — 회원 활성 동의 (status) 기반. admin 전용 toggle-group 클래스 → 공통 border/rounded 로 치환 (user 템플릿 미해석 회귀 수정, 이슈 #430).", + "props": { "className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden" }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "px-4 bg-gray-50 dark:bg-gray-700/50" }, + "props": { "className": "px-4 py-3 bg-gray-50 dark:bg-gray-700/50" }, "children": [ { "type": "basic", "name": "H3", - "props": { "className": "text-primary text-sm font-semibold" }, + "props": { "className": "text-gray-900 dark:text-white text-sm font-semibold" }, "text": "$t:sirsoft-gdpr.mypage.privacy.section_consents" }, { "type": "basic", "name": "P", - "props": { "className": "form-hint" }, + "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" }, "text": "$t:sirsoft-gdpr.mypage.privacy.section_consents_hint" } ] @@ -343,11 +473,11 @@ "type": "basic", "name": "Div", "if": "{{(gdprMeConsents?.data?.consents ?? []).length === 0}}", - "props": { "className": "py-10 text-center text-label-subtle" }, + "props": { "className": "py-10 text-center text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-gdpr.mypage.privacy.no_consents" }, { - "comment": "「모두 켜기」 일괄 동의 바 — 선택형(is_required=false) 중 꺼진(is_consented=false) 항목이 하나라도 있을 때만 노출. 필수는 대상 외. renew-all 이 아니라 신규/재동의 축이므로 grant 를 항목별로 반복 호출하는 대신, 서버 renew-all 은 '이미 활성 항목의 정책 갱신' 전용이라 여기서는 각 꺼진 선택형 항목을 grant 로 순차 켠다. 다만 레이아웃 반복 호출은 표현이 어렵기에, 배너의 '모두 동의' 와 동일하게 consent/cookie(intent 없음, 전체 true) 로 한 번에 처리.", + "comment": "「모두 동의」 일괄 동의 바 — 선택형(is_required=false) 중 미동의(is_consented=false) 항목이 하나라도 있을 때만 노출. 필수는 대상 외. renew-all 이 아니라 신규/재동의 축이므로 grant 를 항목별로 반복 호출하는 대신, 서버 renew-all 은 '이미 활성 항목의 정책 갱신' 전용이라 여기서는 각 미동의 선택형 항목을 grant 로 순차 처리한다. 다만 레이아웃 반복 호출은 표현이 어렵기에, 배너의 '모두 동의' 와 동일하게 consent/cookie(intent 없음, 전체 true) 로 한 번에 처리.", "type": "basic", "name": "Div", "if": "{{(gdprMeConsents?.data?.consents ?? []).filter(c => c?.is_required !== true && c?.is_consented !== true).length > 0}}", @@ -404,7 +534,7 @@ ] }, { - "comment": "토글 리스트 (이슈 #430 재설계) — 업계 표준 CMP(OneTrust/Cookiebot) 패턴: 필수=잠긴 '항상 켜짐', 선택=on/off 스위치. 동의일 컬럼은 사용자 화면에서 제거(감사 로그/관리자 화면에만 존재). 스위치는 Checkbox/Toggle 컴포넌트 부재로 클릭 가능한 Div 로 구현 — 필수는 클릭 액션 없이 시각적 잠금.", + "comment": "항목 리스트 (이슈 #430 재설계) — 상태 배지 + 동의/철회 버튼 방식. 필수=상태 배지 '항상 적용' + 잠금 버튼(액션 없음), 선택=상태 배지(동의함/거부함/미설정) + 동의·철회 버튼(확인 모달 경유). 동의일 컬럼은 사용자 화면에서 제거(동의한 항목만 라벨 아래 날짜 표시, 감사 로그 전체는 관리자 화면에만 존재). 초기 설계였던 on/off 스위치는 user 템플릿 스타일 회귀 + PO 지시로 배지+버튼 방식으로 대체됨(2026-07-21).", "type": "basic", "name": "Div", "if": "{{(gdprMeConsents?.data?.consents ?? []).length > 0}}", @@ -418,7 +548,7 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-between gap-4 px-4 py-3" }, + "props": { "className": "flex items-center justify-between gap-4 px-4 py-4" }, "children": [ { "type": "basic", @@ -429,13 +559,14 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2" }, + "props": { "className": "flex items-center gap-2 flex-wrap" }, "children": [ { + "comment": "라벨 뒤에 '쿠키' 접미사 (이슈 #430) — label_suffix='{label} 쿠키'. 운영자 카탈로그 라벨이 '기능' 이어도 화면엔 '기능 쿠키' 로 노출.", "type": "basic", "name": "Span", - "props": { "className": "text-primary font-medium text-sm" }, - "text": "{{consent?.consent_label ?? consent?.consent_key ?? ''}}" + "props": { "className": "text-gray-900 dark:text-white font-medium text-sm" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.label_suffix|label={{consent?.consent_label ?? consent?.consent_key ?? ''}}" }, { "type": "basic", @@ -443,6 +574,13 @@ "if": "{{consent?.is_required === true}}", "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-gdpr.mypage.privacy.required_label" + }, + { + "comment": "이슈 #430 (3안) — 상태 배지를 라벨 옆으로. 서버 status_badge_label (필수=항상 적용 / 동의함 / 거부함 / 미설정). status 로 색 분기.", + "type": "basic", + "name": "Span", + "props": { "className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold {{consent?.status === 'consented' ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : (consent?.status === 'required' ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400')}}" }, + "text": "{{consent?.status_badge_label ?? ''}}" } ] }, @@ -450,129 +588,106 @@ "type": "basic", "name": "Span", "if": "{{!!consent?.consent_description}}", - "props": { "className": "block text-tertiary mt-0.5 text-xs leading-relaxed" }, + "props": { "className": "block text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed" }, "text": "{{consent?.consent_description ?? ''}}" + }, + { + "comment": "이슈 #430 (3안) — 라벨 아래 이력 줄: '날짜 + 동의'. 상태는 이미 라벨 옆 배지가 말하므로 여기선 시점만 강조. 서버가 동의한 항목만 status_label·status_at_formatted 를 채워 내려주므로(거부/미설정은 null) 둘 다 있을 때만 렌더.", + "type": "basic", + "name": "Div", + "if": "{{!!consent?.status_label && !!consent?.status_at_formatted}}", + "props": { "className": "mt-1.5 text-xs text-gray-400 dark:text-gray-500" }, + "children": [ + { "type": "basic", "name": "Span", "text": "{{consent?.status_at_formatted ?? ''}}" }, + { "type": "basic", "name": "Span", "props": { "className": "ml-1" }, "text": "{{consent?.status_label ?? ''}}" } + ] } ] }, { + "comment": "이슈 #430 (3안) — 오른쪽 클러스터: 단일 액션 버튼만. 상태 배지는 라벨 옆으로 이동. 필수=잠금버튼(액션X), 동의함=철회버튼(철회 모달), 그 외=동의버튼(동의 모달).", "type": "basic", "name": "Div", - "props": { "className": "shrink-0 flex items-center gap-2" }, + "props": { "className": "shrink-0 flex items-center" }, "children": [ { - "comment": "필수 카테고리 — 잠긴 '항상 켜짐' 스위치. 클릭 액션 없음(철회 불가, ePrivacy Art.5(3) 면제). 파란 채움 + 오른쪽 knob + not-allowed 커서로 잠금 표현.", + "comment": "필수 — 잠금 버튼 (비활성, 액션 없음). ePrivacy Art.5(3) 면제.", "type": "basic", - "name": "Div", + "name": "Button", "if": "{{consent?.is_required === true}}", - "props": { "className": "flex items-center gap-2" }, + "props": { + "type": "button", + "disabled": true, + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed" + }, "children": [ - { - "type": "basic", - "name": "Span", - "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400" }, - "text": "$t:sirsoft-gdpr.mypage.privacy.always_on" - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600/60 dark:bg-blue-500/60 cursor-not-allowed" }, - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "lock", "size": "xs", "className": "absolute left-1.5 text-white/80" } }, - { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } - ] - } + { "type": "basic", "name": "Icon", "props": { "name": "lock", "size": "xs" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.locked_label" } ] }, { - "comment": "선택형 카테고리 — on/off 스위치. 켜짐(is_consented=true)이면 녹색+revoke 확인 모달, 꺼짐이면 회색+grant 즉시 호출. grantingKey 로 해당 행만 처리 중 표시.", + "comment": "동의함(선택) — 철회 버튼. 클릭 시 철회 확인 모달 (즉시 철회 아님, 오조작 방지).", "type": "basic", - "name": "Div", - "if": "{{consent?.is_required !== true}}", - "props": { "className": "flex items-center gap-2" }, - "children": [ + "name": "Button", + "if": "{{consent?.is_required !== true && consent?.is_consented === true}}", + "props": { + "type": "button", + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "className": "inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" + }, + "actions": [ { - "type": "basic", - "name": "Span", - "props": { "className": "text-xs font-medium {{consent?.is_consented ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'}}" }, - "text": "{{consent?.is_consented ? '$t:sirsoft-gdpr.mypage.privacy.toggle_on' : '$t:sirsoft-gdpr.mypage.privacy.toggle_off'}}" - }, - { - "comment": "켜짐 상태 스위치 — 클릭 시 철회 확인 모달. 녹색 채움 + knob 오른쪽.", - "type": "basic", - "name": "Button", - "if": "{{consent?.is_consented === true}}", - "props": { - "type": "button", - "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", - "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-green-500 dark:bg-green-600 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-1" - }, + "type": "click", + "handler": "sequence", "actions": [ { - "type": "click", - "handler": "sequence", - "actions": [ - { - "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", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } - ] - }, - { - "comment": "꺼짐 상태 스위치 — 클릭 시 즉시 grant. 회색 채움 + knob 왼쪽. 정책 갱신 항목도 별도 색 분기 없이 grant 로 통합(정책 갱신도 결국 동의 표명, grant 는 항상 현재 정책 버전 기록).", - "type": "basic", - "name": "Button", - "if": "{{consent?.is_consented !== true}}", - "props": { - "type": "button", - "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", - "disabled": "{{_local.grantingKey === consent?.consent_key}}", - "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-wait" - }, - "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": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-1 rounded-full bg-white shadow transition-transform" } } + "handler": "setState", + "params": { + "target": "global", + "revokeTargetKey": "{{consent?.consent_key}}", + "revokeTargetLabel": "{{consent?.consent_label ?? consent?.consent_key}}", + "revokeTargetDescription": "{{consent?.consent_description ?? ''}}", + "revokeTargetDate": "{{consent?.status_at_formatted ?? ''}}" + } + }, + { "handler": "openModal", "target": "revoke_confirm_modal" } ] } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.revoke" } + ] + }, + { + "comment": "거부함/미설정(선택) — 동의 버튼. 클릭 시 동의 확인 모달 (신규, 즉시 grant 아님).", + "type": "basic", + "name": "Button", + "if": "{{consent?.is_required !== true && consent?.is_consented !== true}}", + "props": { + "type": "button", + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "className": "inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-blue-600 text-white hover:bg-blue-700 transition-colors" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "grantTargetKey": "{{consent?.consent_key}}", + "grantTargetLabel": "{{consent?.consent_label ?? consent?.consent_key}}", + "grantTargetDescription": "{{consent?.consent_description ?? ''}}" + } + }, + { "handler": "openModal", "target": "grant_confirm_modal" } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.grant" } ] } ] diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index 8cfc2227..c7b344be 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -528,11 +528,14 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(text).toContain('sirsoft-gdpr.banner.storage_only_line|location='); }); - it('푸터 라인은 시각적 무게 최소화 — 작은 보조 텍스트 시맨틱 + 상단 구분선', () => { + it('푸터 라인은 시각적 무게 최소화 — 작은 보조 텍스트 (공통 Tailwind 코어 클래스) + 상단 구분선', () => { // 동의 행위 자체에 시선이 가도록 운영 주체는 부드러운 보조 정보로 처리. - // 작은 회색 텍스트 톤은 표준 시맨틱(.text-tertiary)으로 통일 (#399). + // 이슈 #430 — 배너는 user 페이지에 렌더되므로 admin 전용 시맨틱(.text-tertiary)은 미해석. + // 동등한 공통 Tailwind 코어 클래스로 치환 (회귀 수정). const className = (footer?.props as { className?: string })?.className ?? ''; - expect(className).toContain('text-tertiary'); + expect(className).toContain('text-xs'); + expect(className).toContain('text-gray-500'); + expect(className).not.toContain('text-tertiary'); expect(className).toContain('border-t'); }); }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx index 6fba4b7f..2d452db5 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx @@ -143,10 +143,13 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 expect(text).toContain('sirsoft-gdpr.mypage.privacy.storage_only_meta|location='); }); - it('시각적 무게 최소화 — 작은 보조 텍스트 시맨틱', () => { - // 작은 회색 텍스트 톤은 표준 시맨틱(.text-tertiary)으로 통일 (#399). + it('시각적 무게 최소화 — 작은 보조 텍스트 (공통 Tailwind 코어 클래스)', () => { + // 이슈 #430 — 이 카드는 user 페이지(sirsoft-basic)에 렌더되므로 admin 전용 시맨틱(.text-tertiary)은 + // CSS 미해석. 동등한 공통 Tailwind 코어 클래스로 치환 (회귀 수정). const className = (meta?.props as { className?: string })?.className ?? ''; - expect(className).toContain('text-tertiary'); + expect(className).toContain('text-xs'); + expect(className).toContain('text-gray-500'); + expect(className).not.toContain('text-tertiary'); }); }); @@ -157,6 +160,21 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 expect(modal?.name).toBe('Modal'); }); + it('이슈 #430 — grant_confirm_modal(동의 확인 모달)이 modals 배열에 포함', () => { + const modal = (root.modals ?? []).find((m) => m.id === 'grant_confirm_modal'); + expect(modal).toBeTruthy(); + expect(modal?.name).toBe('Modal'); + const text = serializeForSearch(modal); + // 동의 확인 문구 + grant API + 대상 _global.grantTargetKey + expect(text).toContain('mypage.privacy.grant_confirm_message'); + expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/grant"'); + expect(text).toContain('_global.grantTargetKey'); + // 확인 후 refetch + syncConsent + closeModal + expect(text).toContain('"dataSourceId": "gdprMeConsents"'); + expect(text).toContain('"handler": "sirsoft-gdpr.syncConsent"'); + expect(text).toContain('"handler": "closeModal"'); + }); + it('제거된 데이터 요청·자가 취소 모달은 존재하지 않는다', () => { const ids = (root.modals ?? []).map((m) => m.id); expect(ids).not.toContain('gdpr_data_request_modal'); @@ -203,6 +221,8 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 // raw key (cookie_functional 등) 대신 사용자 친화 description 을 모달에 표시하기 위해 // 행 핸들러에서 consent_description 을 _global 에 세팅한다. expect(text).toContain('"revokeTargetDescription": "{{consent?.consent_description ?? \'\'}}"'); + // 이슈 #430 — 철회 모달에서 마지막 동의일을 보여주기 위해 status_at_formatted 를 _global 로 전달. + expect(text).toContain('"revokeTargetDate": "{{consent?.status_at_formatted ?? \'\'}}"'); }); it('동의 항목 컬럼이 라벨 우선 표시 (consent_label fallback consent_key)', () => { @@ -233,6 +253,13 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 expect(text).not.toContain('font-mono break-all'); }); + it('이슈 #430 — 모달에 철회 대상의 마지막 동의일 노출 (revokeTargetDate 있을 때만)', () => { + const text = serializeForSearch(modal); + // 사용자가 언제 동의한 것을 철회하는지 인지하도록 마지막 동의일을 표시. + expect(text).toContain('_global.revokeTargetDate'); + expect(text).toContain('sirsoft-gdpr.mypage.privacy.revoke_target_consented_at|date='); + }); + it('확인 버튼 → POST /consent/revoke + onSuccess 에 refetch + closeModal + toast', () => { const text = serializeForSearch(modal); expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/revoke"'); @@ -270,77 +297,102 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 /** * @scenario entry=reject, subject=member, category=required - * @effects mypage_no_consent_date_column_for_user + * @effects mypage_status_date_line_per_row */ - it('사용자 화면에 동의일 컬럼이 없다 (감사/관리자 전용) — col_consented_at / col_action 헤더 제거', () => { - // 재설계 회귀 가드: 표 헤더(col_consent_key/col_consented/col_consented_at/col_action) 전면 제거. - // 동의일은 GDPR 감사 로그·관리자 화면에만 존재하고 사용자 토글 화면엔 노출하지 않음. + it('옛 표 헤더 컬럼 방식은 미사용 (col_consented_at / col_action 헤더 없음)', () => { + // 회귀 가드: 재설계로 표 헤더(col_consent_key/col_consented/col_consented_at/col_action) 방식은 제거. + // 날짜는 컬럼이 아니라 각 행의 상태별 한 줄(status_at_formatted)로 노출한다. const text = serializeForSearch(section); expect(text).not.toContain('mypage.privacy.col_consented_at'); expect(text).not.toContain('mypage.privacy.col_action'); - // 행 단위 consented_at 바인딩도 사용자 화면에서 제거 - expect(text).not.toContain('consent?.consented_at'); + }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_status_date_line_consented_only + */ + it('이슈 #430 — 동의한 항목만 날짜 줄 노출 (서버가 동의 상태에만 status_at_formatted 제공)', () => { + // PO 결정(2026-07-20): 날짜는 "동의한 항목만". 거부·철회·미설정은 서버가 status_at_formatted 를 + // null 로 내려주므로 날짜 줄이 렌더되지 않는다. 프론트는 status_label && status_at_formatted 가 둘 다 + // 있을 때만 날짜 줄을 그린다. + const text = serializeForSearch(section); + expect(text).toContain('consent?.status_label'); + expect(text).toContain('consent?.status_at_formatted'); + // 날짜 줄은 둘 다 있을 때만 (거부/미설정은 null → 미노출) + expect(text).toContain('!!consent?.status_label && !!consent?.status_at_formatted'); }); /** * @scenario entry=reject, subject=member, category=required - * @effects mypage_required_shows_locked_always_on_toggle + * @effects mypage_required_shows_locked_button */ - it('필수 카테고리 — 잠긴 「항상 켜짐」 토글 (액션 없음, lock 아이콘 + not-allowed)', () => { + it('필수 카테고리 — 상태 배지 + 잠금 버튼 (액션 없음, lock 아이콘 + not-allowed)', () => { const text = serializeForSearch(section); expect(text).toContain('consent?.is_required === true'); - expect(text).toContain('mypage.privacy.always_on'); - // 잠금 시각 표현: lock 아이콘 + not-allowed 커서 + // 상태 배지는 서버 status_badge_label 사용 + expect(text).toContain('consent?.status_badge_label'); + // 잠금 버튼: lock 아이콘 + not-allowed + locked_label expect(text).toContain('"name": "lock"'); expect(text).toContain('cursor-not-allowed'); - // 필수 행에는 required_label 배지도 노출 + expect(text).toContain('mypage.privacy.locked_label'); + // 필수 행 라벨 옆 required_label 배지도 유지 expect(text).toContain('mypage.privacy.required_label'); }); /** * @scenario entry=reject, subject=member, category=optional - * @effects mypage_optional_shows_on_off_toggle + * @effects mypage_optional_consented_shows_revoke_button */ - it('선택형 켜짐(is_consented) — 녹색 토글 + 「켜짐」 라벨, 클릭 시 철회 확인 모달', () => { + it('선택형 동의함 — 철회 버튼, 클릭 시 철회 확인 모달 (즉시 철회 아님)', () => { const text = serializeForSearch(section); - expect(text).toContain('consent?.is_consented === true'); - expect(text).toContain('mypage.privacy.toggle_on'); - // 켜짐 토글은 녹색 - expect(text).toContain('bg-green-500'); - // 클릭 시 철회 확인 모달 오픈 (즉시 철회 아님 — 오조작 방지) + expect(text).toContain('consent?.is_required !== true && consent?.is_consented === true'); + expect(text).toContain('mypage.privacy.revoke'); + // 클릭 시 철회 확인 모달 오픈 + 대상 키를 global 로 세팅 expect(text).toContain('"target": "revoke_confirm_modal"'); + expect(text).toContain('"revokeTargetKey"'); }); - it('선택형 꺼짐(!is_consented) — 회색 토글 + 「꺼짐」 라벨, 클릭 시 즉시 grant', () => { + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_optional_unset_shows_grant_button + */ + it('선택형 거부함/미설정 — 동의 버튼, 클릭 시 동의 확인 모달 (즉시 grant 아님)', () => { const text = serializeForSearch(section); - expect(text).toContain('consent?.is_consented !== true'); - expect(text).toContain('mypage.privacy.toggle_off'); - // 꺼짐 토글은 회색 - expect(text).toContain('bg-gray-300'); - // 클릭 시 POST /consent/grant - expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/grant"'); + expect(text).toContain('consent?.is_required !== true && consent?.is_consented !== true'); + expect(text).toContain('mypage.privacy.grant'); + // 토글 즉시 grant 가 아니라 동의 확인 모달 경유 (grant_confirm_modal) + expect(text).toContain('"target": "grant_confirm_modal"'); + expect(text).toContain('grantTargetKey'); }); - it('grant onSuccess 에서 refetch + syncConsent + 토스트 호출', () => { - // 회귀 가드: 꺼짐→켜짐 시 interceptor functionalConsented 즉시 갱신 (동의 즉시성). + it('이슈 #430 (3안) — 상태 배지는 라벨 줄에, 날짜 줄은 날짜+동의 순서 (배지↔날짜 동의 중복 제거)', () => { const text = serializeForSearch(section); - expect(text).toContain('"dataSourceId": "gdprMeConsents"'); - const matches = text.match(/"handler":\s*"sirsoft-gdpr\.syncConsent"/g) ?? []; - expect(matches.length).toBeGreaterThanOrEqual(1); + // 배지가 라벨 옆 flex-wrap 줄에 위치 (라벨 label_suffix 와 같은 컨테이너) + expect(text).toContain('items-center gap-2 flex-wrap'); + expect(text).toContain('consent?.status_badge_label'); + // 날짜 줄은 날짜가 먼저, 그 뒤에 동의 라벨 (동사 중복 회피 — 상태는 배지가 말함) + const idxDate = text.indexOf('status_at_formatted'); + const idxLabelAfter = text.indexOf('status_label', idxDate); + expect(idxDate).toBeGreaterThan(-1); + expect(idxLabelAfter).toBeGreaterThan(idxDate); }); - it('꺼짐 토글에 grantingKey 비교 disabled — 처리 중 중복 클릭 차단', () => { + it('「켜짐/꺼짐」 토글 표현 및 스위치 마크업이 부활하지 않는다 (회귀 가드)', () => { + // 토글 제거 회귀 가드: user 템플릿 w-11 safelist 붕괴 + '켜짐/꺼짐' 표현 어색함으로 버튼 전환. const text = serializeForSearch(section); - expect(text).toContain("_local.grantingKey === consent?.consent_key"); + expect(text).not.toContain('mypage.privacy.toggle_on'); + expect(text).not.toContain('mypage.privacy.toggle_off'); + // 스위치 knob 마크업(translate-x-6 / translate-x-1 + rounded-full 스위치 트랙)이 없어야 함 + expect(text).not.toContain('h-6 w-11'); }); /** * @scenario entry=reject, subject=member, category=optional * @effects mypage_enable_all_bar_when_optional_off */ - it('꺼진 선택 항목이 있을 때만 「모두 켜기」 바 노출 + POST /consent/cookie 로 일괄 동의', () => { + it('동의 안 한 선택 항목이 있을 때만 「모두 동의」 바 노출 + POST /consent/cookie 로 일괄 동의', () => { const text = serializeForSearch(section); - // 노출 조건: 선택형(is_required !== true) 중 꺼진(is_consented !== true) 항목 존재 + // 노출 조건: 선택형(is_required !== true) 중 미동의(is_consented !== true) 항목 존재 expect(text).toContain("c?.is_required !== true && c?.is_consented !== true"); expect(text).toContain('mypage.privacy.enable_all'); expect(text).toContain('mypage.privacy.enable_all_hint'); @@ -350,12 +402,15 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 expect(text).toContain('mypage.privacy.enable_all_success'); }); - it('제거된 표 배지/버튼 구조가 부활하지 않는다 (회귀 가드)', () => { - // 재설계로 상태 배지(bg-amber-100)·철회 빨간 버튼(bg-red-600 버튼)·can_grant/can_revoke 분기가 - // 토글로 대체됨. can_grant/can_revoke 메타는 서버 Resource 에 남아있으나 레이아웃은 미참조. + it('grant/revoke onSuccess 에서 refetch + syncConsent 호출 (동의 즉시성)', () => { + // 회귀 가드: 동의/철회 시 interceptor 즉시 갱신 — 모달의 sequence onSuccess 에 syncConsent 존재. + const text = serializeForSearch(root); + const matches = text.match(/"handler":\s*"sirsoft-gdpr\.syncConsent"/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it('제거된 amber 갱신 분기 / rejected_label 이 부활하지 않는다 (회귀 가드)', () => { const text = serializeForSearch(section); - expect(text).not.toContain('consent?.can_revoke === true'); - expect(text).not.toContain('consent?.can_grant === true'); expect(text).not.toContain('mypage.privacy.rejected_label'); expect(text).not.toContain('needs_renewal_this_item'); }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index ae46dac7..ec4ab41b 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -271,16 +271,18 @@ "revoke": "Revoke", "grant": "Consent", "grant_again": "Consent Again", - "always_on": "Always On", - "toggle_on": "On", - "toggle_off": "Off", - "enable_all": "Enable All", - "enable_all_hint": "You have optional items turned off. Enable them all at once.", - "enable_all_success": "All optional items have been enabled.", + "label_suffix": "{label} cookies", + "locked_label": "Required", + "enable_all": "Consent to all", + "enable_all_hint": "You have optional items you haven't consented to. Consent to them all at once.", + "enable_all_success": "You have consented to all optional items.", "required_label": "Required", "required_hint": "This item is required for service operation and cannot be revoked.", + "grant_confirm_title": "Confirm Cookie Consent", + "grant_confirm_message": "Do you want to consent to this cookie? Once consented, this category's cookies will be used, and you can revoke it anytime.", "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.", + "revoke_target_consented_at": "Consented · {date}", "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.", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index e31291fb..1c7253f2 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -271,16 +271,18 @@ "revoke": "철회", "grant": "동의", "grant_again": "다시 동의", - "always_on": "항상 켜짐", - "toggle_on": "켜짐", - "toggle_off": "꺼짐", - "enable_all": "모두 켜기", - "enable_all_hint": "동의하지 않은 선택 항목이 있습니다. 한 번에 모두 켤 수 있습니다.", - "enable_all_success": "선택 항목을 모두 켰습니다.", + "label_suffix": "{label} 쿠키", + "locked_label": "필수", + "enable_all": "모두 동의", + "enable_all_hint": "동의하지 않은 선택 항목이 있습니다. 한 번에 모두 동의할 수 있습니다.", + "enable_all_success": "선택 항목에 모두 동의했습니다.", "required_label": "필수", "required_hint": "서비스 제공에 필수적이라 철회할 수 없습니다.", + "grant_confirm_title": "쿠키 동의 확인", + "grant_confirm_message": "이 쿠키에 동의하시겠습니까? 동의하면 해당 카테고리의 쿠키가 사용되며, 언제든 다시 철회할 수 있습니다.", "revoke_confirm_title": "동의 철회 확인", "revoke_confirm_message": "이 항목을 철회하시겠습니까? 동의를 철회하면 해당 카테고리 관련 기능이 제한될 수 있습니다.", + "revoke_target_consented_at": "동의 · {date}", "no_consents": "활성 동의 항목이 없습니다.", "needs_renewal": "쿠키 정책이 변경되었습니다. 동의를 갱신해주세요.", "needs_renewal_with_version": "쿠키 정책이 버전 {version} 으로 변경되었습니다. 선택 항목의 동의를 다시 확인해주세요.", diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php index 05b51579..42c3ee4e 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php @@ -39,6 +39,39 @@ class GdprUserConsentResource extends BaseApiResource // 선택형 항목 중 정책 버전이 옛 버전인지 — 필수는 ePrivacy Art.5(3) 면제로 갱신 대상 아님. $needsRenewalThisItem = ! $isRequired && $isConsented && $policyVersion !== '' && $policyVersion !== $currentPolicyVersion; + // 이슈 #430 — 마이페이지 「내 동의 현황」 각 행의 상태를 서버가 판정한다. + // 상태 판정: 필수 → required(동의), 선택 동의중 → consented(동의), + // 선택 명시 거부 → rejected(거부), 선택 철회 이력 → revoked(철회), 그 외(신규 미선택) → none. + if ($isRequired) { + $status = 'required'; + } elseif ($isConsented) { + $status = 'consented'; + } elseif ((bool) $this->is_rejected) { + $status = 'rejected'; + } elseif ($this->revoked_at !== null) { + $status = 'revoked'; + } else { + $status = 'none'; + } + // 표시 날짜/라벨은 "동의한 항목만" 노출한다 (PO 결정, 2026-07-20). + // 동의 상태(required·consented)만 동의일 + '동의' 라벨을 내려주고, + // 거부·철회·미선택은 status_label·status_at_formatted 를 null 로 두어 프론트가 날짜 줄을 렌더하지 않는다. + // (거부일·철회일은 사용자 화면 확인 가치가 낮고, 감사 로그·관리자 화면에서 확인 가능) + $isConsentState = $status === 'required' || $status === 'consented'; + $statusRawDate = $isConsentState ? $this->consented_at : null; + $statusLabel = $isConsentState + ? __('sirsoft-gdpr::messages.mypage.privacy.status.granted') + : null; + // 이슈 #430 (버튼 재설계) — 상태 배지 문구. 토글 대신 배지+단일버튼 UI 에서 + // 오른쪽 배지에 노출한다. 철회 이력(revoked)과 신규 미선택(none)은 '미설정'으로 통합. + $statusBadgeKey = match ($status) { + 'required' => 'required', + 'consented' => 'consented', + 'rejected' => 'rejected', + default => 'unset', // revoked, none + }; + $statusBadgeLabel = __('sirsoft-gdpr::messages.mypage.privacy.badge.'.$statusBadgeKey); + return [ 'id' => $this->id, 'consent_key' => $consentKey, @@ -56,6 +89,13 @@ class GdprUserConsentResource extends BaseApiResource // 비활성 (철회 또는 신규) → 다시 동의 (기존 동작). 'can_grant' => ! $isRequired && (! $isConsented || $needsRenewalThisItem), 'needs_renewal_this_item' => $needsRenewalThisItem, + // 이슈 #430 — 상태별 단일 표시 필드. 프론트는 status 로 색만 분기하고 나머지는 그대로 렌더. + 'status' => $status, + 'status_label' => $statusLabel, + 'status_badge_label' => $statusBadgeLabel, + 'status_at_formatted' => $statusRawDate !== null + ? $this->formatDateTimeStringForUser($statusRawDate) + : null, 'consented_at' => $this->formatDateTimeStringForUser($this->consented_at), 'revoked_at' => $this->formatDateTimeStringForUser($this->revoked_at), 'consent_count' => (int) $this->consent_count, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Services/CookieCategoryService.php b/plugins/_bundled/sirsoft-gdpr/src/Services/CookieCategoryService.php index 7f5b397f..141be05a 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Services/CookieCategoryService.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Services/CookieCategoryService.php @@ -157,9 +157,10 @@ class CookieCategoryService /** * consent_key 에 대응하는 사용자 친화 설명을 현재 locale 기준으로 반환합니다. * - * 마이페이지 「내 동의 현황」 의 동의 항목 컬럼에서 영문 식별자 대신 회원이 - * 즉시 이해할 수 있는 한 줄 설명을 노출하기 위함. 카탈로그에 매칭되는 카테고리가 - * 없거나 설명이 비어 있으면 null 반환 — 호출 측이 fallback 처리. + * 설명(description)은 관리자 설정 화면에 편집 UI 가 없어(라벨만 편집 가능) 운영자가 + * 카테고리를 저장하면 description 이 유실된다. 따라서 설명은 관리자 설정(getCategories)이 + * 아니라 항상 코드 기본값(getDefaultCategories)에서 가져온다 — 운영자 라벨 커스텀과 + * 무관하게 마이페이지·배너에 일관된 설명이 노출된다 (이슈 #430). 기본값에도 없으면 null. * * @param string $consentKey 동의 항목 키 (예: cookie_analytics) * @return string|null @@ -170,7 +171,7 @@ class CookieCategoryService $locale = (string) app()->getLocale(); $fallbackLocale = (string) config('app.fallback_locale', 'en'); - foreach ($this->getCategories() as $cat) { + foreach ($this->getDefaultCategories() as $cat) { if (($cat['key'] ?? null) !== $bareKey) { continue; } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php new file mode 100644 index 00000000..d642424d --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php @@ -0,0 +1,124 @@ + + */ + private function toArray(GdprUserConsent $consent): array + { + return (new GdprUserConsentResource($consent))->toArray(Request::create('/')); + } + + public function test_required_category_maps_to_required_status_with_consented_date(): void + { + $consent = new GdprUserConsent([ + 'consent_key' => 'cookie_necessary', + 'is_consented' => true, + 'is_rejected' => false, + 'consented_at' => '2026-07-14 09:00:00', + 'policy_version' => '1', + ]); + + $result = $this->toArray($consent); + + $this->assertSame('required', $result['status']); + // 필수는 '동의' 라벨을 granted 키로 공유. + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.status.granted'), $result['status_label']); + $this->assertNotNull($result['status_at_formatted']); + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.required'), $result['status_badge_label']); + } + + public function test_consented_optional_maps_to_consented_status_with_consented_date(): void + { + $consent = new GdprUserConsent([ + 'consent_key' => 'cookie_functional', + 'is_consented' => true, + 'is_rejected' => false, + 'consented_at' => '2026-07-14 09:00:00', + 'policy_version' => '1', + ]); + + $result = $this->toArray($consent); + + $this->assertSame('consented', $result['status']); + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.status.consented'), $result['status_label']); + $this->assertNotNull($result['status_at_formatted']); + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.consented'), $result['status_badge_label']); + } + + public function test_rejected_optional_maps_to_rejected_status_with_no_date_label(): void + { + // PO 결정(2026-07-20) — 날짜/라벨은 "동의한 항목만" 노출. 거부는 상태만 rejected 로 내려주고 + // status_label·status_at_formatted 는 null (프론트가 날짜 줄을 렌더하지 않음). + $consent = new GdprUserConsent([ + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => true, + 'rejected_at' => '2026-07-10 08:00:00', + 'policy_version' => '1', + ]); + + $result = $this->toArray($consent); + + $this->assertSame('rejected', $result['status']); + $this->assertNull($result['status_label']); + $this->assertNull($result['status_at_formatted']); + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.rejected'), $result['status_badge_label']); + } + + public function test_revoked_optional_maps_to_revoked_status_with_no_date_label(): void + { + // 철회도 거부와 동일 — 동의 상태가 아니므로 날짜/라벨 미노출. + $consent = new GdprUserConsent([ + 'consent_key' => 'cookie_marketing', + 'is_consented' => false, + 'is_rejected' => false, + 'revoked_at' => '2026-07-12 07:00:00', + 'policy_version' => '1', + ]); + + $result = $this->toArray($consent); + + $this->assertSame('revoked', $result['status']); + $this->assertNull($result['status_label']); + $this->assertNull($result['status_at_formatted']); + // 철회는 신규 미선택과 함께 '미설정'(unset) 배지로 통합. + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.unset'), $result['status_badge_label']); + } + + public function test_new_untouched_optional_maps_to_none_with_null_label_and_date(): void + { + $consent = new GdprUserConsent([ + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => false, + 'policy_version' => '1', + ]); + + $result = $this->toArray($consent); + + $this->assertSame('none', $result['status']); + // 신규 미선택은 라벨/날짜 모두 미노출 → 프론트가 날짜 줄 자체를 렌더하지 않음. + $this->assertNull($result['status_label']); + $this->assertNull($result['status_at_formatted']); + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.unset'), $result['status_badge_label']); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/CookieCategoryServiceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/CookieCategoryServiceTest.php index f3817d85..d8c4c3a8 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/CookieCategoryServiceTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/CookieCategoryServiceTest.php @@ -162,4 +162,37 @@ class CookieCategoryServiceTest extends PluginTestCase app()->setLocale('ko'); $this->assertNull($this->makeService()->getLabelForKey('cookie_analytics')); } + + public function test_get_description_uses_default_even_when_admin_setting_omits_description(): void + { + // 이슈 #430 회귀 가드: 관리자 설정 화면에 설명 편집 UI 가 없어, 운영자가 라벨만 저장하면 + // cookie_categories 에 description 이 유실된다. 설명은 관리자 설정이 아니라 항상 코드 + // 기본값에서 가져와야 한다 — 아래처럼 description 이 없는 설정이 저장돼도 기본 설명이 나온다. + $this->mockSettings([ + 'cookie_categories' => json_encode([ + ['key' => 'functional', 'required' => false, 'label' => ['ko' => '기능', 'en' => 'Functional']], + ]), + ]); + + app()->setLocale('ko'); + $desc = $this->makeService()->getDescriptionForKey('cookie_functional'); + $this->assertNotNull($desc); + $this->assertNotSame('', $desc); + // 라벨은 운영자 커스텀('기능')을 유지하되, 설명은 코드 기본값에서 온다. + $this->assertSame('기능', $this->makeService()->getLabelForKey('cookie_functional')); + } + + public function test_get_description_ignores_admin_custom_description(): void + { + // 설명은 관리자 설정이 아니라 코드 기본값 SSoT — 운영자가 설정에 다른 설명을 넣어도 + // 코드 기본 설명이 우선한다 (설명 편집 UI 부재 정합). + $this->mockSettings([ + 'cookie_categories' => json_encode([ + ['key' => 'functional', 'required' => false, 'label' => ['ko' => '기능'], 'description' => ['ko' => '운영자가 임의로 넣은 설명']], + ]), + ]); + + app()->setLocale('ko'); + $this->assertNotSame('운영자가 임의로 넣은 설명', $this->makeService()->getDescriptionForKey('cookie_functional')); + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml index 099238ec..0ae5287f 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml +++ b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml @@ -73,10 +73,11 @@ effects: - consent_action_from_decision_granted_revoked_rejected # value/isRejection → granted/revoked/rejected 3분기 # 화면 표기 (레이아웃 렌더링) - - mypage_required_shows_locked_always_on_toggle # 마이페이지 필수 = 잠긴 '항상 켜짐' 토글 (액션/동의일 없음) - - mypage_optional_shows_on_off_toggle # 마이페이지 선택 = on/off 토글 (켜짐 녹색→철회, 꺼짐 회색→동의) - - mypage_no_consent_date_column_for_user # 사용자 화면에 동의일 컬럼 미노출 (감사/관리자 전용) - - mypage_enable_all_bar_when_optional_off # 꺼진 선택 항목 있을 때 '모두 켜기' 바 노출 + - mypage_required_shows_locked_button # 마이페이지 필수 = 상태 배지 + 잠금 버튼 (액션 없음) + - mypage_optional_consented_shows_revoke_button # 마이페이지 선택 동의함 = 철회 버튼 → 철회 확인 모달 + - mypage_optional_unset_shows_grant_button # 마이페이지 선택 거부함/미설정 = 동의 버튼 → 동의 확인 모달 + - mypage_status_date_line_consented_only # 동의한 항목만 날짜 줄 노출 (서버 status_at_formatted, 거부/미설정은 null) + - mypage_enable_all_bar_when_optional_off # 미동의 선택 항목 있을 때 '모두 동의' 바 노출 - admin_log_action_badge_rejected_amber # 관리자 동의이력 rejected 배지(amber) - admin_log_filter_rejected_checkbox # 관리자 [거부] 필터 체크박스 - banner_reject_button_sends_intent_reject # 배너 거부 버튼이 intent:'reject' 전송 From da031804ae49f11ec3ac138705cef29ece9773cc Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 12:24:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(sirsoft-gdpr):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=99=98=EA=B2=BD=EC=84=A4=EC=A0=95=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=20id=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20+=20=EB=8B=A4=ED=81=AC=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=A7=80=20=EC=83=89=EC=83=81=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cookie_categories_rows / blocked_domains_taginputs iteration 컨테이너의 정적 id를 index_var(categoryIdx)로 동적화 — 카테고리가 여러 개일 때 동일 HTML id가 중복 렌더되던 W3C id 유일성 위반 수정 (audit layout-iteration-static-id) - 카테고리 「필수」 배지 dark:bg-red-800 / text-danger-soft 클래스 사이 공백 누락으로 다크 모드 배경·글자색이 미적용되던 오타 수정 이슈 작업 중 audit-branch 전수 검사에서 발견한 기존 결함 (이번 이슈 기능과 무관, ) --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 9 +- .../layouts/admin_plugin_settings.test.tsx | 28 +- .../layouts/admin/plugin_settings.json | 339 +++++++++--------- 3 files changed, 191 insertions(+), 185 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 8e738382..557662e9 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-07-14 +## [1.0.2] - 2026-07-27 ### Added @@ -21,6 +21,13 @@ - 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). +### Fixed + +- 관리자 환경설정의 쿠키 카테고리·차단 도메인 목록에서 항목이 여러 개일 때 화면 요소 식별자가 중복되던 문제를 바로잡았습니다. +- 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다. +- 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. +- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. + ## [1.0.1] - 2026-07-16 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index e177dc81..0b6b3e4f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -140,7 +140,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au * 운영자가 카드에서 required 값을 변경하는 위젯/액션은 존재하지 않는다. */ it('카테고리 카드 영역에 Toggle/setState(required) 위젯이 없다', () => { - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).not.toContain('"name":"Toggle"'); @@ -149,7 +149,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('카테고리 카드 헤더에 「필수」/「선택」 배지가 required 값으로 분기 렌더된다', () => { - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).toContain('category_required_badge'); @@ -167,7 +167,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au */ it('Phase 1: cookie_categories 영역에 functional 분기 (아이콘 + scope + tools) 존재', () => { // 실제 카테고리 카드는 cookie_categories_rows iteration 노드에 위치 (subsection 헤더 별개) - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); // 1. 카테고리 아이콘 분기 (fa-sliders / emerald 색상) @@ -186,7 +186,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au // 법령 인용 (ePrivacy Art.5(3) / GDPR Art.6(1)(a) 등) 은 운영자 의사결정에 불필요하여 // 펼치기 영역에서 제거되었다. 정책 버전 snapshot 에 카테고리 본문이 영속되므로 // 감사 자료는 그쪽에서 확인 가능. - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).not.toContain('cookie_categories.info.necessary.legal_basis'); @@ -197,7 +197,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('Phase 1: 도메인 차단 섹션의 카테고리 아이콘에 functional 분기 존재', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // functional 카테고리 분기 (sliders 아이콘 + emerald 색상) @@ -331,7 +331,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('blocked_domains_taginputs iteration 이 cookie_categories 에서 necessary 제외하고 렌더', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); expect(wrapper).toBeTruthy(); expect(wrapper?.iteration?.source).toBe( "(_local.form?.cookie_categories ?? []).filter(c => c?.key !== 'necessary')" @@ -340,7 +340,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('TagInput 가 카테고리별 도메인 입력으로 사용되며 creatable=true 자유 입력 허용', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // TagInput composite 사용 expect(serialized).toContain('"name":"TagInput"'); @@ -353,7 +353,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('TagInput change 이벤트가 $event.target.value 에서 string 배열을 추출하여 form.blocked_domains 정적 키로 setState', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 정적 setState 키 — form.blocked_domains 만 사용 (CRITICAL 규정 준수) expect(serialized).toContain('"form.blocked_domains"'); @@ -368,7 +368,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('카탈로그 도메인 일괄 추가 링크 미존재 — 신규 설치 시 카탈로그가 이미 기본값으로 채워져 있고, 카탈로그 갱신 빈도 낮음. 운영자가 의도적으로 제외한 도메인이 재추가되는 위험 회피 (검토 #9)', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 일괄 추가 링크 라벨/툴팁 키 사용 금지 (lang 키 자체 제거됨) expect(serialized).not.toContain('blocked_domains.add_catalog_link'); @@ -392,7 +392,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('422 검증 에러 시 카테고리 카드 외곽이 빨간 테두리로 강조 + 카드 내부에 첫 잘못된 도메인 에러 메시지 Span 노출 (검토 #10 옵션 B — 카테고리 내 어느 인덱스든 매칭)', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 카드 외곽 동적 className — 카테고리 내 *어느 인덱스든* 에러 키가 있으면 true (some) expect(serialized).toContain("Object.keys(_local.errors ?? {}).some(k => k.startsWith('blocked_domains.' + category.key + '.'))"); @@ -406,8 +406,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).toContain('"form.blocked_domains"'); }); - it('header_save_button onError 토스트가 422 응답의 첫 번째 검증 에러 메시지를 노출 + fallback 일반화 메시지 유지 (검토 #10 옵션 A 기반)', () => { - const saveBtn = findById(root, 'header_save_button'); + it('footer_save_button onError 토스트가 422 응답의 첫 번째 검증 에러 메시지를 노출 + fallback 일반화 메시지 유지 (검토 #10 옵션 A 기반)', () => { + const saveBtn = findById(root, 'footer_save_button'); const serialized = JSON.stringify(saveBtn); // 첫 번째 검증 에러 메시지 추출 표현식 expect(serialized).toContain('Object.values(error.errors ?? {})[0]'); @@ -551,8 +551,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(Object.keys(params ?? {})).not.toContain('policyGuideOpen'); }); - it('상단 저장 sequence 는 단순 save_success 토스트만 노출 — published_version 분기 / 정책 버전 refetch 제거 (수동 발행 모델)', () => { - const btn = findById(root, 'header_save_button'); + it('하단 저장 sequence 는 단순 save_success 토스트만 노출 — published_version 분기 / 정책 버전 refetch 제거 (수동 발행 모델)', () => { + const btn = findById(root, 'footer_save_button'); const serialized = JSON.stringify(btn); // 옛 자동 발행 흐름의 분기 / refetch 제거 — 저장은 정책 버전을 발행하지 않으므로 분기 불필요 expect(serialized).not.toContain('response?.data?.published_version'); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index e577651e..859aedef 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -92,14 +92,14 @@ "dataKey": "form", "trackChanges": true, "props": { - "className": "bg-gray-50 dark:bg-gray-900 min-h-screen" + "className": "admin-page-content-responsive-fluid" }, "children": [ { "id": "sticky_header", "type": "basic", "name": "Div", - "comment": "헤더 + 탭을 sticky 컨테이너로 묶음 (게시판 설정과 동일한 구조)", + "comment": "헤더 + 탭을 sticky 컨테이너로 묶음 (게시판 설정과 동일한 구조 — root 가 admin-page-content-responsive-fluid 로 좌우 패딩을 이미 갖고 있으므로 header_content 는 자체 좌우 패딩을 두지 않는다. 그래야 sticky_header 의 border-b 폭이 아래 카드 폭과 정확히 일치한다).", "props": { "className": "sticky-section-header" }, @@ -109,16 +109,14 @@ "type": "basic", "name": "Div", "props": { - "className": "px-4 sm:px-6 lg:px-8 pt-4 pb-0" + "className": "pt-4 pb-0" }, "children": [ { "id": "header_top_row", "type": "basic", "name": "Div", - "props": { - "className": "panel-header-row" - }, + "comment": "제목 wrapper — 게시판 설정(page_header_wrapper)과 동일하게 별도 className 없음. panel-header-row(border-b 포함) 를 쓰면 탭 위에 불필요한 구분선이 하나 더 생긴다.", "children": [ { "id": "page_header", @@ -130,163 +128,6 @@ "backPath": "/admin/plugins", "backLabel": "$t:admin.plugins.back_to_list" } - }, - { - "id": "header_save_buttons", - "type": "basic", - "name": "Div", - "props": { - "className": "flex-center gap-3 flex-shrink-0 pt-1" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "if": "{{_local.hasChanges}}", - "props": { - "className": "flex-center gap-1.5 text-sm text-amber-600 dark:text-amber-400" - }, - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "circle-dot", - "size": "xs" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-gdpr.settings.unsaved_changes" - } - ] - }, - { - "id": "header_cancel_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "btn btn-secondary text-sm" - }, - "actions": [ - { - "type": "click", - "handler": "navigate", - "params": { - "path": "/admin/plugins" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Span", - "text": "$t:common.cancel" - } - ] - }, - { - "id": "header_save_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "disabled": "{{!_local.hasChanges || _local.isSaving}}", - "className": "btn btn-primary flex-center gap-1.5 text-sm" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": true, - "errors": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-gdpr/admin/settings", - "params": { - "method": "PUT", - "body": "{{_local.form}}" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "hasChanges": false - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "gdprSettings" - } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "$t:sirsoft-gdpr.settings.save_success" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "errors": "{{error.errors}}" - } - }, - { - "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.isSaving}}", - "props": { - "name": "spinner", - "size": "sm", - "spin": true - } - }, - { - "type": "basic", - "name": "Span", - "if": "{{!_local.isSaving}}", - "text": "$t:common.save" - }, - { - "type": "basic", - "name": "Span", - "if": "{{_local.isSaving}}", - "text": "$t:common.saving" - } - ] - } - ] } ] }, @@ -294,7 +135,7 @@ "id": "settings_tab_navigation", "type": "composite", "name": "TabNavigationScroll", - "comment": "상단 탭 스크롤 — 클릭 시 해당 카드로 자동 스크롤 + 스크롤 시 활성 탭 자동 갱신", + "comment": "상단 탭 스크롤 — settings_main_content(p-4 sm:p-6 lg:p-8) 안 카드들과 좌우 정렬을 맞추기 위해 header_content 패딩을 그대로 물려받는다(자체 좌우 패딩 없음). 클릭 시 해당 카드로 자동 스크롤 + 스크롤 시 활성 탭 자동 갱신.", "props": { "tabs": [ { @@ -326,9 +167,9 @@ "id": "settings_main_content", "type": "basic", "name": "Div", - "comment": "5 카드 컨테이너 (transition_overlay 타겟)", + "comment": "5 카드 컨테이너 (transition_overlay 타겟). root(admin-page-content-responsive-fluid) 가 좌우 패딩을 이미 가지므로 여기서는 세로 여백만 둔다. 상단 pt-6 은 게시판 설정(.tab-content — sticky 탭과 콘텐츠 사이 표준 24px)과 동일 값.", "props": { - "className": "p-4 sm:p-6 lg:p-8" + "className": "pt-6 pb-6" }, "children": [ { @@ -411,7 +252,7 @@ "type": "basic", "name": "Div", "props": { - "className": "flex-start justify-between gap-4 mb-3" + "className": "panel-header-row" }, "children": [ { @@ -1364,7 +1205,7 @@ ] }, { - "id": "cookie_categories_rows", + "id": "cookie_categories_rows_{{categoryIdx}}", "type": "basic", "name": "Div", "comment": "카드 구조: 헤더(아이콘+이름+key+필수/선택 배지) + 운영 정보 펼침. 카드는 정보 패널이며, 카테고리·필수 여부는 CookieCategory enum 이 SSoT.", @@ -1488,7 +1329,7 @@ "name": "Span", "if": "{{category?.required === true}}", "props": { - "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800text-danger-soft" + "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-danger-soft" }, "text": "$t:sirsoft-gdpr.settings.fields.cookie_categories.category_required_badge" }, @@ -1832,7 +1673,7 @@ ] }, { - "id": "blocked_domains_taginputs", + "id": "blocked_domains_taginputs_{{categoryIdx}}", "type": "basic", "name": "Div", "comment": "카테고리별 TagInput iteration — necessary 제외. options 에 카탈로그 도메인을 추천 노출 (creatable=true 로 자유 입력 가능)", @@ -1944,6 +1785,164 @@ ] } ] + }, + { + "id": "footer_buttons", + "type": "basic", + "name": "Div", + "comment": "하단 sticky 저장/취소 바 — 게시판 설정(admin_board_settings.json footer_buttons)과 동일 패턴으로 통일 (상단 헤더 배치는 스크롤 시 밀리는 것으로 오인되어 재배치).", + "props": { + "className": "sticky-footer-buttons" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "if": "{{_local.hasChanges}}", + "props": { + "className": "flex-center gap-1.5 text-sm text-amber-600 dark:text-amber-400 mr-auto" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "circle-dot", + "size": "xs" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-gdpr.settings.unsaved_changes" + } + ] + }, + { + "id": "footer_cancel_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary text-sm" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "id": "footer_save_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!_local.hasChanges || _local.isSaving}}", + "className": "btn btn-primary flex-center gap-1.5 text-sm" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": true, + "errors": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-gdpr/admin/settings", + "params": { + "method": "PUT", + "body": "{{_local.form}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "hasChanges": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "gdprSettings" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-gdpr.settings.save_success" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "errors": "{{error.errors}}" + } + }, + { + "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.isSaving}}", + "props": { + "name": "spinner", + "size": "sm", + "spin": true + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{!_local.isSaving}}", + "text": "$t:common.save" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.isSaving}}", + "text": "$t:common.saving" + } + ] + } + ] } ] } From 84555ea8eb97ce27e766af20b3072c63cf53335b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 16:18:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(lang-pack):=20g7-plugin-sirsoft-gdpr-ja?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20bump=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 작업 중 일본어 언어팩에 신규 키 12건을 추가했으나 language-pack.json version(1.0.0)을 patch bump 하지 않은 채 커밋됨 (규정: 키 추가/수정 → patch up). 1.0.1로 정정 + CHANGELOG 섹션 분리 + language-pack:update --force 로 설치본 반영. --- .../g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md | 15 ++++++++++----- .../g7-plugin-sirsoft-gdpr-ja/language-pack.json | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md index f3eca31d..a82eec79 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md @@ -4,14 +4,19 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-27 + +### Added + +- 쿠키 거부 기능 및 마이페이지 「쿠키 동의 관리」 재설계에 따른 신규 문구(상태 배지, 동의·철회 확인 모달, 거부 안내 등) 일본어 번역 추가 + +### Changed + +- 초기 토글 방식 설계에서 쓰였던 미사용 문구(항상 켜짐/켜짐/꺼짐) 번역 제거 + ## [1.0.0] - 2026-07-01 ### Added - 쿠키 동의 배너·약관·데이터 요청 등 사용자 화면 프론트엔드 텍스트 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. - GDPR 플러그인(sirsoft-gdpr)의 일본어 번들 언어팩 초기 제공 -- 쿠키 거부 기능 및 마이페이지 「쿠키 동의 관리」 재설계에 따른 신규 문구(상태 배지, 동의·철회 확인 모달, 거부 안내 등) 일본어 번역 추가 - -### Changed - -- 초기 토글 방식 설계에서 쓰였던 미사용 문구(항상 켜짐/켜짐/꺼짐) 번역 제거 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json index 2779e288..799fe5b4 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-gdpr",