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,167 @@
1
+ <div
2
+ data-controller="consentful--preferences"
3
+ data-consentful--preferences-expected-version-id-value="<%= version.id %>"
4
+ data-consentful--preferences-submit-path-value="<%= submit_path %>"
5
+ data-consentful--preferences-categories-value="<%= categories_payload.to_json %>"
6
+ class="c-consent-preferences <%= modal? ? "c-consent-preferences--modal" : "c-consent-preferences--page" %>"
7
+ >
8
+ <% if modal? %>
9
+ <div class="c-consent-preferences__header">
10
+ <h2 class="c-consent-preferences__header-title"><%= version.banner_title %></h2>
11
+ <button type="button" data-action="consentful--preferences#close" aria-label="Close" class="c-consent-preferences__close">
12
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
13
+ <path d="M5 5l10 10M15 5L5 15" stroke-linecap="round"/>
14
+ </svg>
15
+ </button>
16
+ </div>
17
+ <% end %>
18
+
19
+ <div class="c-consent-preferences__body">
20
+ <div class="c-consent-preferences__intro">
21
+ <% unless modal? %>
22
+ <h1 class="c-consent-preferences__page-title"><%= version.banner_title %></h1>
23
+ <% end %>
24
+ <p class="c-consent-preferences__intro-body"><%= version.modal_intro.presence || version.banner_body %></p>
25
+ </div>
26
+
27
+ <div class="c-consent-preferences__tabs" role="tablist">
28
+ <button
29
+ type="button"
30
+ role="tab"
31
+ data-action="consentful--preferences#switchTab"
32
+ data-consentful--preferences-target="tab"
33
+ data-tab="categories"
34
+ aria-selected="true"
35
+ class="c-consent-preferences__tab c-consent-preferences__tab--active"
36
+ >Categories</button>
37
+ <button
38
+ type="button"
39
+ role="tab"
40
+ data-action="consentful--preferences#switchTab"
41
+ data-consentful--preferences-target="tab"
42
+ data-tab="services"
43
+ aria-selected="false"
44
+ class="c-consent-preferences__tab"
45
+ >Services</button>
46
+ </div>
47
+
48
+ <div data-consentful--preferences-target="tabPanel" data-tab="categories" class="c-consent-preferences__panel">
49
+ <% categories.each do |category| %>
50
+ <section class="c-consent-category" data-category-key="<%= category.key %>">
51
+ <header class="c-consent-category__header">
52
+ <div class="c-consent-category__heading">
53
+ <h3 class="c-consent-category__title"><%= category.name %></h3>
54
+ <% if category_blurb(category).present? %>
55
+ <p class="c-consent-category__blurb"><%= category_blurb(category) %></p>
56
+ <% end %>
57
+ </div>
58
+ <%= render Consentful::Toggle.new(
59
+ id_scope: variant,
60
+ name: "categories[#{category.key}]",
61
+ value: "true",
62
+ checked: category.required?,
63
+ disabled: category.required?,
64
+ data: {
65
+ "consentful--preferences-target": "categoryMaster",
66
+ "action": "change->consentful--preferences#cascadeCategory",
67
+ "category-key": category.key
68
+ }
69
+ ) %>
70
+ <button type="button" data-action="consentful--preferences#toggleSection" data-section-target="category-<%= category.key %>" aria-expanded="false" class="c-consent-category__chevron">
71
+ <svg width="20" height="20" data-section-chevron="category-<%= category.key %>" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
72
+ <path d="M6 8l4 4 4-4" stroke-linecap="round" stroke-linejoin="round"/>
73
+ </svg>
74
+ </button>
75
+ </header>
76
+ <div data-section-body="category-<%= category.key %>" class="c-consent-category__body c-consent__hidden">
77
+ <% services.select { |service| service.consent_category_id == category.id }.each do |service| %>
78
+ <div class="c-consent-service__row">
79
+ <span class="c-consent-service__name"><%= service.name %></span>
80
+ <button type="button" class="c-consent-service__info" data-action="consentful--preferences#toggleSection" data-section-target="service-<%= service.slug %>" aria-label="Service details">
81
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
82
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm0-9.5a1 1 0 100-2 1 1 0 000 2zM9 10a1 1 0 011-1h.01a1 1 0 110 2H10v3a1 1 0 11-2 0v-4z" clip-rule="evenodd"/>
83
+ </svg>
84
+ </button>
85
+ <%= render Consentful::Toggle.new(
86
+ id_scope: "#{variant}-cat",
87
+ name: "services[#{service.slug}]",
88
+ value: "true",
89
+ checked: category.required?,
90
+ disabled: category.required?,
91
+ data: {
92
+ "consentful--preferences-target": "serviceToggle",
93
+ "action": "change->consentful--preferences#syncServiceToggle",
94
+ "category-key": category.key,
95
+ "service-slug": service.slug
96
+ }
97
+ ) %>
98
+ </div>
99
+ <div data-section-body="service-<%= service.slug %>" class="c-consent-service__detail c-consent__hidden">
100
+ <%= render Consentful::ServiceDetail.new(service: service) %>
101
+ </div>
102
+ <% end %>
103
+ </div>
104
+ </section>
105
+ <% end %>
106
+ </div>
107
+
108
+ <div data-consentful--preferences-target="tabPanel" data-tab="services" class="c-consent-preferences__panel c-consent__hidden">
109
+ <% services.each do |service| %>
110
+ <div class="c-consent-services-card">
111
+ <header class="c-consent-services-card__header">
112
+ <div class="c-consent-services-card__heading">
113
+ <p class="c-consent-services-card__name"><%= service.name %></p>
114
+ <p class="c-consent-services-card__category"><%= service.consent_category.name %></p>
115
+ </div>
116
+ <%= render Consentful::Toggle.new(
117
+ id_scope: "#{variant}-flat",
118
+ name: "services[#{service.slug}]",
119
+ value: "true",
120
+ checked: service.consent_category.required?,
121
+ disabled: service.consent_category.required?,
122
+ data: {
123
+ "consentful--preferences-target": "serviceToggle",
124
+ "action": "change->consentful--preferences#syncServiceToggle",
125
+ "category-key": service.consent_category.key,
126
+ "service-slug": service.slug
127
+ }
128
+ ) %>
129
+ <button type="button" data-action="consentful--preferences#toggleSection" data-section-target="service-flat-<%= service.slug %>" aria-expanded="false" class="c-consent-category__chevron">
130
+ <svg width="20" height="20" data-section-chevron="service-flat-<%= service.slug %>" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
131
+ <path d="M6 8l4 4 4-4" stroke-linecap="round" stroke-linejoin="round"/>
132
+ </svg>
133
+ </button>
134
+ </header>
135
+ <div data-section-body="service-flat-<%= service.slug %>" class="c-consent-service__detail c-consent__hidden">
136
+ <%= render Consentful::ServiceDetail.new(service: service) %>
137
+ </div>
138
+ </div>
139
+ <% end %>
140
+ </div>
141
+ </div>
142
+
143
+ <% if visitor_uuid.present? %>
144
+ <div class="c-consent-preferences__reference">
145
+ <% if sar_contact_email.present? %>
146
+ If you'd like to request your consent history, quote this reference and send it to
147
+ <a href="mailto:<%= sar_contact_email %>"><%= sar_contact_email %></a>:
148
+ <% else %>
149
+ If you'd like to request your consent history, quote this reference:
150
+ <% end %>
151
+ <code class="c-consent-preferences__reference-code" data-consentful--preferences-target="reference"><%= visitor_uuid %></code>
152
+ <button type="button" data-action="consentful--preferences#copyReference" class="c-consent-preferences__copy-button">Copy</button>
153
+ </div>
154
+ <% end %>
155
+
156
+ <div class="c-consent-preferences__footer">
157
+ <button type="button" data-action="consentful--preferences#save" class="c-consent-action">
158
+ <%= version.save_label.presence || "Save Settings" %>
159
+ </button>
160
+ <button type="button" data-action="consentful--preferences#reject" class="c-consent-action">
161
+ <%= version.reject_label.presence || "Deny" %>
162
+ </button>
163
+ <button type="button" data-action="consentful--preferences#accept" class="c-consent-action">
164
+ <%= version.accept_label.presence || "Accept All" %>
165
+ </button>
166
+ </div>
167
+ </div>
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class Preferences < ViewComponent::Base
5
+ VARIANTS = %i[page modal].freeze
6
+
7
+ def initialize(version:, categories:, visitor_uuid:, submit_path:, variant: :page)
8
+ super()
9
+ @version = version
10
+ @categories = categories
11
+ @visitor_uuid = visitor_uuid
12
+ @submit_path = submit_path
13
+ @variant = VARIANTS.include?(variant) ? variant : :page
14
+ end
15
+
16
+ def render?
17
+ @version.present?
18
+ end
19
+
20
+ def services
21
+ @services ||= ConsentService.active.includes(:consent_category).order(:position, :name)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :version, :categories, :visitor_uuid, :submit_path, :variant
27
+
28
+ def modal?
29
+ variant == :modal
30
+ end
31
+
32
+ def sar_contact_email
33
+ Consentful.config.sar_contact_email
34
+ end
35
+
36
+ def category_blurb(category)
37
+ raw = version.category_blurbs[category.key]
38
+ blurb = raw.is_a?(Hash) ? raw["description"] || raw[:description] : raw
39
+ blurb.presence || category.description.presence || ""
40
+ end
41
+
42
+ def categories_payload
43
+ categories.map do |category|
44
+ { key: category.key, name: category.name, required: category.required? }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,99 @@
1
+ <div>
2
+ <% if service.description.present? %>
3
+ <div class="c-consent-service__detail-section">
4
+ <p class="c-consent-service__detail-label">Description of Service</p>
5
+ <p class="c-consent-service__detail-value"><%= service.description %></p>
6
+ </div>
7
+ <% end %>
8
+
9
+ <% if service.processing_company_name.present? || service.processing_company_address.present? %>
10
+ <div class="c-consent-service__detail-section">
11
+ <p class="c-consent-service__detail-label">Processing Company</p>
12
+ <p class="c-consent-service__detail-value"><%= service.processing_company_name %></p>
13
+ <% if service.processing_company_address.present? %>
14
+ <p class="c-consent-service__detail-value" style="color: #494949;"><%= service.processing_company_address %></p>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <% if service.dpo_email.present? %>
20
+ <div class="c-consent-service__detail-section">
21
+ <p class="c-consent-service__detail-label">Data Protection Officer of Processing Company</p>
22
+ <p class="c-consent-service__detail-value"><%= mail_to service.dpo_email, class: "c-consent-service__detail-link" %></p>
23
+ </div>
24
+ <% end %>
25
+
26
+ <% if chips_for(service.data_purposes).any? %>
27
+ <div class="c-consent-service__detail-section">
28
+ <p class="c-consent-service__detail-label">Data Purposes</p>
29
+ <div class="c-consent-service__chips">
30
+ <% chips_for(service.data_purposes).each do |purpose| %>
31
+ <span class="c-consent-service__chip"><%= purpose %></span>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+
37
+ <% if chips_for(service.technologies_used).any? %>
38
+ <div class="c-consent-service__detail-section">
39
+ <p class="c-consent-service__detail-label">Technologies Used</p>
40
+ <div class="c-consent-service__chips">
41
+ <% chips_for(service.technologies_used).each do |tech| %>
42
+ <span class="c-consent-service__chip"><%= tech %></span>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+ <% end %>
47
+
48
+ <% if chips_for(service.cookie_names).any? %>
49
+ <div class="c-consent-service__detail-section">
50
+ <p class="c-consent-service__detail-label">Cookies</p>
51
+ <div class="c-consent-service__chips">
52
+ <% chips_for(service.cookie_names).each do |cookie| %>
53
+ <span class="c-consent-service__chip c-consent-service__chip--mono"><%= cookie %></span>
54
+ <% end %>
55
+ </div>
56
+ </div>
57
+ <% end %>
58
+
59
+ <% if chips_for(service.data_collected).any? %>
60
+ <div class="c-consent-service__detail-section">
61
+ <p class="c-consent-service__detail-label">Data Collected</p>
62
+ <div class="c-consent-service__chips">
63
+ <% chips_for(service.data_collected).each do |data| %>
64
+ <span class="c-consent-service__chip"><%= data %></span>
65
+ <% end %>
66
+ </div>
67
+ </div>
68
+ <% end %>
69
+
70
+ <% if service.legal_basis.present? %>
71
+ <div class="c-consent-service__detail-section">
72
+ <p class="c-consent-service__detail-label">Legal Basis</p>
73
+ <p class="c-consent-service__detail-value"><%= service.legal_basis %></p>
74
+ </div>
75
+ <% end %>
76
+
77
+ <% if service.processing_location.present? %>
78
+ <div class="c-consent-service__detail-section">
79
+ <p class="c-consent-service__detail-label">Location of Processing</p>
80
+ <ul>
81
+ <li><%= service.processing_location %></li>
82
+ </ul>
83
+ </div>
84
+ <% end %>
85
+
86
+ <% if service.retention_period.present? %>
87
+ <div class="c-consent-service__detail-section">
88
+ <p class="c-consent-service__detail-label">Retention Period</p>
89
+ <p class="c-consent-service__detail-value"><%= service.retention_period %></p>
90
+ </div>
91
+ <% end %>
92
+
93
+ <% if service.third_country_transfer.present? %>
94
+ <div class="c-consent-service__detail-section">
95
+ <p class="c-consent-service__detail-label">Transfer to Third Countries</p>
96
+ <p class="c-consent-service__detail-value"><%= service.third_country_transfer %></p>
97
+ </div>
98
+ <% end %>
99
+ </div>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ServiceDetail < ViewComponent::Base
5
+ def initialize(service:)
6
+ super()
7
+ @service = service
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :service
13
+
14
+ def chips_for(values)
15
+ Array(values).compact_blank
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ <label for="<%= id %>" class="c-consent-toggle <%= 'c-consent-toggle--disabled' if disabled %>">
2
+ <% if label && label_before? %>
3
+ <span class="c-consent-toggle__label"><%= label %></span>
4
+ <% end %>
5
+
6
+ <span class="c-consent-toggle__pill-wrap">
7
+ <input
8
+ type="checkbox"
9
+ id="<%= id %>"
10
+ name="<%= name %>"
11
+ value="<%= value %>"
12
+ <%= "checked" if checked %>
13
+ <%= "disabled" if disabled %>
14
+ class="c-consent-toggle__input"
15
+ <% data.each do |key, val| %>data-<%= key.to_s.dasherize %>="<%= val %>" <% end %>
16
+ />
17
+ <span class="c-consent-toggle__pill"></span>
18
+ <span class="c-consent-toggle__knob"></span>
19
+ <svg class="c-consent-toggle__icon c-consent-toggle__icon--off" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
20
+ <path d="M5 5l10 10M15 5L5 15" stroke-linecap="round"/>
21
+ </svg>
22
+ <svg class="c-consent-toggle__icon c-consent-toggle__icon--on" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
23
+ <path d="M4 10l4 4 8-8" stroke-linecap="round" stroke-linejoin="round"/>
24
+ </svg>
25
+ </span>
26
+
27
+ <% if label && !label_before? %>
28
+ <span class="c-consent-toggle__label"><%= label %></span>
29
+ <% end %>
30
+ </label>
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class Toggle < ViewComponent::Base
5
+ def initialize(name:, id: nil, id_scope: nil, value: "1", checked: false, disabled: false, label: nil, label_position: :right, data: {}) # rubocop:disable Metrics/ParameterLists
6
+ super()
7
+ @name = name
8
+ @id = id || dom_id_for(name, value, id_scope)
9
+ @value = value
10
+ @checked = checked
11
+ @disabled = disabled
12
+ @label = label
13
+ @label_position = label_position
14
+ @data = data
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :name, :id, :value, :checked, :disabled, :label, :label_position, :data
20
+
21
+ def dom_id_for(name, value, scope)
22
+ parts = [ scope, name, value ].compact.map { |part| part.to_s.parameterize(separator: "_") }
23
+ "toggle-#{parts.join('-')}"
24
+ end
25
+
26
+ def label_before?
27
+ label_position.to_sym == :left
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ # Mix into the host's ApplicationController to give every request an anonymous
5
+ # visitor UUID cookie and the consent-linking helper:
6
+ #
7
+ # class ApplicationController < ActionController::Base
8
+ # include Consentful::VisitorCookie
9
+ # before_action :ensure_visitor_id
10
+ # end
11
+ #
12
+ # The engine's own controllers include it too.
13
+ module VisitorCookie
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ helper_method :current_consent, :consent_visitor_id
18
+ end
19
+
20
+ private
21
+
22
+ def ensure_visitor_id
23
+ return if consent_visitor_id.present?
24
+
25
+ cookies[Consentful.config.visitor_cookie_name] = consent_cookie_options.merge(
26
+ value: SecureRandom.uuid,
27
+ expires: Consentful.config.cookie_expiry_months.months.from_now
28
+ )
29
+ end
30
+
31
+ def consent_visitor_id
32
+ cookies[Consentful.config.visitor_cookie_name]
33
+ end
34
+
35
+ def current_consent
36
+ @current_consent ||= Consentful::ConsentState.from_cookie(cookies[Consentful.config.cookie_name])
37
+ end
38
+
39
+ def consent_cookie_options
40
+ Consentful.config.cookie_options(request)
41
+ end
42
+
43
+ # Bridge the anonymous visitor cookie to a known identifier (e.g. an email a
44
+ # user submitted) so a later SAR can resolve every consent record for them.
45
+ # Safe to call repeatedly — the unique index de-dupes. No-ops on blanks.
46
+ def link_consent_identifier!(type:, value:)
47
+ Consentful.link!(consent_visitor_id, type:, value:)
48
+ rescue StandardError => e
49
+ Consentful.config.error_handler(e)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ # Base for the self-contained admin panel. Auth is delegated to the host via
6
+ # Consentful.config.authorize (defaults to allow-all — hosts MUST set it).
7
+ class BaseController < ActionController::Base
8
+ protect_from_forgery with: :exception
9
+ layout "consentful/admin"
10
+
11
+ before_action :authorize_consentful!
12
+
13
+ helper_method :consentful_identity
14
+
15
+ private
16
+
17
+ def authorize_consentful!
18
+ head :forbidden unless Consentful.config.authorize(self)
19
+ end
20
+
21
+ # Who is acting, per the host. Stamped onto published copy versions.
22
+ def consentful_identity
23
+ Consentful.config.identify(self)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ class CategoriesController < BaseController
6
+ before_action :set_category, only: [ :edit, :update ]
7
+
8
+ def index
9
+ @categories = ConsentCategory.all
10
+ end
11
+
12
+ def edit; end
13
+
14
+ def update
15
+ if @category.update(category_params)
16
+ redirect_to admin_categories_path, notice: "Category updated."
17
+ else
18
+ render :edit, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def reorder
23
+ reorder_entries.each { |entry| ConsentCategory.update(entry[:id], position: entry[:position]) }
24
+ head :no_content
25
+ end
26
+
27
+ private
28
+
29
+ def set_category
30
+ @category = ConsentCategory.find(params[:id])
31
+ end
32
+
33
+ def category_params
34
+ attrs = params.require(:consent_category).permit(:name, :description, :gtag_keys)
35
+ attrs[:gtag_keys] = attrs[:gtag_keys].to_s.split(",").map(&:strip).compact_blank if attrs.key?(:gtag_keys)
36
+ attrs
37
+ end
38
+
39
+ def reorder_entries
40
+ raw = params.require(:consent_categories)
41
+ list = raw.respond_to?(:values) ? raw.values : raw
42
+ list.map { |entry| entry.permit(:id, :position) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ class CopyVersionsController < BaseController
6
+ before_action :set_version, only: [ :show, :edit, :update, :destroy, :publish, :archive, :restore ]
7
+ before_action :require_draft, only: [ :edit, :update, :destroy ]
8
+
9
+ def index
10
+ @versions = ConsentCopyVersion.order(created_at: :desc)
11
+ end
12
+
13
+ def show; end
14
+
15
+ def new
16
+ @version = ConsentCopyVersion.new(category_blurbs: blank_category_blurbs)
17
+ end
18
+
19
+ def edit; end
20
+
21
+ def create
22
+ @version = ConsentCopyVersion.new(version_params)
23
+ if @version.save
24
+ redirect_to edit_admin_copy_version_path(@version), notice: "Draft created."
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def update
31
+ if @version.update(version_params)
32
+ redirect_to edit_admin_copy_version_path(@version), notice: "Draft updated."
33
+ else
34
+ render :edit, status: :unprocessable_entity
35
+ end
36
+ end
37
+
38
+ def destroy
39
+ @version.destroy
40
+ redirect_to admin_copy_versions_path, notice: "Draft deleted."
41
+ end
42
+
43
+ def publish
44
+ unless @version.draft?
45
+ redirect_to admin_copy_versions_path, alert: "Only draft versions can be published."
46
+ return
47
+ end
48
+
49
+ @version.publish!(consentful_identity)
50
+ redirect_to admin_copy_versions_path, notice: "Published. Any previously published version has been archived."
51
+ end
52
+
53
+ def archive
54
+ unless @version.published?
55
+ redirect_to admin_copy_versions_path, alert: "Only published versions can be archived."
56
+ return
57
+ end
58
+
59
+ @version.archive!
60
+ redirect_to admin_copy_versions_path, notice: "Archived."
61
+ end
62
+
63
+ def restore
64
+ if @version.draft?
65
+ redirect_to admin_copy_versions_path, alert: "Draft versions are already editable."
66
+ return
67
+ end
68
+
69
+ draft = ConsentCopyVersion.create!(
70
+ banner_title: @version.banner_title,
71
+ banner_body: @version.banner_body,
72
+ modal_intro: @version.modal_intro,
73
+ accept_label: @version.accept_label,
74
+ reject_label: @version.reject_label,
75
+ manage_label: @version.manage_label,
76
+ save_label: @version.save_label,
77
+ category_blurbs: @version.category_blurbs.deep_dup,
78
+ status: :draft
79
+ )
80
+
81
+ redirect_to edit_admin_copy_version_path(draft),
82
+ notice: "Cloned to a new draft. Edit and publish when you're ready."
83
+ end
84
+
85
+ private
86
+
87
+ def set_version
88
+ @version = ConsentCopyVersion.find(params[:id])
89
+ end
90
+
91
+ def require_draft
92
+ return if @version.draft?
93
+
94
+ redirect_to admin_copy_versions_path,
95
+ alert: "Published and archived versions are read-only. Create a new draft to make changes."
96
+ end
97
+
98
+ def version_params
99
+ params.require(:consent_copy_version).permit(
100
+ :banner_title, :banner_body, :modal_intro,
101
+ :accept_label, :reject_label, :manage_label, :save_label,
102
+ category_blurbs: {}
103
+ )
104
+ end
105
+
106
+ def blank_category_blurbs
107
+ ConsentCategory.order(:position).each_with_object({}) do |category, blurbs|
108
+ blurbs[category.key] = ""
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ class DashboardController < BaseController
6
+ def show
7
+ @report = DashboardReport.new(days: params[:days])
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ class RecordsController < BaseController
6
+ def index
7
+ @lookup = SarLookup.new(email: params[:email], visitor_uuid: params[:visitor_uuid])
8
+ end
9
+ end
10
+ end
11
+ end