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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/consentful/application.css +15 -0
  6. data/app/assets/stylesheets/consentful/consent.scss +518 -0
  7. data/app/components/consentful/banner.html.erb +53 -0
  8. data/app/components/consentful/banner.rb +27 -0
  9. data/app/components/consentful/modal.html.erb +19 -0
  10. data/app/components/consentful/modal.rb +21 -0
  11. data/app/components/consentful/preferences.html.erb +167 -0
  12. data/app/components/consentful/preferences.rb +48 -0
  13. data/app/components/consentful/service_detail.html.erb +99 -0
  14. data/app/components/consentful/service_detail.rb +18 -0
  15. data/app/components/consentful/toggle.html.erb +30 -0
  16. data/app/components/consentful/toggle.rb +30 -0
  17. data/app/controllers/concerns/consentful/visitor_cookie.rb +52 -0
  18. data/app/controllers/consentful/admin/base_controller.rb +27 -0
  19. data/app/controllers/consentful/admin/categories_controller.rb +46 -0
  20. data/app/controllers/consentful/admin/copy_versions_controller.rb +113 -0
  21. data/app/controllers/consentful/admin/dashboard_controller.rb +11 -0
  22. data/app/controllers/consentful/admin/records_controller.rb +11 -0
  23. data/app/controllers/consentful/admin/services_controller.rb +80 -0
  24. data/app/controllers/consentful/application_controller.rb +9 -0
  25. data/app/controllers/consentful/consent_controller.rb +132 -0
  26. data/app/helpers/consentful/application_helper.rb +4 -0
  27. data/app/helpers/consentful/consent_helper.rb +23 -0
  28. data/app/javascript/consentful/controllers/banner_controller.js +92 -0
  29. data/app/javascript/consentful/controllers/clipboard_copy_controller.js +30 -0
  30. data/app/javascript/consentful/controllers/modal_controller.js +50 -0
  31. data/app/javascript/consentful/controllers/modal_trigger_controller.js +9 -0
  32. data/app/javascript/consentful/controllers/preferences_controller.js +182 -0
  33. data/app/javascript/consentful/index.js +42 -0
  34. data/app/javascript/consentful/state.js +91 -0
  35. data/app/jobs/consentful/application_job.rb +4 -0
  36. data/app/mailers/consentful/application_mailer.rb +6 -0
  37. data/app/models/consentful/application_record.rb +8 -0
  38. data/app/models/consentful/consent_category.rb +21 -0
  39. data/app/models/consentful/consent_copy_version.rb +49 -0
  40. data/app/models/consentful/consent_record.rb +33 -0
  41. data/app/models/consentful/consent_service.rb +38 -0
  42. data/app/models/consentful/consent_state.rb +80 -0
  43. data/app/models/consentful/consent_visitor_link.rb +26 -0
  44. data/app/presenters/consentful/consent_record_presenter.rb +73 -0
  45. data/app/services/consentful/dashboard_report.rb +45 -0
  46. data/app/services/consentful/sar_lookup.rb +47 -0
  47. data/app/views/consentful/admin/categories/edit.html.erb +22 -0
  48. data/app/views/consentful/admin/categories/index.html.erb +17 -0
  49. data/app/views/consentful/admin/copy_versions/_form.html.erb +34 -0
  50. data/app/views/consentful/admin/copy_versions/edit.html.erb +2 -0
  51. data/app/views/consentful/admin/copy_versions/index.html.erb +34 -0
  52. data/app/views/consentful/admin/copy_versions/new.html.erb +2 -0
  53. data/app/views/consentful/admin/copy_versions/show.html.erb +24 -0
  54. data/app/views/consentful/admin/dashboard/show.html.erb +35 -0
  55. data/app/views/consentful/admin/records/index.html.erb +35 -0
  56. data/app/views/consentful/admin/services/_form.html.erb +42 -0
  57. data/app/views/consentful/admin/services/edit.html.erb +2 -0
  58. data/app/views/consentful/admin/services/index.html.erb +27 -0
  59. data/app/views/consentful/admin/services/new.html.erb +2 -0
  60. data/app/views/consentful/consent/preferences.html.erb +14 -0
  61. data/app/views/layouts/consentful/admin.html.erb +66 -0
  62. data/app/views/layouts/consentful/application.html.erb +17 -0
  63. data/config/routes.rb +25 -0
  64. data/db/migrate/20260522100000_create_consent_categories.rb +19 -0
  65. data/db/migrate/20260522100100_create_consent_services.rb +38 -0
  66. data/db/migrate/20260522100200_create_consent_copy_versions.rb +30 -0
  67. data/db/migrate/20260522100300_create_consent_records.rb +22 -0
  68. data/db/migrate/20260522100400_create_consent_visitor_links.rb +21 -0
  69. data/lib/consentful/configuration.rb +100 -0
  70. data/lib/consentful/engine.rb +5 -0
  71. data/lib/consentful/seed_starter.rb +57 -0
  72. data/lib/consentful/version.rb +3 -0
  73. data/lib/consentful.rb +27 -0
  74. data/lib/generators/consentful/install_generator.rb +29 -0
  75. data/lib/generators/consentful/templates/consentful.rb +35 -0
  76. data/lib/tasks/consentful_tasks.rake +9 -0
  77. 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,2 @@
1
+ <h1>Edit service</h1>
2
+ <%= render "form" %>
@@ -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,2 @@
1
+ <h1>New service</h1>
2
+ <%= render "form" %>
@@ -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,5 @@
1
+ module Consentful
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Consentful
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ module Consentful
2
+ VERSION = "0.1.0.pre1"
3
+ 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