rails_consent 0.1.0

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.
@@ -0,0 +1,379 @@
1
+ (function(global) {
2
+ const SESSION_KEY = "rails_consent_session_id";
3
+ const BOUNDARY_SELECTOR = "[data-rails-consent]";
4
+ const FOCUSABLE_SELECTOR = [
5
+ "a[href]",
6
+ "area[href]",
7
+ "button:not([disabled])",
8
+ "input:not([disabled]):not([type='hidden'])",
9
+ "select:not([disabled])",
10
+ "textarea:not([disabled])",
11
+ "[tabindex]:not([tabindex='-1'])"
12
+ ].join(", ");
13
+
14
+ function now() {
15
+ return Math.floor(Date.now() / 1000);
16
+ }
17
+
18
+ let fallbackSessionId = null;
19
+
20
+ function sessionStorageRef() {
21
+ try {
22
+ return global.sessionStorage || null;
23
+ } catch (_error) {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function generateSessionId() {
29
+ return typeof global.crypto !== "undefined" && global.crypto.randomUUID ?
30
+ global.crypto.randomUUID() :
31
+ "rc-" + Math.random().toString(36).slice(2, 12);
32
+ }
33
+
34
+ function ensureSessionId() {
35
+ const storage = sessionStorageRef();
36
+ if (storage) {
37
+ const existing = storage.getItem(SESSION_KEY);
38
+ if (existing) return existing;
39
+
40
+ const generated = generateSessionId();
41
+ storage.setItem(SESSION_KEY, generated);
42
+ return generated;
43
+ }
44
+
45
+ if (fallbackSessionId) return fallbackSessionId;
46
+
47
+ fallbackSessionId = generateSessionId();
48
+ return fallbackSessionId;
49
+ }
50
+
51
+ function eventDetail(sessionId, preferences) {
52
+ const detailPreferences = Object.assign({}, preferences);
53
+ const timestamp = detailPreferences.timestamp || now();
54
+ detailPreferences.timestamp = timestamp;
55
+
56
+ return {
57
+ session_id: sessionId,
58
+ preferences: detailPreferences,
59
+ timestamp: timestamp
60
+ };
61
+ }
62
+
63
+ function dispatch(element, name, detail) {
64
+ element.dispatchEvent(new global.CustomEvent(name, {
65
+ bubbles: true,
66
+ detail: detail
67
+ }));
68
+ }
69
+
70
+ function preferenceKey(toggle) {
71
+ return toggle.name.replace(/^preferences\[|\]$/g, "");
72
+ }
73
+
74
+ function csrfToken() {
75
+ const meta = global.document && global.document.querySelector("meta[name='csrf-token']");
76
+ return meta ? meta.getAttribute("content") : "";
77
+ }
78
+
79
+ function normalizedPreferences(container, valueOverride) {
80
+ const preferences = {};
81
+
82
+ container.querySelectorAll("[data-rails-consent-category-toggle]").forEach(function(toggle) {
83
+ preferences[preferenceKey(toggle)] = toggle.disabled ? true : (valueOverride === undefined ? toggle.checked : valueOverride);
84
+ });
85
+
86
+ return preferences;
87
+ }
88
+
89
+ function resolveContainer(container) {
90
+ if (container) return container;
91
+ if (!global.document || typeof global.document.querySelector !== "function") return null;
92
+
93
+ return global.document.querySelector(BOUNDARY_SELECTOR);
94
+ }
95
+
96
+ function applyPreferences(container, preferences) {
97
+ container.dataset.railsConsentRecorded = "true";
98
+
99
+ container.querySelectorAll("[data-rails-consent-category-toggle]").forEach(function(toggle) {
100
+ toggle.checked = !!preferences[preferenceKey(toggle)];
101
+ });
102
+
103
+ hideBanner(container);
104
+ }
105
+
106
+ async function persistPreferences(container, preferences) {
107
+ const url = container.dataset.railsConsentPersistUrl;
108
+ if (!url || typeof global.fetch !== "function") return preferences;
109
+
110
+ const response = await global.fetch(url, {
111
+ method: "POST",
112
+ credentials: "same-origin",
113
+ headers: {
114
+ "Accept": "application/json",
115
+ "Content-Type": "application/json",
116
+ "X-CSRF-Token": csrfToken()
117
+ },
118
+ body: JSON.stringify({ preferences: preferences })
119
+ });
120
+
121
+ if (!response.ok) {
122
+ throw new Error("Rails Consent persistence failed with status " + response.status);
123
+ }
124
+
125
+ if (typeof response.json !== "function") return preferences;
126
+
127
+ const payload = await response.json();
128
+ return payload && payload.preferences ? payload.preferences : preferences;
129
+ }
130
+
131
+ function isDismissible(container) {
132
+ return container.dataset.railsConsentDismissible !== "false";
133
+ }
134
+
135
+ function hideBanner(container) {
136
+ const banner = container.querySelector("[data-rails-consent-banner]");
137
+ if (banner) banner.hidden = true;
138
+ }
139
+
140
+ function showModal(container) {
141
+ const modal = container.querySelector("[data-rails-consent-modal]");
142
+ if (!modal) return;
143
+
144
+ const focusable = modal.querySelectorAll(FOCUSABLE_SELECTOR);
145
+ modal.hidden = false;
146
+ modal.setAttribute("aria-hidden", "false");
147
+ modal.__railsConsentPreviouslyFocused = global.document.activeElement;
148
+
149
+ if (focusable.length > 0) focusable[0].focus();
150
+ }
151
+
152
+ function closeModal(container, options) {
153
+ const modal = container.querySelector("[data-rails-consent-modal]");
154
+ if (!modal) return;
155
+ if (!(options && options.force) && !isDismissible(container)) return;
156
+
157
+ modal.hidden = true;
158
+ modal.setAttribute("aria-hidden", "true");
159
+
160
+ if (modal.__railsConsentPreviouslyFocused && typeof modal.__railsConsentPreviouslyFocused.focus === "function") {
161
+ modal.__railsConsentPreviouslyFocused.focus();
162
+ }
163
+ }
164
+
165
+ function trapFocus(container, event) {
166
+ const modal = container.querySelector("[data-rails-consent-modal]");
167
+ if (!modal || modal.hidden) return;
168
+
169
+ if (event.key === "Escape") {
170
+ closeModal(container);
171
+ return;
172
+ }
173
+
174
+ if (event.key !== "Tab") return;
175
+
176
+ const focusable = Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR));
177
+ if (focusable.length === 0) return;
178
+
179
+ const first = focusable[0];
180
+ const last = focusable[focusable.length - 1];
181
+
182
+ if (event.shiftKey && global.document.activeElement === first) {
183
+ event.preventDefault();
184
+ last.focus();
185
+ } else if (!event.shiftKey && global.document.activeElement === last) {
186
+ event.preventDefault();
187
+ first.focus();
188
+ }
189
+ }
190
+
191
+ async function recordPreferences(container, sessionId, preferences, eventName) {
192
+ const lifecycleEventName = container.dataset.railsConsentRecorded === "true" ?
193
+ "rails-consent:preferences-updated" :
194
+ "rails-consent:preferences-saved";
195
+ const requestedPreferences = Object.assign({}, preferences, { timestamp: now() });
196
+ let recordedPreferences = requestedPreferences;
197
+
198
+ try {
199
+ const persistedPreferences = await persistPreferences(container, requestedPreferences);
200
+ recordedPreferences = Object.assign({}, requestedPreferences, persistedPreferences);
201
+ } catch (error) {
202
+ if (global.console && typeof global.console.error === "function") {
203
+ global.console.error("[Rails Consent] Failed to persist preferences", error);
204
+ }
205
+ return null;
206
+ }
207
+
208
+ applyPreferences(container, recordedPreferences);
209
+ closeModal(container, { force: true });
210
+
211
+ const detail = eventDetail(sessionId, recordedPreferences);
212
+ dispatch(container, lifecycleEventName, detail);
213
+
214
+ if (eventName !== lifecycleEventName) {
215
+ dispatch(container, eventName, detail);
216
+ }
217
+
218
+ return recordedPreferences;
219
+ }
220
+
221
+ function setupContainer(container) {
222
+ if (container.dataset.railsConsentInitialized === "true") return;
223
+
224
+ container.dataset.railsConsentInitialized = "true";
225
+ const sessionId = ensureSessionId();
226
+ const modal = container.querySelector("[data-rails-consent-modal]");
227
+ const banner = container.querySelector("[data-rails-consent-banner]");
228
+
229
+ if (banner && !banner.hidden) {
230
+ dispatch(container, "rails-consent:banner-shown", eventDetail(sessionId, normalizedPreferences(container)));
231
+ }
232
+
233
+ container.addEventListener("keydown", function(event) {
234
+ trapFocus(container, event);
235
+ });
236
+
237
+ container.querySelectorAll("[data-rails-consent-open-preferences]").forEach(function(trigger) {
238
+ trigger.addEventListener("click", function() {
239
+ showModal(container);
240
+ dispatch(container, "rails-consent:preferences-opened", eventDetail(sessionId, normalizedPreferences(container)));
241
+ });
242
+ });
243
+
244
+ container.querySelectorAll("[data-rails-consent-close-preferences]").forEach(function(trigger) {
245
+ trigger.addEventListener("click", function() {
246
+ closeModal(container);
247
+ });
248
+ });
249
+
250
+ container.querySelectorAll("[data-rails-consent-dismiss]").forEach(function(trigger) {
251
+ trigger.addEventListener("click", function() {
252
+ if (!isDismissible(container)) return;
253
+ hideBanner(container);
254
+ dispatch(container, "rails-consent:dismissed", eventDetail(sessionId, normalizedPreferences(container)));
255
+ });
256
+ });
257
+
258
+ container.querySelectorAll("[data-rails-consent-accept-all]").forEach(function(trigger) {
259
+ trigger.addEventListener("click", function() {
260
+ void recordPreferences(container, sessionId, normalizedPreferences(container, true), "rails-consent:accepted-all");
261
+ });
262
+ });
263
+
264
+ container.querySelectorAll("[data-rails-consent-reject-optional]").forEach(function(trigger) {
265
+ trigger.addEventListener("click", function() {
266
+ void recordPreferences(container, sessionId, normalizedPreferences(container, false), "rails-consent:rejected-optional");
267
+ });
268
+ });
269
+
270
+ const saveButton = container.querySelector("[data-rails-consent-save-preferences]");
271
+ if (saveButton) {
272
+ saveButton.addEventListener("click", function() {
273
+ void recordPreferences(container, sessionId, normalizedPreferences(container), "rails-consent:preferences-saved");
274
+ });
275
+ }
276
+
277
+ if (modal) {
278
+ modal.addEventListener("click", function(event) {
279
+ if (event.target === modal) closeModal(container);
280
+ });
281
+ }
282
+ }
283
+
284
+ function bindGlobalPreferenceTriggers(documentRef) {
285
+ if (documentRef.documentElement.dataset.railsConsentGlobalBound === "true") return;
286
+
287
+ documentRef.documentElement.dataset.railsConsentGlobalBound = "true";
288
+
289
+ documentRef.addEventListener("click", function(event) {
290
+ const trigger = event.target.closest("[data-rails-consent-open-preferences-global]");
291
+ if (!trigger) return;
292
+
293
+ const container = documentRef.querySelector(BOUNDARY_SELECTOR);
294
+ if (!container) return;
295
+
296
+ event.preventDefault();
297
+ showModal(container);
298
+ dispatch(container, "rails-consent:preferences-opened", eventDetail(
299
+ ensureSessionId(),
300
+ normalizedPreferences(container)
301
+ ));
302
+ });
303
+ }
304
+
305
+ function init(documentRef) {
306
+ const doc = documentRef || global.document;
307
+ if (!doc) return;
308
+
309
+ bindGlobalPreferenceTriggers(doc);
310
+ doc.querySelectorAll(BOUNDARY_SELECTOR).forEach(setupContainer);
311
+ }
312
+
313
+ function openPreferences(container) {
314
+ const target = resolveContainer(container);
315
+ if (!target) return;
316
+
317
+ showModal(target);
318
+ dispatch(target, "rails-consent:preferences-opened", eventDetail(
319
+ ensureSessionId(),
320
+ normalizedPreferences(target)
321
+ ));
322
+ }
323
+
324
+ function closePreferences(container, options) {
325
+ const target = resolveContainer(container);
326
+ if (!target) return;
327
+
328
+ closeModal(target, options);
329
+ }
330
+
331
+ function preferences(container) {
332
+ const target = resolveContainer(container);
333
+ if (!target) return {};
334
+
335
+ return normalizedPreferences(target);
336
+ }
337
+
338
+ function consentGiven(categoryName, container) {
339
+ const target = resolveContainer(container);
340
+ if (!target) return false;
341
+
342
+ const categoryKey = String(categoryName);
343
+ return preferences(target)[categoryKey] === true;
344
+ }
345
+
346
+ function consentRecorded(container) {
347
+ const target = resolveContainer(container);
348
+ if (!target) return false;
349
+
350
+ return target.dataset.railsConsentRecorded === "true";
351
+ }
352
+
353
+ function sessionId() {
354
+ return ensureSessionId();
355
+ }
356
+
357
+ const Runtime = {
358
+ init: init,
359
+ openPreferences: openPreferences,
360
+ closePreferences: closePreferences,
361
+ preferences: preferences,
362
+ consentGiven: consentGiven,
363
+ consentRecorded: consentRecorded,
364
+ sessionId: sessionId,
365
+ ensureSessionId: ensureSessionId,
366
+ eventDetail: eventDetail
367
+ };
368
+
369
+ if (typeof module !== "undefined" && module.exports) {
370
+ module.exports = Runtime;
371
+ }
372
+
373
+ global.RailsConsent = Runtime;
374
+
375
+ if (typeof document !== "undefined") {
376
+ document.addEventListener("DOMContentLoaded", function() { init(document); });
377
+ document.addEventListener("turbo:load", function() { init(document); });
378
+ }
379
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,21 @@
1
+ /*
2
+ * rails_consent intentionally keeps styling minimal so host applications can
3
+ * own presentation. These classes exist primarily as semantic hooks.
4
+ */
5
+
6
+ .rails-consent-visually-hidden {
7
+ position: absolute;
8
+ width: 1px;
9
+ height: 1px;
10
+ padding: 0;
11
+ margin: -1px;
12
+ overflow: hidden;
13
+ clip: rect(0, 0, 0, 0);
14
+ white-space: nowrap;
15
+ border: 0;
16
+ }
17
+
18
+ .rails-consent-banner[hidden],
19
+ .rails-consent-modal[hidden] {
20
+ display: none !important;
21
+ }
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsent
4
+ module ControllerHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :consent_given?, :consent_preferences, :consent_recorded?, :reset_consent!
9
+ end
10
+
11
+ def consent_given?(category_name)
12
+ category = rails_consent_categories.find { |item| item.key == category_name.to_sym }
13
+ return false unless category
14
+
15
+ rails_consent_configuration.consent_resolver.call(
16
+ category: category,
17
+ preferences: consent_preferences,
18
+ categories: rails_consent_categories,
19
+ context: self,
20
+ storage: rails_consent_configuration.storage
21
+ )
22
+ end
23
+
24
+ def consent_preferences
25
+ @rails_consent_preferences ||= RailsConsent::PreferenceSet.new(
26
+ categories: rails_consent_categories,
27
+ source: rails_consent_raw_preferences
28
+ ).to_h
29
+ end
30
+
31
+ def consent_recorded?
32
+ return @rails_consent_recorded unless @rails_consent_recorded.nil?
33
+
34
+ @rails_consent_recorded = ActiveModel::Type::Boolean.new.cast(
35
+ rails_consent_configuration.consent_recorded_provider.call(
36
+ context: self,
37
+ categories: rails_consent_categories,
38
+ cookies: send(:cookies),
39
+ preferences: consent_preferences,
40
+ request: request,
41
+ raw_preferences: rails_consent_raw_preferences,
42
+ storage: rails_consent_configuration.storage
43
+ )
44
+ )
45
+ end
46
+
47
+ def reset_consent!
48
+ rails_consent_configuration.preferences_destroyer.call(
49
+ context: self,
50
+ categories: rails_consent_categories,
51
+ cookies: send(:cookies),
52
+ request: request,
53
+ storage: rails_consent_configuration.storage
54
+ )
55
+
56
+ @rails_consent_preferences = nil
57
+ @rails_consent_raw_preferences = nil
58
+ @rails_consent_recorded = nil
59
+ true
60
+ end
61
+
62
+ private
63
+
64
+ def rails_consent_categories
65
+ @rails_consent_categories ||= RailsConsent::CategoryLoader.new(locale: I18n.locale).categories
66
+ end
67
+
68
+ def rails_consent_configuration
69
+ @rails_consent_configuration ||= RailsConsent.configuration.tap(&:validate!)
70
+ end
71
+
72
+ def rails_consent_raw_preferences
73
+ @rails_consent_raw_preferences ||= begin
74
+ value = rails_consent_configuration.preferences_provider.call(
75
+ context: self,
76
+ categories: rails_consent_categories,
77
+ cookies: send(:cookies),
78
+ request: request,
79
+ storage: rails_consent_configuration.storage
80
+ )
81
+
82
+ value.nil? ? {} : value
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsent
4
+ class PreferencesController < ::ApplicationController
5
+ def create
6
+ payload = configuration.preferences_writer.call(
7
+ context: self,
8
+ categories: categories,
9
+ cookies: cookies,
10
+ preferences: preferences_params.to_h,
11
+ request: request,
12
+ storage: configuration.storage
13
+ )
14
+
15
+ render json: {
16
+ preferences: normalized_preferences(payload || preferences_params.to_h)
17
+ }
18
+ end
19
+
20
+ def destroy
21
+ configuration.preferences_destroyer.call(
22
+ context: self,
23
+ categories: categories,
24
+ cookies: cookies,
25
+ request: request,
26
+ storage: configuration.storage
27
+ )
28
+
29
+ head :no_content
30
+ end
31
+
32
+ private
33
+
34
+ def categories
35
+ @categories ||= RailsConsent::CategoryLoader.new(locale: I18n.locale).categories
36
+ end
37
+
38
+ def configuration
39
+ @configuration ||= RailsConsent.configuration.tap(&:validate!)
40
+ end
41
+
42
+ def normalized_preferences(payload)
43
+ RailsConsent::PreferenceSet.new(
44
+ categories: categories,
45
+ source: payload
46
+ ).to_h
47
+ end
48
+
49
+ def preferences_params
50
+ params.fetch(:preferences, ActionController::Parameters.new).permit(
51
+ *categories.map { |category| category.key.to_s }
52
+ )
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,44 @@
1
+ module RailsConsent
2
+ module ApplicationHelper
3
+ def rails_consent_assets
4
+ safe_join(
5
+ [
6
+ stylesheet_link_tag("rails_consent/application", media: "all", "data-turbo-track": "reload"),
7
+ javascript_include_tag("rails_consent", "data-turbo-track": "reload", defer: true, nonce: true)
8
+ ],
9
+ "\n"
10
+ )
11
+ end
12
+
13
+ def rails_consent_banner(position: nil, dismissible: nil)
14
+ configuration = controller.send(:rails_consent_configuration)
15
+
16
+ render partial: "rails_consent/banner", locals: {
17
+ categories: controller.send(:rails_consent_categories),
18
+ preferences: consent_preferences,
19
+ position: (position || configuration.banner_position).to_sym,
20
+ dismissible: dismissible.nil? ? configuration.prompt_dismissible : dismissible,
21
+ consent_recorded: consent_recorded?,
22
+ persist_url: rails_consent_preferences_path
23
+ }
24
+ end
25
+
26
+ def rails_consent_preferences_button(label = nil, **options, &block)
27
+ if label.is_a?(Hash)
28
+ options = label
29
+ label = nil
30
+ end
31
+
32
+ html_options = {
33
+ type: "button",
34
+ data: { rails_consent_open_preferences_global: true }
35
+ }.deep_merge(options)
36
+
37
+ if block_given?
38
+ button_tag(**html_options, &block)
39
+ else
40
+ button_tag(label || t("rails_consent.banner.manage_preferences"), **html_options)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ <section
2
+ class="rails-consent rails-consent--<%= position %>"
3
+ data-rails-consent
4
+ data-rails-consent-dismissible="<%= dismissible %>"
5
+ data-rails-consent-persist-url="<%= persist_url %>"
6
+ data-rails-consent-recorded="<%= consent_recorded %>">
7
+ <div
8
+ class="rails-consent-banner"
9
+ data-rails-consent-banner
10
+ <%= %(hidden="hidden") if consent_recorded %>>
11
+ <div class="rails-consent-banner__content">
12
+ <p class="rails-consent-banner__eyebrow"><%= t("rails_consent.banner.eyebrow") %></p>
13
+ <h2 class="rails-consent-banner__title"><%= t("rails_consent.banner.title") %></h2>
14
+ <p class="rails-consent-banner__text">
15
+ <%= t("rails_consent.banner.description") %>
16
+ </p>
17
+ </div>
18
+
19
+ <div class="rails-consent-banner__actions">
20
+ <button type="button" class="rails-consent-banner__button" data-rails-consent-accept-all>
21
+ <%= t("rails_consent.banner.accept_all") %>
22
+ </button>
23
+ <button type="button" class="rails-consent-banner__button" data-rails-consent-reject-optional>
24
+ <%= t("rails_consent.banner.reject_optional") %>
25
+ </button>
26
+ <button type="button" class="rails-consent-banner__button" data-rails-consent-open-preferences>
27
+ <%= t("rails_consent.banner.manage_preferences") %>
28
+ </button>
29
+ <% if dismissible %>
30
+ <button
31
+ type="button"
32
+ class="rails-consent-banner__button"
33
+ data-rails-consent-dismiss>
34
+ <%= t("rails_consent.banner.dismiss") %>
35
+ </button>
36
+ <% end %>
37
+ </div>
38
+ </div>
39
+
40
+ <%= render partial: "rails_consent/preferences_modal", locals: {
41
+ categories: categories,
42
+ preferences: preferences,
43
+ dismissible: dismissible
44
+ } %>
45
+ </section>
@@ -0,0 +1,32 @@
1
+ <% key = category.key.to_s %>
2
+ <% input_id = "rails-consent-category-#{index}-#{key}" %>
3
+ <% description_id = "#{input_id}-description" %>
4
+
5
+ <article class="rails-consent-category">
6
+ <div class="rails-consent-category__header">
7
+ <div>
8
+ <h3 class="rails-consent-category__title"><%= category.label %></h3>
9
+ <p id="<%= description_id %>" class="rails-consent-category__description"><%= category.description %></p>
10
+ </div>
11
+
12
+ <div class="rails-consent-category__toggle">
13
+ <input
14
+ id="<%= input_id %>"
15
+ type="checkbox"
16
+ name="preferences[<%= key %>]"
17
+ value="true"
18
+ class="rails-consent-category__checkbox"
19
+ aria-describedby="<%= description_id %>"
20
+ data-rails-consent-category-toggle
21
+ <%= %(checked="checked") if preferences[key] %>
22
+ <%= %(disabled="disabled") if category.required? %>>
23
+ <label for="<%= input_id %>" class="rails-consent-category__label">
24
+ <%= category.required? ? t("rails_consent.category.always_enabled") : t("rails_consent.category.allow_category", category: category.label.downcase) %>
25
+ </label>
26
+ </div>
27
+ </div>
28
+
29
+ <% if category.cookies.any? %>
30
+ <%= render partial: "rails_consent/cookie_table", locals: { category: category } %>
31
+ <% end %>
32
+ </article>
@@ -0,0 +1,19 @@
1
+ <table class="rails-consent-cookie-table">
2
+ <caption class="rails-consent-visually-hidden"><%= t("rails_consent.category.cookies_caption", category: category.label) %></caption>
3
+ <thead>
4
+ <tr>
5
+ <th scope="col"><%= t("rails_consent.category.table.cookie") %></th>
6
+ <th scope="col"><%= t("rails_consent.category.table.provider") %></th>
7
+ <th scope="col"><%= t("rails_consent.category.table.purpose") %></th>
8
+ </tr>
9
+ </thead>
10
+ <tbody>
11
+ <% category.cookies.each do |cookie| %>
12
+ <tr>
13
+ <th scope="row"><%= cookie.name %></th>
14
+ <td><%= cookie.provider || t("rails_consent.category.not_specified") %></td>
15
+ <td><%= cookie.purpose || t("rails_consent.category.not_specified") %></td>
16
+ </tr>
17
+ <% end %>
18
+ </tbody>
19
+ </table>