biscuit-rails 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d2cbbeb961e7c378e000b1e9406a080b4abb744427c0cf54e06f41520f3db858
4
+ data.tar.gz: ba4ca1917c1a017958c53b9682721ac506a49924253e9f72db6dc88aca90377f
5
+ SHA512:
6
+ metadata.gz: 610e88154f02e24a8a9e32f075bc1ee406c1cbba9d161dc4bf84cd3266711c61337da365aed1865520b87390aae30f70746ec17eea7149894aa26171212f866f
7
+ data.tar.gz: 7dc949957ecc062fd0266780414039c0b146f36073d6e3fb7c1b7b7bb2de4451b3129cf548735b1b08e9b827d6ee716c51e14364229bf9fb66ee5852ca38b893
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.1] - 2026-03-19
9
+
10
+ ### Added
11
+
12
+ - `reload_on_consent` option for `biscuit_banner` — when `true`, triggers a
13
+ `Turbo.visit` page reload after consent is saved so conditionally-loaded
14
+ scripts are evaluated with the updated cookie
15
+
16
+ ---
17
+
18
+ ## [0.1.0] - 2026-03-19
19
+
20
+ ### Added
21
+
22
+ - GDPR-compliant cookie consent banner for Rails 8+
23
+ - Configurable banner position (top or bottom)
24
+ - Cookie categories with per-category consent tracking (necessary, analytics, marketing, preferences)
25
+ - Stimulus controller for accept all, reject all, manage preferences, and reopen flows
26
+ - Preferences panel with per-category checkboxes, pre-populated from existing consent state
27
+ - Persistent "Cookie settings" reopener link shown after consent is given
28
+ - Consent stored as a versioned JSON cookie (`biscuit_consent`)
29
+ - `biscuit_banner` view helper for rendering the banner in any layout
30
+ - `biscuit_allowed?(:category)` helper for conditional script/content loading in views
31
+ - `Biscuit::Consent.new(cookies).allowed?(:category)` for controller-level consent checks
32
+ - Full configuration API via `Biscuit.configure` initializer
33
+ - i18n support with English and French translations included
34
+ - CSS custom property theming — all visual tokens overridable without touching gem CSS
35
+ - Rails engine with isolated namespace, auto-mounted routes at `/biscuit/consent`
36
+ - No runtime dependencies beyond Rails itself
37
+ - No asset pipeline (Sprockets) dependency — assets served via Propshaft
38
+ - No JavaScript build step — delivered as a plain ES module for import maps
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,413 @@
1
+ # Biscuit
2
+
3
+ GDPR-compliant cookie consent banner for Rails 8. Renders a configurable
4
+ bottom/top banner, manages consent state via a browser cookie, and exposes a
5
+ Stimulus controller for interactivity. Supports i18n and CSS custom property
6
+ theming with no external runtime dependencies.
7
+
8
+ ---
9
+
10
+ ## Requirements
11
+
12
+ | Requirement | Version |
13
+ |---|---|
14
+ | Ruby | >= 3.2 |
15
+ | Rails | >= 8.0 |
16
+ | Stimulus | Any (via `@hotwired/stimulus`) |
17
+ | Import maps | Rails default (`importmap-rails`) |
18
+
19
+ No Sprockets, no build step. Assets are served via **Propshaft** (Rails 8
20
+ default).
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ Add to your `Gemfile`:
27
+
28
+ ```ruby
29
+ gem "biscuit-rails"
30
+ ```
31
+
32
+ Then:
33
+
34
+ ```sh
35
+ bundle install
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Setup
41
+
42
+ ### 1. Mount the engine
43
+
44
+ In `config/routes.rb`:
45
+
46
+ ```ruby
47
+ Rails.application.routes.draw do
48
+ mount Biscuit::Engine, at: "/biscuit"
49
+ # ... your other routes
50
+ end
51
+ ```
52
+
53
+ ### 2. Pin the Stimulus controller
54
+
55
+ In `config/importmap.rb`:
56
+
57
+ ```ruby
58
+ pin "biscuit/biscuit_controller", to: "biscuit/biscuit_controller.js"
59
+ ```
60
+
61
+ ### 3. Register the Stimulus controller
62
+
63
+ In `app/javascript/controllers/index.js`:
64
+
65
+ ```javascript
66
+ import BiscuitController from "biscuit/biscuit_controller"
67
+ application.register("biscuit", BiscuitController)
68
+ ```
69
+
70
+ ### 4. Include the stylesheet
71
+
72
+ In your layout (`app/views/layouts/application.html.erb`):
73
+
74
+ ```erb
75
+ <%= stylesheet_link_tag "biscuit/biscuit" %>
76
+ ```
77
+
78
+ ### 5. Render the banner
79
+
80
+ In your layout, inside `<body>`:
81
+
82
+ ```erb
83
+ <%= biscuit_banner %>
84
+ ```
85
+
86
+ That's it. The banner renders on every page. Once a user makes a consent
87
+ choice it hides itself and shows a small "Cookie settings" link so they can
88
+ revisit their preferences at any time.
89
+
90
+ ---
91
+
92
+ ## Banner Options
93
+
94
+ `biscuit_banner` accepts keyword options to control behaviour per-page:
95
+
96
+ ### `reload_on_consent`
97
+
98
+ When `true`, the page reloads via `Turbo.visit` after the user saves their
99
+ consent choice, instead of just hiding the banner. This is useful when your
100
+ layout conditionally loads scripts based on consent — a reload ensures those
101
+ scripts are evaluated with the new cookie in place.
102
+
103
+ ```erb
104
+ <%= biscuit_banner(reload_on_consent: true) %>
105
+ ```
106
+
107
+ Default: `false` — the banner hides in place without a page reload.
108
+
109
+ ---
110
+
111
+ ## Configuration
112
+
113
+ Create an initializer at `config/initializers/biscuit.rb`:
114
+
115
+ ```ruby
116
+ Biscuit.configure do |config|
117
+ # Cookie categories — see "Custom categories" below
118
+ config.categories = {
119
+ necessary: { required: true },
120
+ analytics: { required: false },
121
+ preferences: { required: false },
122
+ marketing: { required: false }
123
+ }
124
+
125
+ # Name of the browser cookie that stores consent state
126
+ # Default: "biscuit_consent"
127
+ config.cookie_name = "biscuit_consent"
128
+
129
+ # How long the consent cookie lasts, in days
130
+ # Default: 365
131
+ config.cookie_expires_days = 365
132
+
133
+ # Cookie path
134
+ # Default: "/"
135
+ config.cookie_path = "/"
136
+
137
+ # Cookie domain — nil means current domain
138
+ # Default: nil
139
+ config.cookie_domain = nil
140
+
141
+ # SameSite attribute
142
+ # Default: "Lax"
143
+ config.cookie_same_site = "Lax"
144
+
145
+ # Banner position: :bottom or :top
146
+ # Default: :bottom
147
+ config.position = :bottom
148
+
149
+ # URL for the "Learn more" privacy policy link
150
+ # Default: "#"
151
+ config.privacy_policy_url = "/privacy"
152
+ end
153
+ ```
154
+
155
+ ### All options at a glance
156
+
157
+ | Option | Default | Description |
158
+ |---|---|---|
159
+ | `categories` | `{necessary: {required: true}, analytics: {required: false}, marketing: {required: false}}` | Cookie categories shown to the user |
160
+ | `cookie_name` | `"biscuit_consent"` | Browser cookie name |
161
+ | `cookie_expires_days` | `365` | Cookie lifetime in days |
162
+ | `cookie_path` | `"/"` | Cookie path |
163
+ | `cookie_domain` | `nil` | Cookie domain (nil = current domain) |
164
+ | `cookie_same_site` | `"Lax"` | SameSite cookie attribute |
165
+ | `position` | `:bottom` | Banner position (`:bottom` or `:top`) |
166
+ | `privacy_policy_url` | `"#"` | "Learn more" link URL |
167
+
168
+ ---
169
+
170
+ ## Custom Cookie Categories
171
+
172
+ Define any categories you need. Each entry requires a `:required` key.
173
+ Categories with `required: true` are shown as permanently checked and
174
+ non-toggleable (necessary cookies). All others are opt-in checkboxes.
175
+
176
+ ```ruby
177
+ config.categories = {
178
+ necessary: { required: true },
179
+ analytics: { required: false },
180
+ preferences: { required: false },
181
+ marketing: { required: false }
182
+ }
183
+ ```
184
+
185
+ Add matching i18n keys for each custom category. For example, to add a
186
+ `preferences` category, add to `config/locales/en.yml`:
187
+
188
+ ```yaml
189
+ en:
190
+ biscuit:
191
+ categories:
192
+ preferences:
193
+ name: "Preferences"
194
+ description: "Remember your settings and personalisation choices."
195
+ ```
196
+
197
+ Biscuit ships with built-in translations for `necessary`, `analytics`,
198
+ `marketing`, and `preferences` in English and French. Any other category
199
+ requires you to add your own keys.
200
+
201
+ ---
202
+
203
+ ## CSS Theming
204
+
205
+ All styles are scoped under `.biscuit-banner`. Every visual property is
206
+ expressed as a CSS custom property, so you can override the entire look
207
+ without touching the gem.
208
+
209
+ ### Available custom properties
210
+
211
+ | Property | Default | Description |
212
+ |---|---|---|
213
+ | `--biscuit-bg` | `Canvas` | Banner background colour (browser default background) |
214
+ | `--biscuit-color` | `CanvasText` | Banner text colour (browser default text) |
215
+ | `--biscuit-muted` | `GrayText` | Secondary / description text colour |
216
+ | `--biscuit-accent` | `#4f46e5` | Primary button background |
217
+ | `--biscuit-accent-hover` | `#4338ca` | Primary button hover background |
218
+ | `--biscuit-border` | `rgba(0,0,0,0.12)` | Divider / border colour |
219
+ | `--biscuit-radius` | `0.375rem` | Button / panel border radius |
220
+ | `--biscuit-font-size` | `0.875rem` | Base font size |
221
+ | `--biscuit-font-family` | `inherit` | Font family |
222
+ | `--biscuit-z-index` | `9999` | Stack order |
223
+ | `--biscuit-padding` | `1rem 1.5rem` | Banner padding |
224
+ | `--biscuit-shadow-bottom` | `0 -2px 12px rgba(0,0,0,0.12)` | Shadow when `position: bottom` |
225
+ | `--biscuit-shadow-top` | `0 2px 12px rgba(0,0,0,0.12)` | Shadow when `position: top` |
226
+ | `--biscuit-max-width` | `64rem` | Inner content max-width |
227
+
228
+ ### Override example
229
+
230
+ In your application's CSS, after including the biscuit stylesheet:
231
+
232
+ ```css
233
+ .biscuit-banner {
234
+ --biscuit-accent: #0070f3;
235
+ --biscuit-accent-hover: #005bb5;
236
+ --biscuit-border: rgba(0, 0, 0, 0.08);
237
+ }
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Checking Consent in Views
243
+
244
+ Use the `biscuit_allowed?` helper, which is available in all views and
245
+ layouts:
246
+
247
+ ```erb
248
+ <% if biscuit_allowed?(:analytics) %>
249
+ <!-- Google Analytics or similar -->
250
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
251
+ <% end %>
252
+
253
+ <% if biscuit_allowed?(:marketing) %>
254
+ <!-- Marketing pixel -->
255
+ <% end %>
256
+ ```
257
+
258
+ `:necessary` always returns `true` regardless of cookie state.
259
+
260
+ ---
261
+
262
+ ## Checking Consent in Controllers
263
+
264
+ ```ruby
265
+ class ApplicationController < ActionController::Base
266
+ def analytics_enabled?
267
+ Biscuit::Consent.new(cookies).allowed?(:analytics)
268
+ end
269
+ end
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Cookie Format
275
+
276
+ The consent cookie stores a JSON payload:
277
+
278
+ ```json
279
+ {
280
+ "v": 1,
281
+ "consented_at": "2026-03-19T10:00:00Z",
282
+ "categories": {
283
+ "necessary": true,
284
+ "analytics": false,
285
+ "marketing": true
286
+ }
287
+ }
288
+ ```
289
+
290
+ - `v` — schema version (currently `1`). Biscuit ignores cookies from
291
+ unknown versions.
292
+ - `consented_at` — UTC ISO 8601 timestamp of when consent was recorded.
293
+ - `categories` — per-category boolean map. `necessary` is always `true`.
294
+
295
+ The cookie is **not** `httponly` so that client-side JavaScript can read
296
+ consent state for lazy-loading scripts.
297
+
298
+ ---
299
+
300
+ ## GDPR Notes
301
+
302
+ Biscuit provides the consent UI and storage mechanism. You are responsible for:
303
+
304
+ ### What Biscuit does
305
+
306
+ - Renders a banner that requires an explicit user action before dismissal
307
+ (no auto-dismiss)
308
+ - Offers equally prominent "Accept all" and "Reject non-essential" buttons
309
+ - Records granular, timestamped consent per category
310
+ - Allows the user to withdraw or amend consent at any time via the
311
+ "Cookie settings" link
312
+ - Marks `:necessary` cookies as non-toggleable and clearly labelled
313
+ - Writes no non-essential cookies itself — only the consent cookie, which
314
+ is a functional/necessary cookie
315
+
316
+ ### What Biscuit does NOT do
317
+
318
+ - **It does not block third-party scripts automatically.** You must
319
+ conditionally load scripts based on `biscuit_allowed?(:category)`.
320
+ See the pattern below.
321
+ - It does not implement geo-targeting (showing the banner only to EU
322
+ visitors).
323
+ - It does not store consent in a database (v1 is cookie-only).
324
+ - It does not provide a legal opinion on whether your implementation
325
+ meets GDPR requirements. Consult a lawyer.
326
+
327
+ ### Pattern: blocking non-essential scripts until consent
328
+
329
+ ```erb
330
+ <%# In your layout — only load analytics after consent %>
331
+ <% if biscuit_allowed?(:analytics) %>
332
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
333
+ <script>
334
+ window.dataLayer = window.dataLayer || [];
335
+ function gtag(){dataLayer.push(arguments);}
336
+ gtag('js', new Date());
337
+ gtag('config', 'G-XXXXXX');
338
+ </script>
339
+ <% end %>
340
+ ```
341
+
342
+ For scripts that must load on the client side after a user accepts consent
343
+ during their current session (without a page reload), listen for the Fetch
344
+ response in your own JavaScript and initialise scripts there, or use a
345
+ lightweight Turbo visit to reload the page after the consent POST succeeds.
346
+ (Turbo Stream support for post-consent injection is planned for v2.)
347
+
348
+ ### GDPR compliance checklist
349
+
350
+ - [x] No non-essential cookies set before consent
351
+ - [x] Consent is freely given — equal prominence for accept and reject
352
+ - [x] No pre-ticked boxes for non-required categories
353
+ - [x] No dark patterns
354
+ - [x] User can withdraw or amend consent at any time
355
+ - [x] Consent is granular — recorded per category
356
+ - [x] Consent is timestamped
357
+ - [x] Necessary cookies are clearly labelled and non-toggleable
358
+ - [x] Banner does not auto-dismiss
359
+
360
+ ---
361
+
362
+ ## i18n
363
+
364
+ Biscuit ships with English (`en`) and French (`fr`) translations. To add
365
+ another locale, create `config/locales/biscuit.<locale>.yml` in your app:
366
+
367
+ ```yaml
368
+ de:
369
+ biscuit:
370
+ banner:
371
+ aria_label: "Cookie-Zustimmung"
372
+ message: "Wir verwenden Cookies, um Ihr Erlebnis auf dieser Website zu verbessern."
373
+ learn_more: "Mehr erfahren"
374
+ accept_all: "Alle akzeptieren"
375
+ reject_all: "Nicht wesentliche ablehnen"
376
+ manage: "Einstellungen verwalten"
377
+ save: "Einstellungen speichern"
378
+ reopen: "Cookie-Einstellungen"
379
+ categories:
380
+ necessary:
381
+ name: "Notwendig"
382
+ description: "Für die Funktion der Website erforderlich. Kann nicht deaktiviert werden."
383
+ analytics:
384
+ name: "Analyse"
385
+ description: "Helfen uns zu verstehen, wie Besucher die Website nutzen."
386
+ marketing:
387
+ name: "Marketing"
388
+ description: "Werden verwendet, um personalisierte Werbung anzuzeigen."
389
+ preferences:
390
+ name: "Präferenzen"
391
+ description: "Speichern Ihre Einstellungen und Personalisierungsoptionen."
392
+ ```
393
+
394
+ ---
395
+
396
+ ## Engine Routes
397
+
398
+ The engine mounts two endpoints:
399
+
400
+ | Method | Path | Action |
401
+ |---|---|---|
402
+ | `POST` | `/biscuit/consent` | Record consent for all categories |
403
+ | `DELETE` | `/biscuit/consent` | Clear the consent cookie |
404
+
405
+ Both endpoints require a valid CSRF token. The Stimulus controller
406
+ reads the token from the `data-biscuit-csrf-token-value` attribute
407
+ (set automatically by the banner partial from `form_authenticity_token`).
408
+
409
+ ---
410
+
411
+ ## License
412
+
413
+ MIT
@@ -0,0 +1,89 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["preferencesPanel", "categoryCheckbox", "manageLink"]
5
+ static values = {
6
+ endpoint: String,
7
+ csrfToken: String,
8
+ position: { type: String, default: "bottom" },
9
+ alreadyConsented: { type: Boolean, default: false },
10
+ reloadOnConsent: { type: Boolean, default: false }
11
+ }
12
+
13
+ connect() {
14
+ if (this.alreadyConsentedValue) {
15
+ this.#hideBanner()
16
+ this.#showManageLink()
17
+ }
18
+ }
19
+
20
+ acceptAll() {
21
+ this.#post(this.#allCategories(true))
22
+ }
23
+
24
+ rejectAll() {
25
+ this.#post(this.#allCategories(false))
26
+ }
27
+
28
+ togglePreferences() {
29
+ const panel = this.preferencesPanelTarget
30
+ const isOpen = panel.classList.contains("biscuit-preferences--open")
31
+ panel.classList.toggle("biscuit-preferences--open", !isOpen)
32
+ panel.hidden = isOpen
33
+
34
+ // Update aria-expanded on the toggle button
35
+ const btn = this.element.querySelector("[data-action~='biscuit#togglePreferences']")
36
+ if (btn) btn.setAttribute("aria-expanded", String(!isOpen))
37
+ }
38
+
39
+ savePreferences() {
40
+ const categories = {}
41
+ this.categoryCheckboxTargets.forEach(cb => {
42
+ categories[cb.dataset.category] = cb.checked
43
+ })
44
+ this.#post(categories)
45
+ }
46
+
47
+ reopen() {
48
+ this.#showBanner()
49
+ this.#hideManageLink()
50
+ }
51
+
52
+ // Private
53
+
54
+ #allCategories(value) {
55
+ const categories = {}
56
+ this.categoryCheckboxTargets.forEach(cb => {
57
+ categories[cb.dataset.category] = value
58
+ })
59
+ return categories
60
+ }
61
+
62
+ async #post(categories) {
63
+ try {
64
+ const response = await fetch(this.endpointValue, {
65
+ method: "POST",
66
+ headers: {
67
+ "Content-Type": "application/json",
68
+ "X-CSRF-Token": this.csrfTokenValue
69
+ },
70
+ body: JSON.stringify({ categories })
71
+ })
72
+ if (response.ok) {
73
+ if (this.reloadOnConsentValue) {
74
+ Turbo.visit(window.location.href)
75
+ } else {
76
+ this.#hideBanner()
77
+ this.#showManageLink()
78
+ }
79
+ }
80
+ } catch (error) {
81
+ console.error("[Biscuit] Failed to save consent:", error)
82
+ }
83
+ }
84
+
85
+ #hideBanner() { this.element.hidden = true; this.element.setAttribute("aria-hidden", "true") }
86
+ #showBanner() { this.element.hidden = false; this.element.removeAttribute("aria-hidden") }
87
+ #showManageLink() { if (this.hasManageLinkTarget) this.manageLinkTarget.hidden = false }
88
+ #hideManageLink() { if (this.hasManageLinkTarget) this.manageLinkTarget.hidden = true }
89
+ }
@@ -0,0 +1,123 @@
1
+ .biscuit-banner {
2
+ /* Tokens — override in your app's CSS */
3
+ --biscuit-bg: Canvas;
4
+ --biscuit-color: CanvasText;
5
+ --biscuit-muted: GrayText;
6
+ --biscuit-accent: #4f46e5;
7
+ --biscuit-accent-hover: #4338ca;
8
+ --biscuit-border: rgba(0,0,0,0.12);
9
+ --biscuit-radius: 0.375rem;
10
+ --biscuit-font-size: 0.875rem;
11
+ --biscuit-font-family: inherit;
12
+ --biscuit-z-index: 9999;
13
+ --biscuit-padding: 1rem 1.5rem;
14
+ --biscuit-shadow-bottom: 0 -2px 12px rgba(0,0,0,0.12);
15
+ --biscuit-shadow-top: 0 2px 12px rgba(0,0,0,0.12);
16
+ --biscuit-max-width: 64rem;
17
+
18
+ position: fixed;
19
+ left: 0;
20
+ right: 0;
21
+ z-index: var(--biscuit-z-index);
22
+ background: var(--biscuit-bg);
23
+ color: var(--biscuit-color);
24
+ font-size: var(--biscuit-font-size);
25
+ font-family: var(--biscuit-font-family);
26
+ padding: var(--biscuit-padding);
27
+ }
28
+
29
+ .biscuit-banner[data-biscuit-position-value="bottom"] {
30
+ bottom: 0;
31
+ box-shadow: var(--biscuit-shadow-bottom);
32
+ }
33
+
34
+ .biscuit-banner[data-biscuit-position-value="top"] {
35
+ top: 0;
36
+ box-shadow: var(--biscuit-shadow-top);
37
+ }
38
+
39
+ .biscuit-banner__inner {
40
+ max-width: var(--biscuit-max-width);
41
+ margin: 0 auto;
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ gap: 0.75rem;
46
+ }
47
+
48
+ .biscuit-banner__message { flex: 1 1 20rem; }
49
+
50
+ .biscuit-banner__actions {
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ gap: 0.5rem;
54
+ }
55
+
56
+ .biscuit-btn {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ padding: 0.4rem 0.9rem;
60
+ border-radius: var(--biscuit-radius);
61
+ font-size: var(--biscuit-font-size);
62
+ font-weight: 500;
63
+ cursor: pointer;
64
+ border: 1px solid transparent;
65
+ transition: background 0.15s, color 0.15s;
66
+ white-space: nowrap;
67
+ }
68
+
69
+ .biscuit-btn--primary {
70
+ background: var(--biscuit-accent);
71
+ color: #fff;
72
+ }
73
+ .biscuit-btn--primary:hover { background: var(--biscuit-accent-hover); }
74
+
75
+ .biscuit-btn--secondary {
76
+ background: transparent;
77
+ color: var(--biscuit-color);
78
+ border-color: var(--biscuit-border);
79
+ }
80
+ .biscuit-btn--secondary:hover { background: rgba(0,0,0,0.05); }
81
+
82
+ .biscuit-preferences { width: 100%; margin-top: 0.75rem; border-top: 1px solid var(--biscuit-border); padding-top: 0.75rem; }
83
+ .biscuit-preferences:not(.biscuit-preferences--open) { display: none; }
84
+
85
+ .biscuit-preference-row {
86
+ display: flex;
87
+ align-items: flex-start;
88
+ gap: 0.75rem;
89
+ padding: 0.5rem 0;
90
+ border-bottom: 1px solid var(--biscuit-border);
91
+ }
92
+ .biscuit-preference-row:last-child { border-bottom: none; }
93
+ .biscuit-preference-row__label { flex: 1; }
94
+ .biscuit-preference-row__name { font-weight: 600; display: block; }
95
+ .biscuit-preference-row__description { color: var(--biscuit-muted); font-size: 0.8rem; }
96
+
97
+ .biscuit-preferences__footer {
98
+ margin-top: 0.75rem;
99
+ }
100
+
101
+ .biscuit-manage-link {
102
+ position: fixed;
103
+ z-index: var(--biscuit-z-index);
104
+ font-size: 0.75rem;
105
+ color: var(--biscuit-muted);
106
+ background: transparent;
107
+ border: none;
108
+ cursor: pointer;
109
+ padding: 0.25rem 0.5rem;
110
+ text-decoration: underline;
111
+ }
112
+ .biscuit-manage-link[data-biscuit-position-value="bottom"] { bottom: 0.5rem; left: 1rem; }
113
+ .biscuit-manage-link[data-biscuit-position-value="top"] { top: 0.5rem; left: 1rem; }
114
+
115
+ .biscuit-learn-more {
116
+ color: var(--biscuit-color);
117
+ }
118
+
119
+ @media (max-width: 640px) {
120
+ .biscuit-banner__inner { flex-direction: column; align-items: flex-start; }
121
+ .biscuit-banner__actions { width: 100%; }
122
+ .biscuit-btn { flex: 1 1 auto; justify-content: center; }
123
+ }
@@ -0,0 +1,18 @@
1
+ module Biscuit
2
+ class ConsentController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ def update
6
+ categories = params.require(:categories).permit(
7
+ Biscuit.configuration.categories.keys.map(&:to_s)
8
+ ).to_h
9
+ Biscuit::Consent.write(cookies, categories)
10
+ render json: { ok: true }
11
+ end
12
+
13
+ def destroy
14
+ Biscuit::Consent.clear(cookies)
15
+ render json: { ok: true }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Biscuit
2
+ module BiscuitHelper
3
+ # Renders the consent banner.
4
+ # If consent has already been given, renders only the minimal
5
+ # "Cookie settings" reopener link (hidden banner state).
6
+ def biscuit_banner(**options)
7
+ consent = Biscuit::Consent.new(cookies)
8
+ render partial: "biscuit/banner/banner",
9
+ locals: { consent: consent, options: options }
10
+ end
11
+
12
+ # Returns true if the user has consented to the given category.
13
+ # Safe to call even when no cookie exists — returns false.
14
+ def biscuit_allowed?(category)
15
+ Biscuit::Consent.new(cookies).allowed?(category)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,104 @@
1
+ <div class="biscuit-banner"
2
+ data-controller="biscuit"
3
+ data-biscuit-position-value="<%= Biscuit.configuration.position %>"
4
+ data-biscuit-csrf-token-value="<%= form_authenticity_token %>"
5
+ data-biscuit-endpoint-value="<%= biscuit.consent_path %>"
6
+ data-biscuit-already-consented-value="<%= consent.given? %>"
7
+ data-biscuit-reload-on-consent-value="<%= options[:reload_on_consent] ? 'true' : 'false' %>"
8
+ role="dialog"
9
+ aria-label="<%= t("biscuit.banner.aria_label") %>"
10
+ aria-modal="false">
11
+
12
+ <div class="biscuit-banner__inner">
13
+ <p class="biscuit-banner__message">
14
+ <%= t("biscuit.banner.message") %>
15
+ <a href="<%= Biscuit.configuration.privacy_policy_url %>" class="biscuit-learn-more">
16
+ <%= t("biscuit.banner.learn_more") %>
17
+ </a>
18
+ </p>
19
+
20
+ <div class="biscuit-banner__actions">
21
+ <button type="button"
22
+ class="biscuit-btn biscuit-btn--primary"
23
+ data-action="click->biscuit#acceptAll"
24
+ aria-label="<%= t("biscuit.banner.accept_all") %>">
25
+ <%= t("biscuit.banner.accept_all") %>
26
+ </button>
27
+
28
+ <button type="button"
29
+ class="biscuit-btn biscuit-btn--secondary"
30
+ data-action="click->biscuit#rejectAll"
31
+ aria-label="<%= t("biscuit.banner.reject_all") %>">
32
+ <%= t("biscuit.banner.reject_all") %>
33
+ </button>
34
+
35
+ <button type="button"
36
+ class="biscuit-btn biscuit-btn--secondary"
37
+ data-action="click->biscuit#togglePreferences"
38
+ aria-expanded="false"
39
+ aria-controls="biscuit-preferences-panel">
40
+ <%= t("biscuit.banner.manage") %>
41
+ </button>
42
+ </div>
43
+
44
+ <div id="biscuit-preferences-panel"
45
+ class="biscuit-preferences"
46
+ data-biscuit-target="preferencesPanel"
47
+ hidden
48
+ aria-label="<%= t("biscuit.banner.manage") %>">
49
+
50
+ <% Biscuit.configuration.categories.each do |key, opts| %>
51
+ <div class="biscuit-preference-row">
52
+ <% checkbox_id = "biscuit-category-#{key}" %>
53
+
54
+ <% if opts[:required] %>
55
+ <input type="checkbox"
56
+ id="<%= checkbox_id %>"
57
+ checked
58
+ disabled
59
+ aria-label="<%= t("biscuit.categories.#{key}.name") %>"
60
+ aria-describedby="<%= checkbox_id %>-desc">
61
+ <% else %>
62
+ <input type="checkbox"
63
+ id="<%= checkbox_id %>"
64
+ data-biscuit-target="categoryCheckbox"
65
+ data-category="<%= key %>"
66
+ <%= consent.given? && consent.allowed?(key) ? "checked" : "" %>
67
+ aria-label="<%= t("biscuit.categories.#{key}.name") %>"
68
+ aria-describedby="<%= checkbox_id %>-desc">
69
+ <% end %>
70
+
71
+ <div class="biscuit-preference-row__label">
72
+ <label for="<%= checkbox_id %>" class="biscuit-preference-row__name">
73
+ <%= t("biscuit.categories.#{key}.name") %>
74
+ <% if opts[:required] %>
75
+ <span aria-hidden="true"> (required)</span>
76
+ <% end %>
77
+ </label>
78
+ <span id="<%= checkbox_id %>-desc" class="biscuit-preference-row__description">
79
+ <%= t("biscuit.categories.#{key}.description") %>
80
+ </span>
81
+ </div>
82
+ </div>
83
+ <% end %>
84
+
85
+ <div class="biscuit-preferences__footer">
86
+ <button type="button"
87
+ class="biscuit-btn biscuit-btn--primary"
88
+ data-action="click->biscuit#savePreferences">
89
+ <%= t("biscuit.banner.save") %>
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <button type="button"
97
+ class="biscuit-manage-link"
98
+ data-biscuit-target="manageLink"
99
+ data-biscuit-position-value="<%= Biscuit.configuration.position %>"
100
+ data-action="click->biscuit#reopen"
101
+ hidden
102
+ aria-label="<%= t("biscuit.banner.reopen") %>">
103
+ <%= t("biscuit.banner.reopen") %>
104
+ </button>
@@ -0,0 +1,24 @@
1
+ en:
2
+ biscuit:
3
+ banner:
4
+ aria_label: "Cookie consent"
5
+ message: "We use cookies to improve your experience on this site."
6
+ learn_more: "Learn more"
7
+ accept_all: "Accept all"
8
+ reject_all: "Reject non-essential"
9
+ manage: "Manage preferences"
10
+ save: "Save preferences"
11
+ reopen: "Cookie settings"
12
+ categories:
13
+ necessary:
14
+ name: "Necessary"
15
+ description: "Required for the site to function. Cannot be disabled."
16
+ analytics:
17
+ name: "Analytics"
18
+ description: "Help us understand how visitors use the site."
19
+ marketing:
20
+ name: "Marketing"
21
+ description: "Used to show personalised advertisements."
22
+ preferences:
23
+ name: "Preferences"
24
+ description: "Remember your settings and personalisation choices."
@@ -0,0 +1,24 @@
1
+ fr:
2
+ biscuit:
3
+ banner:
4
+ aria_label: "Consentement aux cookies"
5
+ message: "Nous utilisons des cookies pour améliorer votre expérience sur ce site."
6
+ learn_more: "En savoir plus"
7
+ accept_all: "Tout accepter"
8
+ reject_all: "Refuser les non-essentiels"
9
+ manage: "Gérer les préférences"
10
+ save: "Enregistrer les préférences"
11
+ reopen: "Paramètres des cookies"
12
+ categories:
13
+ necessary:
14
+ name: "Nécessaires"
15
+ description: "Indispensables au fonctionnement du site. Ne peuvent pas être désactivés."
16
+ analytics:
17
+ name: "Analytiques"
18
+ description: "Nous aident à comprendre comment les visiteurs utilisent le site."
19
+ marketing:
20
+ name: "Marketing"
21
+ description: "Utilisés pour afficher des publicités personnalisées."
22
+ preferences:
23
+ name: "Préférences"
24
+ description: "Mémorisent vos paramètres et vos choix de personnalisation."
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ Biscuit::Engine.routes.draw do
2
+ post "/consent", to: "consent#update"
3
+ delete "/consent", to: "consent#destroy"
4
+ end
@@ -0,0 +1,48 @@
1
+ module Biscuit
2
+ class Configuration
3
+ # Cookie categories. Each key is the category identifier (symbol).
4
+ # :necessary is always present and always true — cannot be disabled by the user.
5
+ # Additional categories are shown to the user and default to opt-out.
6
+ # Each category accepts:
7
+ # required: true/false — if true, shown as disabled/checked in the UI
8
+ attr_accessor :categories
9
+
10
+ # Name of the browser cookie storing consent state.
11
+ # Default: "biscuit_consent"
12
+ attr_accessor :cookie_name
13
+
14
+ # Consent cookie lifetime in days.
15
+ # Default: 365
16
+ attr_accessor :cookie_expires_days
17
+
18
+ # Cookie path. Default: "/"
19
+ attr_accessor :cookie_path
20
+
21
+ # Cookie domain. nil means current domain. Default: nil
22
+ attr_accessor :cookie_domain
23
+
24
+ # SameSite attribute. Default: "Lax"
25
+ attr_accessor :cookie_same_site
26
+
27
+ # Banner position: :bottom or :top. Default: :bottom
28
+ attr_accessor :position
29
+
30
+ # URL for the "Learn more" / privacy policy link. Default: "#"
31
+ attr_accessor :privacy_policy_url
32
+
33
+ def initialize
34
+ @categories = {
35
+ necessary: { required: true },
36
+ analytics: { required: false },
37
+ marketing: { required: false }
38
+ }
39
+ @cookie_name = "biscuit_consent"
40
+ @cookie_expires_days = 365
41
+ @cookie_path = "/"
42
+ @cookie_domain = nil
43
+ @cookie_same_site = "Lax"
44
+ @position = :bottom
45
+ @privacy_policy_url = "#"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,61 @@
1
+ module Biscuit
2
+ class Consent
3
+ CURRENT_VERSION = 1
4
+
5
+ # cookies: ActionDispatch::Cookies::CookieJar
6
+ def initialize(cookies)
7
+ @cookies = cookies
8
+ @data = self.class.parse(cookies[Biscuit.configuration.cookie_name])
9
+ end
10
+
11
+ # True if a valid consent decision has been recorded.
12
+ def given?
13
+ !@data.nil?
14
+ end
15
+
16
+ # True if the user has consented to this category.
17
+ # :necessary always returns true regardless of cookie state.
18
+ def allowed?(category)
19
+ return true if category.to_sym == :necessary
20
+ return false unless given?
21
+ @data.dig("categories", category.to_s) == true
22
+ end
23
+
24
+ # Write consent cookie to the jar.
25
+ def self.write(cookies, categories)
26
+ value = build_value(categories)
27
+ config = Biscuit.configuration
28
+ cookies[config.cookie_name] = {
29
+ value: value.to_json,
30
+ expires: config.cookie_expires_days.days.from_now,
31
+ path: config.cookie_path,
32
+ domain: config.cookie_domain,
33
+ same_site: config.cookie_same_site,
34
+ httponly: false # Must be readable by JS for client-side consent checks
35
+ }
36
+ end
37
+
38
+ # Clear the consent cookie.
39
+ def self.clear(cookies)
40
+ cookies.delete(Biscuit.configuration.cookie_name)
41
+ end
42
+
43
+ # Build the cookie value hash. Always forces necessary: true.
44
+ def self.build_value(categories)
45
+ cats = categories.transform_keys(&:to_s)
46
+ .transform_values { |v| v == true || v == "true" }
47
+ cats["necessary"] = true
48
+ { "v" => CURRENT_VERSION, "consented_at" => Time.now.utc.iso8601, "categories" => cats }
49
+ end
50
+
51
+ # Parse raw cookie string. Returns nil if blank, invalid JSON, or wrong version.
52
+ def self.parse(raw)
53
+ return nil if raw.blank?
54
+ data = JSON.parse(raw)
55
+ return nil unless data.is_a?(Hash) && data["v"] == CURRENT_VERSION && data["categories"].is_a?(Hash)
56
+ data
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,17 @@
1
+ module Biscuit
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Biscuit
4
+
5
+ # Make helper available in all host app views
6
+ initializer "biscuit.helpers" do
7
+ ActiveSupport.on_load(:action_view) do
8
+ include Biscuit::BiscuitHelper
9
+ end
10
+ end
11
+
12
+ # Register i18n locale files
13
+ initializer "biscuit.i18n" do
14
+ config.i18n.load_path += Dir[Engine.root.join("config/locales/*.yml")]
15
+ end
16
+ end
17
+ end
@@ -0,0 +1 @@
1
+ require "biscuit"
@@ -0,0 +1,3 @@
1
+ module Biscuit
2
+ VERSION = "0.1.1"
3
+ end
data/lib/biscuit.rb ADDED
@@ -0,0 +1,20 @@
1
+ require "biscuit/version"
2
+ require "biscuit/configuration"
3
+ require "biscuit/consent"
4
+ require "biscuit/engine"
5
+
6
+ module Biscuit
7
+ class << self
8
+ def configuration
9
+ @configuration ||= Configuration.new
10
+ end
11
+
12
+ def configure
13
+ yield configuration
14
+ end
15
+
16
+ def reset_configuration!
17
+ @configuration = Configuration.new
18
+ end
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: biscuit-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Gareth James
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-03-19 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: Biscuit provides a configurable GDPR cookie consent banner for Rails
27
+ 8+ applications. It manages consent state via a browser cookie, exposes a Stimulus
28
+ controller for interactivity, and supports i18n and CSS custom property theming
29
+ with no build step required.
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE
36
+ - README.md
37
+ - app/assets/javascripts/biscuit/biscuit_controller.js
38
+ - app/assets/stylesheets/biscuit/biscuit.css
39
+ - app/controllers/biscuit/consent_controller.rb
40
+ - app/helpers/biscuit/biscuit_helper.rb
41
+ - app/views/biscuit/banner/_banner.html.erb
42
+ - config/locales/en.yml
43
+ - config/locales/fr.yml
44
+ - config/routes.rb
45
+ - lib/biscuit.rb
46
+ - lib/biscuit/configuration.rb
47
+ - lib/biscuit/consent.rb
48
+ - lib/biscuit/engine.rb
49
+ - lib/biscuit/rails.rb
50
+ - lib/biscuit/version.rb
51
+ homepage: https://github.com/garethfr/biscuit-rails
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://bemused.org/projects/biscuit-rails
56
+ source_code_uri: https://github.com/garethfr/biscuit-rails
57
+ changelog_uri: https://github.com/garethfr/biscuit-rails/blob/main/CHANGELOG.md
58
+ bug_tracker_uri: https://github.com/garethfr/biscuit-rails/issues
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.2'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.2
74
+ specification_version: 4
75
+ summary: GDPR-compliant cookie consent banner for Rails 8
76
+ test_files: []