consentful 0.1.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/consentful/application.css +15 -0
- data/app/assets/stylesheets/consentful/consent.scss +518 -0
- data/app/components/consentful/banner.html.erb +53 -0
- data/app/components/consentful/banner.rb +27 -0
- data/app/components/consentful/modal.html.erb +19 -0
- data/app/components/consentful/modal.rb +21 -0
- data/app/components/consentful/preferences.html.erb +167 -0
- data/app/components/consentful/preferences.rb +48 -0
- data/app/components/consentful/service_detail.html.erb +99 -0
- data/app/components/consentful/service_detail.rb +18 -0
- data/app/components/consentful/toggle.html.erb +30 -0
- data/app/components/consentful/toggle.rb +30 -0
- data/app/controllers/concerns/consentful/visitor_cookie.rb +52 -0
- data/app/controllers/consentful/admin/base_controller.rb +27 -0
- data/app/controllers/consentful/admin/categories_controller.rb +46 -0
- data/app/controllers/consentful/admin/copy_versions_controller.rb +113 -0
- data/app/controllers/consentful/admin/dashboard_controller.rb +11 -0
- data/app/controllers/consentful/admin/records_controller.rb +11 -0
- data/app/controllers/consentful/admin/services_controller.rb +80 -0
- data/app/controllers/consentful/application_controller.rb +9 -0
- data/app/controllers/consentful/consent_controller.rb +132 -0
- data/app/helpers/consentful/application_helper.rb +4 -0
- data/app/helpers/consentful/consent_helper.rb +23 -0
- data/app/javascript/consentful/controllers/banner_controller.js +92 -0
- data/app/javascript/consentful/controllers/clipboard_copy_controller.js +30 -0
- data/app/javascript/consentful/controllers/modal_controller.js +50 -0
- data/app/javascript/consentful/controllers/modal_trigger_controller.js +9 -0
- data/app/javascript/consentful/controllers/preferences_controller.js +182 -0
- data/app/javascript/consentful/index.js +42 -0
- data/app/javascript/consentful/state.js +91 -0
- data/app/jobs/consentful/application_job.rb +4 -0
- data/app/mailers/consentful/application_mailer.rb +6 -0
- data/app/models/consentful/application_record.rb +8 -0
- data/app/models/consentful/consent_category.rb +21 -0
- data/app/models/consentful/consent_copy_version.rb +49 -0
- data/app/models/consentful/consent_record.rb +33 -0
- data/app/models/consentful/consent_service.rb +38 -0
- data/app/models/consentful/consent_state.rb +80 -0
- data/app/models/consentful/consent_visitor_link.rb +26 -0
- data/app/presenters/consentful/consent_record_presenter.rb +73 -0
- data/app/services/consentful/dashboard_report.rb +45 -0
- data/app/services/consentful/sar_lookup.rb +47 -0
- data/app/views/consentful/admin/categories/edit.html.erb +22 -0
- data/app/views/consentful/admin/categories/index.html.erb +17 -0
- data/app/views/consentful/admin/copy_versions/_form.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/edit.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/index.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/new.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/show.html.erb +24 -0
- data/app/views/consentful/admin/dashboard/show.html.erb +35 -0
- data/app/views/consentful/admin/records/index.html.erb +35 -0
- data/app/views/consentful/admin/services/_form.html.erb +42 -0
- data/app/views/consentful/admin/services/edit.html.erb +2 -0
- data/app/views/consentful/admin/services/index.html.erb +27 -0
- data/app/views/consentful/admin/services/new.html.erb +2 -0
- data/app/views/consentful/consent/preferences.html.erb +14 -0
- data/app/views/layouts/consentful/admin.html.erb +66 -0
- data/app/views/layouts/consentful/application.html.erb +17 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260522100000_create_consent_categories.rb +19 -0
- data/db/migrate/20260522100100_create_consent_services.rb +38 -0
- data/db/migrate/20260522100200_create_consent_copy_versions.rb +30 -0
- data/db/migrate/20260522100300_create_consent_records.rb +22 -0
- data/db/migrate/20260522100400_create_consent_visitor_links.rb +21 -0
- data/lib/consentful/configuration.rb +100 -0
- data/lib/consentful/engine.rb +5 -0
- data/lib/consentful/seed_starter.rb +57 -0
- data/lib/consentful/version.rb +3 -0
- data/lib/consentful.rb +27 -0
- data/lib/generators/consentful/install_generator.rb +29 -0
- data/lib/generators/consentful/templates/consentful.rb +35 -0
- data/lib/tasks/consentful_tasks.rake +9 -0
- metadata +149 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<h1>Dashboard</h1>
|
|
2
|
+
<p class="cf-muted">Last <%= @report.days %> days (since <%= @report.since.strftime("%Y-%m-%d") %>).</p>
|
|
3
|
+
|
|
4
|
+
<div class="cf-card">
|
|
5
|
+
<div class="cf-stat"><%= @report.total_records %></div>
|
|
6
|
+
<div class="cf-muted">consent events recorded</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<h2>Category acceptance</h2>
|
|
10
|
+
<table class="cf-table">
|
|
11
|
+
<thead><tr><th>Category</th><th>Accepted</th><th>Rate</th></tr></thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
<% @report.category_acceptance.each do |row| %>
|
|
14
|
+
<tr>
|
|
15
|
+
<td><%= row[:category].name %></td>
|
|
16
|
+
<td><%= row[:accepted] %> / <%= row[:total] %></td>
|
|
17
|
+
<td><%= row[:rate] %>%</td>
|
|
18
|
+
</tr>
|
|
19
|
+
<% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
|
|
23
|
+
<h2>Source breakdown</h2>
|
|
24
|
+
<table class="cf-table">
|
|
25
|
+
<thead><tr><th>Source</th><th>Count</th><th>Rate</th></tr></thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @report.source_breakdown.each do |row| %>
|
|
28
|
+
<tr>
|
|
29
|
+
<td><%= row[:source].to_s.humanize %></td>
|
|
30
|
+
<td><%= row[:count] %></td>
|
|
31
|
+
<td><%= row[:rate] %>%</td>
|
|
32
|
+
</tr>
|
|
33
|
+
<% end %>
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<h1>SAR Lookup</h1>
|
|
2
|
+
<p class="cf-muted">Resolve a subject-access request by email or visitor reference into every recorded consent event.</p>
|
|
3
|
+
|
|
4
|
+
<div class="cf-card">
|
|
5
|
+
<%= form_with url: admin_records_path, method: :get do |f| %>
|
|
6
|
+
<div class="cf-field"><%= f.label :email %><%= f.text_field :email, value: @lookup.email %></div>
|
|
7
|
+
<div class="cf-field"><%= f.label :visitor_uuid, "Visitor reference (UUID)" %><%= f.text_field :visitor_uuid, value: @lookup.visitor_uuid %></div>
|
|
8
|
+
<%= f.submit "Search", class: "cf-btn cf-btn--primary" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if @lookup.given_any_input? %>
|
|
13
|
+
<h2><%= @lookup.records.size %> consent event(s)</h2>
|
|
14
|
+
|
|
15
|
+
<% if @lookup.linked_identifiers.any? %>
|
|
16
|
+
<p class="cf-muted">Linked identifiers:
|
|
17
|
+
<%= @lookup.linked_identifiers.map { |link| "#{link.identifier_type}: #{link.identifier_value}" }.join("; ") %>
|
|
18
|
+
</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @lookup.records.empty? %>
|
|
22
|
+
<p class="cf-muted">No consent records found for that subject.</p>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% @lookup.records.each do |record| %>
|
|
26
|
+
<% text = Consentful::ConsentRecordPresenter.new(record).email_text %>
|
|
27
|
+
<div class="cf-card">
|
|
28
|
+
<pre class="cf-pre"><%= text %></pre>
|
|
29
|
+
<button type="button" class="cf-btn"
|
|
30
|
+
data-controller="consentful--clipboard"
|
|
31
|
+
data-consentful--clipboard-text-value="<%= text %>"
|
|
32
|
+
data-action="consentful--clipboard#copy">Copy for email</button>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%= form_with model: @service, scope: :consent_service,
|
|
2
|
+
url: (@service.persisted? ? admin_service_path(@service) : admin_services_path) do |f| %>
|
|
3
|
+
<% if @service.errors.any? %>
|
|
4
|
+
<div class="cf-flash cf-flash--alert"><%= @service.errors.full_messages.to_sentence %></div>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<div class="cf-card">
|
|
8
|
+
<h2>Wiring</h2>
|
|
9
|
+
<div class="cf-field"><%= f.label :name %><%= f.text_field :name %></div>
|
|
10
|
+
<div class="cf-field"><%= f.label :slug %><%= f.text_field :slug %><p class="cf-hint">Lowercase letters, digits and underscores only.</p></div>
|
|
11
|
+
<div class="cf-field"><%= f.label :consent_category_id, "Category" %><%= f.collection_select :consent_category_id, @categories, :id, :name %></div>
|
|
12
|
+
<div class="cf-field"><%= f.label :loader_kind %><%= f.select :loader_kind, Consentful::ConsentService::LOADER_KIND_LABELS.map { |k, v| [v, k] } %></div>
|
|
13
|
+
<div class="cf-field"><%= f.label :loader_ref, "Loader reference" %><%= f.text_field :loader_ref %><p class="cf-hint">Partial path(s) or tag id, depending on loader kind.</p></div>
|
|
14
|
+
<div class="cf-field"><%= f.label :domain %><%= f.text_field :domain %></div>
|
|
15
|
+
<div class="cf-field"><label><%= f.check_box :active %> Active</label></div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="cf-card">
|
|
19
|
+
<h2>GDPR Art. 13/14 disclosure</h2>
|
|
20
|
+
<div class="cf-field"><%= f.label :description %><%= f.text_area :description %></div>
|
|
21
|
+
<div class="cf-field"><%= f.label :processing_company_name %><%= f.text_field :processing_company_name %></div>
|
|
22
|
+
<div class="cf-field"><%= f.label :processing_company_address %><%= f.text_field :processing_company_address %></div>
|
|
23
|
+
<div class="cf-field"><%= f.label :dpo_email, "DPO email" %><%= f.text_field :dpo_email %></div>
|
|
24
|
+
<div class="cf-field"><%= f.label :privacy_url %><%= f.text_field :privacy_url %></div>
|
|
25
|
+
<div class="cf-field"><%= f.label :legal_basis %><%= f.text_field :legal_basis %></div>
|
|
26
|
+
<div class="cf-field"><%= f.label :retention_period %><%= f.text_area :retention_period %></div>
|
|
27
|
+
<div class="cf-field"><%= f.label :processing_location %><%= f.text_field :processing_location %></div>
|
|
28
|
+
<div class="cf-field"><%= f.label :third_country_transfer %><%= f.text_area :third_country_transfer %></div>
|
|
29
|
+
<div class="cf-field"><%= f.label :data_purposes %><%= f.text_field :data_purposes, value: @service.data_purposes.join(", ") %><p class="cf-hint">Comma-separated.</p></div>
|
|
30
|
+
<div class="cf-field"><%= f.label :technologies_used %><%= f.text_field :technologies_used, value: @service.technologies_used.join(", ") %><p class="cf-hint">Comma-separated.</p></div>
|
|
31
|
+
<div class="cf-field"><%= f.label :data_collected %><%= f.text_field :data_collected, value: @service.data_collected.join(", ") %><p class="cf-hint">Comma-separated.</p></div>
|
|
32
|
+
<div class="cf-field"><%= f.label :cookie_names %><%= f.text_field :cookie_names, value: @service.cookie_names.join(", ") %><p class="cf-hint">Comma-separated.</p></div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="cf-actions">
|
|
36
|
+
<%= f.submit (@service.persisted? ? "Save service" : "Create service"), class: "cf-btn cf-btn--primary" %>
|
|
37
|
+
<%= link_to "Cancel", admin_services_path, class: "cf-btn" %>
|
|
38
|
+
<% if @service.persisted? %>
|
|
39
|
+
<%= button_to "Delete", admin_service_path(@service), method: :delete, class: "cf-btn cf-btn--danger", form: { data: { turbo_confirm: "Delete this service?" } } %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<h1>Services</h1>
|
|
2
|
+
|
|
3
|
+
<div class="cf-actions" style="margin-bottom:16px;">
|
|
4
|
+
<%= link_to "New service", new_admin_service_path, class: "cf-btn cf-btn--primary" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% @services_by_category.each do |category, services| %>
|
|
8
|
+
<h2><%= category.name %></h2>
|
|
9
|
+
<table class="cf-table">
|
|
10
|
+
<thead><tr><th>Name</th><th>Slug</th><th>Loader</th><th>Active</th><th></th></tr></thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% if services.empty? %>
|
|
13
|
+
<tr><td colspan="5" class="cf-muted">No services in this category.</td></tr>
|
|
14
|
+
<% else %>
|
|
15
|
+
<% services.each do |service| %>
|
|
16
|
+
<tr>
|
|
17
|
+
<td><%= service.name %></td>
|
|
18
|
+
<td><code><%= service.slug %></code></td>
|
|
19
|
+
<td class="cf-muted"><%= service.loader_kind_label %></td>
|
|
20
|
+
<td><%= service.active? ? "Yes" : "No" %></td>
|
|
21
|
+
<td><%= link_to "Edit", edit_admin_service_path(service), class: "cf-btn" %></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
27
|
+
<% end %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<div class="c-consent-page">
|
|
2
|
+
<% if @published_version.present? %>
|
|
3
|
+
<%= render Consentful::Preferences.new(
|
|
4
|
+
version: @published_version,
|
|
5
|
+
categories: @categories,
|
|
6
|
+
visitor_uuid: consent_visitor_id,
|
|
7
|
+
submit_path: consent_path,
|
|
8
|
+
variant: :page
|
|
9
|
+
) %>
|
|
10
|
+
<% else %>
|
|
11
|
+
<h1 class="c-consent-page__title">Cookie preferences</h1>
|
|
12
|
+
<p class="c-consent-page__empty">No consent copy has been published yet. Please check back shortly.</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Consent Management</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --cf-navy:#002b49; --cf-blue:#0481b7; --cf-gold:#ac8c30; --cf-ea:#eaeaea; --cf-f2:#f2f2f2; --cf-49:#494949; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--cf-navy); background:var(--cf-f2); }
|
|
12
|
+
a { color:var(--cf-blue); }
|
|
13
|
+
.cf-header { background:var(--cf-navy); color:#fff; padding:0 24px; }
|
|
14
|
+
.cf-header__title { margin:0; padding:16px 0 8px; font-size:18px; font-weight:700; }
|
|
15
|
+
.cf-nav { display:flex; gap:4px; flex-wrap:wrap; }
|
|
16
|
+
.cf-nav a { color:#cfe3ef; text-decoration:none; padding:10px 14px; border-radius:6px 6px 0 0; font-size:14px; }
|
|
17
|
+
.cf-nav a:hover { background:rgba(255,255,255,.08); color:#fff; }
|
|
18
|
+
.cf-nav a.is-active { background:var(--cf-f2); color:var(--cf-navy); font-weight:600; }
|
|
19
|
+
.cf-main { max-width:1040px; margin:0 auto; padding:24px; }
|
|
20
|
+
.cf-flash { padding:12px 16px; border-radius:6px; margin-bottom:16px; font-size:14px; }
|
|
21
|
+
.cf-flash--notice { background:#e6f4ea; color:#1e4620; border:1px solid #b7dcc0; }
|
|
22
|
+
.cf-flash--alert { background:#fdecea; color:#611a15; border:1px solid #f3c2bd; }
|
|
23
|
+
h1 { font-size:22px; margin:0 0 16px; }
|
|
24
|
+
h2 { font-size:17px; margin:24px 0 12px; }
|
|
25
|
+
.cf-card { background:#fff; border:1px solid var(--cf-ea); border-radius:8px; padding:20px; margin-bottom:16px; }
|
|
26
|
+
table.cf-table { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--cf-ea); border-radius:8px; overflow:hidden; }
|
|
27
|
+
.cf-table th, .cf-table td { text-align:left; padding:10px 14px; border-bottom:1px solid var(--cf-ea); font-size:14px; }
|
|
28
|
+
.cf-table th { background:var(--cf-f2); font-weight:600; }
|
|
29
|
+
.cf-table tr:last-child td { border-bottom:none; }
|
|
30
|
+
.cf-btn { display:inline-block; padding:8px 14px; border-radius:6px; border:1px solid var(--cf-ea); background:#fff; color:var(--cf-navy); font-size:14px; text-decoration:none; cursor:pointer; }
|
|
31
|
+
.cf-btn--primary { background:var(--cf-navy); color:#fff; border-color:var(--cf-navy); }
|
|
32
|
+
.cf-btn--gold { background:var(--cf-gold); color:#fff; border-color:var(--cf-gold); }
|
|
33
|
+
.cf-btn--danger { color:#b3261e; border-color:#e7b4b0; }
|
|
34
|
+
.cf-field { margin-bottom:14px; display:block; }
|
|
35
|
+
.cf-field label { display:block; font-size:13px; font-weight:600; margin-bottom:4px; }
|
|
36
|
+
.cf-field input[type=text], .cf-field textarea, .cf-field select { width:100%; padding:8px 10px; border:1px solid var(--cf-ea); border-radius:6px; font-size:14px; font-family:inherit; }
|
|
37
|
+
.cf-field textarea { min-height:80px; }
|
|
38
|
+
.cf-hint { font-size:12px; color:var(--cf-49); margin-top:4px; }
|
|
39
|
+
.cf-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:600; }
|
|
40
|
+
.cf-badge--draft { background:var(--cf-f2); color:var(--cf-49); }
|
|
41
|
+
.cf-badge--published { background:#e6f4ea; color:#1e4620; }
|
|
42
|
+
.cf-badge--archived { background:#fdecea; color:#611a15; }
|
|
43
|
+
.cf-actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
44
|
+
.cf-stat { font-size:28px; font-weight:700; }
|
|
45
|
+
pre.cf-pre { background:var(--cf-f2); border:1px solid var(--cf-ea); border-radius:6px; padding:12px; font-size:12px; overflow:auto; white-space:pre-wrap; }
|
|
46
|
+
.cf-muted { color:var(--cf-49); font-size:13px; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<header class="cf-header">
|
|
51
|
+
<h1 class="cf-header__title">Consent Management</h1>
|
|
52
|
+
<nav class="cf-nav">
|
|
53
|
+
<% nav = [["Dashboard", admin_root_path, "dashboard"], ["Copy", admin_copy_versions_path, "copy_versions"], ["Categories", admin_categories_path, "categories"], ["Services", admin_services_path, "services"], ["SAR Lookup", admin_records_path, "records"]] %>
|
|
54
|
+
<% nav.each do |label, path, ctrl| %>
|
|
55
|
+
<%= link_to label, path, class: ("is-active" if controller_name == ctrl) %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</nav>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<main class="cf-main">
|
|
61
|
+
<% if flash[:notice] %><div class="cf-flash cf-flash--notice"><%= flash[:notice] %></div><% end %>
|
|
62
|
+
<% if flash[:alert] %><div class="cf-flash cf-flash--alert"><%= flash[:alert] %></div><% end %>
|
|
63
|
+
<%= yield %>
|
|
64
|
+
</main>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Consentful</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "consentful/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Consentful::Engine.routes.draw do
|
|
2
|
+
# Public endpoints — intended to mount at the host root.
|
|
3
|
+
post "/consent", to: "consent#update", as: :consent
|
|
4
|
+
post "/consent/link", to: "consent#link", as: :consent_link
|
|
5
|
+
get "/consent/state", to: "consent#state", as: :consent_state
|
|
6
|
+
get "/cookie-preferences", to: "consent#preferences", as: :cookie_preferences
|
|
7
|
+
|
|
8
|
+
# Self-contained admin panel — Flipper-style. Mounted at <engine>/consent-management,
|
|
9
|
+
# gated by Consentful.config.authorize.
|
|
10
|
+
scope path: "consent-management", as: :admin, module: :admin do
|
|
11
|
+
root to: "dashboard#show", as: :root
|
|
12
|
+
resources :categories, only: [ :index, :edit, :update ] do
|
|
13
|
+
post :reorder, on: :collection
|
|
14
|
+
end
|
|
15
|
+
resources :services
|
|
16
|
+
resources :copy_versions do
|
|
17
|
+
member do
|
|
18
|
+
post :publish
|
|
19
|
+
post :archive
|
|
20
|
+
post :restore
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
resources :records, only: [ :index ]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateConsentCategories < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :consent_categories do |t|
|
|
6
|
+
t.string :key, null: false
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.text :description
|
|
9
|
+
t.integer :position, null: false, default: 0
|
|
10
|
+
t.boolean :required, null: false, default: false
|
|
11
|
+
t.string :gtag_keys, array: true, default: [], null: false
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :consent_categories, :key, unique: true
|
|
17
|
+
add_index :consent_categories, :position
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateConsentServices < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :consent_services do |t|
|
|
6
|
+
# Identification / wiring
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.string :slug, null: false
|
|
9
|
+
t.references :consent_category, null: false, foreign_key: true
|
|
10
|
+
t.string :domain
|
|
11
|
+
t.integer :loader_kind, null: false, default: 0
|
|
12
|
+
t.string :loader_ref
|
|
13
|
+
t.boolean :active, null: false, default: true
|
|
14
|
+
t.integer :position, null: false, default: 0
|
|
15
|
+
|
|
16
|
+
# GDPR Art. 13/14 detail-card content
|
|
17
|
+
t.text :description
|
|
18
|
+
t.string :processing_company_name
|
|
19
|
+
t.string :processing_company_address
|
|
20
|
+
t.string :dpo_email
|
|
21
|
+
t.string :privacy_url
|
|
22
|
+
t.string :data_purposes, array: true, default: [], null: false
|
|
23
|
+
t.string :technologies_used, array: true, default: [], null: false
|
|
24
|
+
t.string :data_collected, array: true, default: [], null: false
|
|
25
|
+
t.string :legal_basis
|
|
26
|
+
t.string :processing_location
|
|
27
|
+
t.text :retention_period
|
|
28
|
+
t.text :third_country_transfer
|
|
29
|
+
t.string :cookie_names, array: true, default: [], null: false
|
|
30
|
+
|
|
31
|
+
t.timestamps
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
add_index :consent_services, :slug, unique: true
|
|
35
|
+
add_index :consent_services, [ :consent_category_id, :position ]
|
|
36
|
+
add_index :consent_services, :active
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateConsentCopyVersions < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :consent_copy_versions do |t|
|
|
6
|
+
t.integer :status, null: false, default: 0
|
|
7
|
+
t.datetime :published_at
|
|
8
|
+
# Free-form identity of whoever published, resolved by the host via
|
|
9
|
+
# Consentful.config.identify. Not a foreign key.
|
|
10
|
+
t.string :published_by
|
|
11
|
+
|
|
12
|
+
t.string :banner_title
|
|
13
|
+
t.text :banner_body
|
|
14
|
+
t.text :modal_intro
|
|
15
|
+
t.string :accept_label
|
|
16
|
+
t.string :reject_label
|
|
17
|
+
t.string :manage_label
|
|
18
|
+
t.string :save_label
|
|
19
|
+
|
|
20
|
+
t.jsonb :category_blurbs, null: false, default: {}
|
|
21
|
+
t.string :digest, null: false
|
|
22
|
+
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_index :consent_copy_versions, :status
|
|
27
|
+
add_index :consent_copy_versions, :digest
|
|
28
|
+
add_index :consent_copy_versions, :published_at
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateConsentRecords < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :consent_records do |t|
|
|
6
|
+
t.string :visitor_uuid, null: false
|
|
7
|
+
t.references :consent_copy_version, null: false, foreign_key: true
|
|
8
|
+
t.jsonb :category_choices, null: false, default: {}
|
|
9
|
+
t.jsonb :service_overrides, null: false, default: {}
|
|
10
|
+
t.integer :source, null: false
|
|
11
|
+
t.string :user_agent
|
|
12
|
+
t.string :referer
|
|
13
|
+
t.datetime :recorded_at, null: false
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :consent_records, :visitor_uuid
|
|
19
|
+
add_index :consent_records, :recorded_at
|
|
20
|
+
add_index :consent_records, [ :visitor_uuid, :recorded_at ]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateConsentVisitorLinks < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :consent_visitor_links do |t|
|
|
6
|
+
t.string :visitor_uuid, null: false
|
|
7
|
+
t.integer :identifier_type, null: false
|
|
8
|
+
t.string :identifier_value, null: false
|
|
9
|
+
t.datetime :linked_at, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :consent_visitor_links, :visitor_uuid
|
|
15
|
+
add_index :consent_visitor_links, [ :identifier_type, :identifier_value ]
|
|
16
|
+
add_index :consent_visitor_links,
|
|
17
|
+
[ :visitor_uuid, :identifier_type, :identifier_value ],
|
|
18
|
+
unique: true,
|
|
19
|
+
name: "index_consent_visitor_links_on_uuid_and_identifier"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Consentful
|
|
2
|
+
# Host-app configuration for the engine. Set via `Consentful.configure`.
|
|
3
|
+
#
|
|
4
|
+
# Consentful.configure do |c|
|
|
5
|
+
# c.cookie_name = "scl_consent"
|
|
6
|
+
# c.authorize = ->(controller) { controller.current_admin&.super_admin? }
|
|
7
|
+
# c.identify = ->(controller) { controller.current_admin&.email }
|
|
8
|
+
# end
|
|
9
|
+
class Configuration
|
|
10
|
+
# Name of the cookie storing the visitor's consent state.
|
|
11
|
+
attr_accessor :cookie_name
|
|
12
|
+
|
|
13
|
+
# Name of the cookie storing the anonymous visitor UUID.
|
|
14
|
+
attr_accessor :visitor_cookie_name
|
|
15
|
+
|
|
16
|
+
# How long a recorded consent (and its cookie) remains valid, in months.
|
|
17
|
+
attr_accessor :cookie_expiry_months
|
|
18
|
+
|
|
19
|
+
# Ordered list of consent category keys the host supports.
|
|
20
|
+
attr_accessor :category_keys
|
|
21
|
+
|
|
22
|
+
# The category key that is always-on and cannot be rejected.
|
|
23
|
+
attr_accessor :required_category_key
|
|
24
|
+
|
|
25
|
+
# When false, tracking-enforcement helpers allow everything (rollout flag).
|
|
26
|
+
attr_accessor :enforce
|
|
27
|
+
|
|
28
|
+
# Identifier types (name => enum value) used to link an anonymous visitor
|
|
29
|
+
# UUID back to a known identity for SAR resolution. Host domain concepts.
|
|
30
|
+
attr_accessor :visitor_identifier_types
|
|
31
|
+
|
|
32
|
+
# ->(context) { truthy } gate for the mounted admin panel. `context` is the
|
|
33
|
+
# admin controller instance, so the host can reach its own auth helpers.
|
|
34
|
+
attr_writer :authorize
|
|
35
|
+
|
|
36
|
+
# ->(context) { String } resolving who is acting in the admin panel, stored
|
|
37
|
+
# as `published_by` on a copy version. Replaces a hard FK to a host model.
|
|
38
|
+
attr_writer :identify
|
|
39
|
+
|
|
40
|
+
# ->(request) { Hash } building cookie options (secure/same_site/domain…).
|
|
41
|
+
# Hosts override to add a cookie `:domain` for cross-subdomain consent.
|
|
42
|
+
attr_writer :cookie_options
|
|
43
|
+
|
|
44
|
+
# Optional ->(exception) { } sink for swallowed errors (e.g. Rollbar.error).
|
|
45
|
+
attr_writer :error_handler
|
|
46
|
+
|
|
47
|
+
# Contact address shown on the preference centre for subject-access requests.
|
|
48
|
+
attr_accessor :sar_contact_email
|
|
49
|
+
|
|
50
|
+
# Layout used to render the standalone /cookie-preferences page. Hosts set
|
|
51
|
+
# this to their own layout (e.g. "application") to wrap it in site chrome.
|
|
52
|
+
attr_accessor :layout
|
|
53
|
+
|
|
54
|
+
def initialize
|
|
55
|
+
@cookie_name = "consent"
|
|
56
|
+
@visitor_cookie_name = "visitor_id"
|
|
57
|
+
@cookie_expiry_months = 12
|
|
58
|
+
@category_keys = %w[necessary analytics marketing]
|
|
59
|
+
@required_category_key = "necessary"
|
|
60
|
+
@enforce = false
|
|
61
|
+
@visitor_identifier_types = { email: 0 }
|
|
62
|
+
@authorize = ->(_context) { true }
|
|
63
|
+
@identify = ->(_context) { nil }
|
|
64
|
+
@cookie_options = ->(_request) { { secure: Rails.env.production?, same_site: :lax, httponly: false } }
|
|
65
|
+
@error_handler = nil
|
|
66
|
+
@sar_contact_email = nil
|
|
67
|
+
@layout = "consentful/application"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cookie_options(request)
|
|
71
|
+
@cookie_options.call(request)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def error_handler(exception)
|
|
75
|
+
@error_handler&.call(exception)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authorize(context)
|
|
79
|
+
@authorize.call(context)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def identify(context)
|
|
83
|
+
@identify.call(context)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
def config
|
|
89
|
+
@config ||= Configuration.new
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def configure
|
|
93
|
+
yield config
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reset_config!
|
|
97
|
+
@config = Configuration.new
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
# Idempotent starter data so a fresh install has working categories, a couple
|
|
5
|
+
# of example services, and a published copy version (so the banner renders).
|
|
6
|
+
# Safe to run repeatedly. Host apps are expected to curate their own services.
|
|
7
|
+
module SeedStarter
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
CATEGORIES = [
|
|
11
|
+
{ key: "necessary", name: "Strictly Necessary", required: true, position: 0,
|
|
12
|
+
description: "Required for the site to function.", gtag_keys: %w[security_storage] },
|
|
13
|
+
{ key: "analytics", name: "Analytics", required: false, position: 1,
|
|
14
|
+
description: "Helps us understand how visitors use the site.", gtag_keys: %w[analytics_storage] },
|
|
15
|
+
{ key: "marketing", name: "Marketing", required: false, position: 2,
|
|
16
|
+
description: "Used to deliver relevant advertising.", gtag_keys: %w[ad_storage ad_user_data ad_personalization] }
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
categories = seed_categories
|
|
21
|
+
seed_example_services(categories)
|
|
22
|
+
seed_copy_version
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def seed_categories
|
|
26
|
+
CATEGORIES.to_h do |attrs|
|
|
27
|
+
category = ConsentCategory.find_or_initialize_by(key: attrs[:key])
|
|
28
|
+
category.update!(attrs)
|
|
29
|
+
[ attrs[:key], category ]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def seed_example_services(categories)
|
|
34
|
+
ConsentService.find_or_create_by!(slug: "example_analytics") do |service|
|
|
35
|
+
service.assign_attributes(
|
|
36
|
+
name: "Example Analytics", consent_category: categories["analytics"],
|
|
37
|
+
loader_kind: :external_only, active: true, position: 0,
|
|
38
|
+
description: "An example analytics service — replace with your own."
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def seed_copy_version
|
|
44
|
+
return if ConsentCopyVersion.current
|
|
45
|
+
|
|
46
|
+
ConsentCopyVersion.create!(
|
|
47
|
+
status: :published, published_at: Time.current, published_by: "consentful:seed",
|
|
48
|
+
banner_title: "Privacy settings",
|
|
49
|
+
banner_body: "We use cookies to improve your experience.",
|
|
50
|
+
modal_intro: "Manage how we and our partners use cookies and similar technologies.",
|
|
51
|
+
accept_label: "Accept all", reject_label: "Reject all",
|
|
52
|
+
manage_label: "Manage", save_label: "Save settings",
|
|
53
|
+
category_blurbs: CATEGORIES.to_h { |c| [ c[:key], c[:description] ] }
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/consentful.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "view_component"
|
|
2
|
+
|
|
3
|
+
require "consentful/version"
|
|
4
|
+
require "consentful/configuration"
|
|
5
|
+
require "consentful/seed_starter"
|
|
6
|
+
|
|
7
|
+
module Consentful
|
|
8
|
+
# Link an anonymous visitor UUID to a known identity (email, lead id, …) so a
|
|
9
|
+
# later subject-access request can resolve every consent record for a person.
|
|
10
|
+
def self.link!(visitor_uuid, type:, value:)
|
|
11
|
+
return if visitor_uuid.blank? || value.blank?
|
|
12
|
+
|
|
13
|
+
ConsentVisitorLink.find_or_create_by!(
|
|
14
|
+
visitor_uuid:,
|
|
15
|
+
identifier_type: type,
|
|
16
|
+
identifier_value: normalize_value(type, value)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.normalize_value(type, value)
|
|
21
|
+
return value.to_s.strip.downcase if type.to_s == "email"
|
|
22
|
+
|
|
23
|
+
value.to_s.strip
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require "consentful/engine"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Consentful
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates the Consentful initializer and prints the remaining install steps."
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "consentful.rb", "config/initializers/consentful.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_readme
|
|
17
|
+
say "\nConsentful installed. Remaining steps:", :green
|
|
18
|
+
say " 1. Install migrations: bin/rails consentful:install:migrations"
|
|
19
|
+
say " 2. Migrate: bin/rails db:migrate"
|
|
20
|
+
say " 3. (optional) Seed: bin/rails consentful:seed"
|
|
21
|
+
say " 4. Mount the engine in config/routes.rb:"
|
|
22
|
+
say " mount Consentful::Engine => \"/\""
|
|
23
|
+
say " 5. Edit config/initializers/consentful.rb — set `authorize` at minimum."
|
|
24
|
+
say " 6. JS: import { registerConsentful } from 'consentful'; registerConsentful(application)"
|
|
25
|
+
say " 7. CSS: @import \"consentful/consent\";"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|