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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/CONTRIBUTING.md +35 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/app/assets/config/rails_consent_manifest.js +2 -0
- data/app/assets/javascripts/rails_consent.js +379 -0
- data/app/assets/stylesheets/rails_consent/application.css +21 -0
- data/app/controllers/concerns/rails_consent/controller_helpers.rb +86 -0
- data/app/controllers/rails_consent/preferences_controller.rb +55 -0
- data/app/helpers/rails_consent/application_helper.rb +44 -0
- data/app/views/rails_consent/_banner.html.erb +45 -0
- data/app/views/rails_consent/_category_toggle.html.erb +32 -0
- data/app/views/rails_consent/_cookie_table.html.erb +19 -0
- data/app/views/rails_consent/_preferences_modal.html.erb +53 -0
- data/config/locales/en.yml +53 -0
- data/lib/generators/rails_consent/install/install_generator.rb +19 -0
- data/lib/generators/rails_consent/install/templates/initializer.rb +109 -0
- data/lib/generators/rails_consent/install/templates/rails_consent.en.yml +53 -0
- data/lib/rails_consent/category.rb +36 -0
- data/lib/rails_consent/category_loader.rb +60 -0
- data/lib/rails_consent/configuration.rb +273 -0
- data/lib/rails_consent/cookie_store.rb +56 -0
- data/lib/rails_consent/default_consent_resolver.rb +13 -0
- data/lib/rails_consent/engine.rb +25 -0
- data/lib/rails_consent/preference_set.rb +49 -0
- data/lib/rails_consent/version.rb +3 -0
- data/lib/rails_consent.rb +31 -0
- metadata +94 -0
|
@@ -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>
|