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 +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/app/assets/javascripts/biscuit/biscuit_controller.js +89 -0
- data/app/assets/stylesheets/biscuit/biscuit.css +123 -0
- data/app/controllers/biscuit/consent_controller.rb +18 -0
- data/app/helpers/biscuit/biscuit_helper.rb +18 -0
- data/app/views/biscuit/banner/_banner.html.erb +104 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/fr.yml +24 -0
- data/config/routes.rb +4 -0
- data/lib/biscuit/configuration.rb +48 -0
- data/lib/biscuit/consent.rb +61 -0
- data/lib/biscuit/engine.rb +17 -0
- data/lib/biscuit/rails.rb +1 -0
- data/lib/biscuit/version.rb +3 -0
- data/lib/biscuit.rb +20 -0
- metadata +76 -0
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,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"
|
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: []
|