consently 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 +23 -0
- data/MIT-LICENSE +20 -0
- data/README.md +279 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/consently.css +252 -0
- data/app/controllers/consently/application_controller.rb +4 -0
- data/app/controllers/consently/consents_controller.rb +44 -0
- data/app/helpers/consently/application_helper.rb +4 -0
- data/app/helpers/consently/tags_helper.rb +176 -0
- data/app/javascript/consently/banner_controller.js +190 -0
- data/app/models/consently/application_record.rb +5 -0
- data/app/models/consently/consent_record.rb +18 -0
- data/app/views/consently/_banner.html.erb +93 -0
- data/app/views/consently/_policy.html.erb +85 -0
- data/config/importmap.rb +3 -0
- data/config/locales/cs.yml +33 -0
- data/config/locales/de.yml +33 -0
- data/config/locales/en.yml +33 -0
- data/config/locales/es.yml +33 -0
- data/config/locales/fr.yml +33 -0
- data/config/locales/hu.yml +33 -0
- data/config/locales/it.yml +33 -0
- data/config/locales/nl.yml +33 -0
- data/config/locales/pl.yml +33 -0
- data/config/locales/sk.yml +33 -0
- data/config/routes.rb +5 -0
- data/lib/consently/configuration.rb +150 -0
- data/lib/consently/consent.rb +62 -0
- data/lib/consently/cookie.rb +12 -0
- data/lib/consently/engine.rb +33 -0
- data/lib/consently/providers/base.rb +95 -0
- data/lib/consently/providers/clarity.rb +34 -0
- data/lib/consently/providers/custom.rb +34 -0
- data/lib/consently/providers/google_ads.rb +32 -0
- data/lib/consently/providers/google_analytics.rb +45 -0
- data/lib/consently/providers/google_tag_manager.rb +34 -0
- data/lib/consently/providers/hotjar.rb +39 -0
- data/lib/consently/providers/meta_pixel.rb +37 -0
- data/lib/consently/providers/plausible.rb +30 -0
- data/lib/consently/providers.rb +9 -0
- data/lib/consently/script.rb +17 -0
- data/lib/consently/version.rb +3 -0
- data/lib/consently.rb +74 -0
- data/lib/generators/consently/consent_log/consent_log_generator.rb +28 -0
- data/lib/generators/consently/consent_log/templates/create_consently_consent_records.rb +19 -0
- data/lib/generators/consently/install/install_generator.rb +44 -0
- data/lib/generators/consently/install/templates/consently.rb +63 -0
- data/lib/generators/consently/views/views_generator.rb +15 -0
- data/lib/tasks/consently_tasks.rake +4 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4e94002d1ea10654641d354b24f94f18c97a106e71cf737a9fede52de32b6141
|
|
4
|
+
data.tar.gz: 5dd2300f3e1b7e8f7c696dd8dc86d5cdd098c7889e6a070f00b379b2f546b105
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5d47ad72af0dd8538c124c383ebe809605675a05f68ab496a97e88a1f089d9eca4e1e1f779617ffb3361c4d5a0bc7a9eb8dc2ba1b13799b67aeb3e32a6b0856d
|
|
7
|
+
data.tar.gz: c56ac8259f0b480dd6d7c34c0649ca2ac02b365cb3e32d894600d8692918bd21d4c76f32cfc537e30481ebe8c16bf65890ceebc1fa7983e0673d4c0184e632df
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-08-12)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Tag registry: Google Tag Manager, GA4, Google Ads, Microsoft Clarity, Meta
|
|
8
|
+
Pixel, Hotjar, Plausible, and `:custom` for anything else.
|
|
9
|
+
- Non-essential tags render as inert `<script type="text/plain">` whose source
|
|
10
|
+
the browser never fetches, and become live scripts on consent without a
|
|
11
|
+
reload.
|
|
12
|
+
- Google Consent Mode v2: denied by default before any Google tag, updated the
|
|
13
|
+
moment the visitor chooses.
|
|
14
|
+
- Banner and preferences panel in plain CSS - no Tailwind, no build step -
|
|
15
|
+
themed through custom properties, driven by one Stimulus controller, and
|
|
16
|
+
translated into ten languages.
|
|
17
|
+
- `consently_policy` generates the cookie policy from the same configuration:
|
|
18
|
+
categories, vendors, the cookies each one sets and how long they last.
|
|
19
|
+
- Per-domain or per-tenant tag sets from a single initializer.
|
|
20
|
+
- Optional consent log in your own database, anonymous unless you name the
|
|
21
|
+
subject yourself.
|
|
22
|
+
- Global Privacy Control honoured by default, Do Not Track opt-in, and
|
|
23
|
+
`consent_required` for deciding who has to be asked at all.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright TODO: Write your name
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Consently
|
|
2
|
+
|
|
3
|
+
**GDPR cookie consent for Rails that actually blocks your tags.**
|
|
4
|
+
|
|
5
|
+
Most banners ask for consent and load Google Analytics anyway. Consently is
|
|
6
|
+
the other half. Declare your tags once; every non-essential one is rendered as
|
|
7
|
+
an inert `<script type="text/plain">` the browser will not even fetch, and
|
|
8
|
+
becomes a live script the instant the visitor agrees - no page reload, no lost
|
|
9
|
+
pageview.
|
|
10
|
+
|
|
11
|
+
| | |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| **Blocks** | GA4, Google Tag Manager, Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible, anything custom |
|
|
14
|
+
| **Google Consent Mode v2** | denied by default before any Google tag, updated on the click |
|
|
15
|
+
| **Banner** | plain CSS, no Tailwind, no build step, one Stimulus controller, ten languages |
|
|
16
|
+
| **Cookie policy** | generated from the same config - every vendor, every cookie, every duration |
|
|
17
|
+
| **Multi-tenant** | different tags per domain or shop from one initializer |
|
|
18
|
+
| **Proof of consent** | optional log in your own database; no third-party service, nothing leaves your servers |
|
|
19
|
+
| **Install** | one initializer, three helpers in your layout |
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# config/initializers/consently.rb
|
|
23
|
+
Consently.configure do |c|
|
|
24
|
+
c.tag :google_tag_manager, id: "GTM-XXXXXXX"
|
|
25
|
+
c.tag :google_analytics, id: "G-XXXXXXXXXX"
|
|
26
|
+
c.tag :clarity, id: "abcd1234"
|
|
27
|
+
c.tag :meta_pixel, id: "123456789012345"
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```erb
|
|
32
|
+
<head>
|
|
33
|
+
<%= consently_tags %>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<%= consently_noscript_tags %>
|
|
37
|
+
...
|
|
38
|
+
<%= consently_banner %>
|
|
39
|
+
</body>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That is the whole integration.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "consently"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle install
|
|
52
|
+
rails g consently:install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The generator writes the initializer and registers the Stimulus controller in
|
|
56
|
+
`app/javascript/controllers/index.js`. Nothing else to set up: the banner
|
|
57
|
+
brings its own plain CSS, so there is no Tailwind, no build step and no
|
|
58
|
+
config file to keep in sync.
|
|
59
|
+
|
|
60
|
+
## What is a tag
|
|
61
|
+
|
|
62
|
+
| Provider | Key | Default category |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| Google Tag Manager | `:google_tag_manager` | `analytics` |
|
|
65
|
+
| Google Analytics 4 | `:google_analytics` | `analytics` |
|
|
66
|
+
| Google Ads | `:google_ads` | `marketing` |
|
|
67
|
+
| Microsoft Clarity | `:clarity` | `analytics` |
|
|
68
|
+
| Meta Pixel | `:meta_pixel` | `marketing` |
|
|
69
|
+
| Hotjar | `:hotjar` | `analytics` |
|
|
70
|
+
| Plausible | `:plausible` | `analytics` (move to `necessary` if you run it without a banner) |
|
|
71
|
+
| anything else | `:custom` | `marketing` |
|
|
72
|
+
|
|
73
|
+
Every tag takes `category:` to move it, and `:custom` covers vendors Consently
|
|
74
|
+
does not know yet:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
c.tag :custom, as: :piwik, category: :analytics, src: "https://cdn.example.com/piwik.js"
|
|
78
|
+
c.tag :custom, as: :inline_thing, category: :marketing, inline: "console.log('hi')"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Consent
|
|
82
|
+
|
|
83
|
+
Three categories out of the box - `necessary`, `analytics`, `marketing` - and
|
|
84
|
+
you can add your own:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
c.category :personalization
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`necessary` never waits for a click. Everything else is blocked until the
|
|
91
|
+
visitor agrees, and the choice is kept in a `consently` cookie for six months.
|
|
92
|
+
|
|
93
|
+
Change your policy? Bump `c.consent_version` and every older consent stops
|
|
94
|
+
counting; the banner asks again.
|
|
95
|
+
|
|
96
|
+
## Per-domain, per-tenant
|
|
97
|
+
|
|
98
|
+
One initializer, different tags per host or shop:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
c.scope_resolver = -> (request) { request.host }
|
|
102
|
+
|
|
103
|
+
c.scope "trixbrix.eu" do |s|
|
|
104
|
+
s.tag :google_analytics, id: "G-TRIX"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
c.scope "pixelpicture.eu" do |s|
|
|
108
|
+
s.tag :google_analytics, id: "G-PIXEL"
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Scopes inherit the tags declared outside them and override by declaring the
|
|
113
|
+
same provider again. The resolver can return anything - `Current.shop&.name`
|
|
114
|
+
works just as well as a host.
|
|
115
|
+
|
|
116
|
+
## Proof of consent
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
rails g consently:consent_log
|
|
120
|
+
rails db:migrate
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# config/routes.rb
|
|
125
|
+
mount Consently::Engine => "/consently"
|
|
126
|
+
|
|
127
|
+
# config/initializers/consently.rb
|
|
128
|
+
c.log_consents = true
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Each decision is then stored as a `Consently::ConsentRecord`: the categories,
|
|
132
|
+
the policy version, the scope, the user agent, and a salted digest of the IP -
|
|
133
|
+
enough to show a decision was made, not enough to identify anyone.
|
|
134
|
+
|
|
135
|
+
The cookie is still what decides which tags run; the table is only the proof,
|
|
136
|
+
since a visitor can clear their cookies and a log that lives in their browser
|
|
137
|
+
proves nothing. Nobody is named unless you say who they are:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
c.consent_subject = -> (request) { request.env["warden"]&.user&.id }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## After a choice
|
|
144
|
+
|
|
145
|
+
Nothing reloads: the tags the visitor just agreed to start running in place,
|
|
146
|
+
and the page keeps its scroll position and its state. If your page renders
|
|
147
|
+
something the server decides from consent - an embedded map, a video, a
|
|
148
|
+
status panel - ask for a reload instead:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
c.reload_after_choice = true
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Either way a `consently:change` event fires on `document`, carrying the
|
|
155
|
+
granted categories, so you can react to it yourself:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
document.addEventListener("consently:change", ({ detail }) => {
|
|
159
|
+
console.log(detail.categories) // ["analytics"]
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Who gets asked
|
|
164
|
+
|
|
165
|
+
Everyone, by default. If your legal advice says visitors outside the EU do not
|
|
166
|
+
need asking, say who does:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
c.consent_required = -> (request) { EU_COUNTRIES.include?(request.headers["CF-IPCountry"]) }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For anyone that returns false there is no banner, and every category counts as
|
|
173
|
+
granted.
|
|
174
|
+
|
|
175
|
+
Opt-out signals are answers, so those visitors are never asked either. Global
|
|
176
|
+
Privacy Control - binding in California and Colorado - is honoured out of the
|
|
177
|
+
box. Do Not Track is advisory, so it is opt-in:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
c.respect_do_not_track = true
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Accessibility
|
|
184
|
+
|
|
185
|
+
The card is a non-modal `role="dialog"` labelled by its own text, the settings
|
|
186
|
+
button carries `aria-expanded` and `aria-controls`, reopening the panel moves
|
|
187
|
+
focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
|
|
188
|
+
is trapped in a focus cycle they did not ask for.
|
|
189
|
+
|
|
190
|
+
## Events
|
|
191
|
+
|
|
192
|
+
```erb
|
|
193
|
+
<%= consently_data_layer_push("purchase", value: 120, currency: "EUR") %>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Renders nothing when analytics consent is missing.
|
|
197
|
+
|
|
198
|
+
## Styling
|
|
199
|
+
|
|
200
|
+
The banner ships as plain CSS scoped under `.consently`, driven by custom
|
|
201
|
+
properties. Most restyling is a few variables in your own stylesheet:
|
|
202
|
+
|
|
203
|
+
```css
|
|
204
|
+
.consently {
|
|
205
|
+
--consently-accent: #4f46e5;
|
|
206
|
+
--consently-accent-text: #ffffff;
|
|
207
|
+
--consently-radius: 0;
|
|
208
|
+
--consently-max-width: 28rem;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Want the markup instead? Take the partial over, and turn the gem's stylesheet
|
|
213
|
+
off so it stops loading:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
rails g consently:views
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
c.stylesheet = false
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Translations live under the `consently.*` keys; your own locale files win over
|
|
224
|
+
the gem's, so overriding one string means writing that one key.
|
|
225
|
+
|
|
226
|
+
## Turbo
|
|
227
|
+
|
|
228
|
+
The banner is `data-turbo-permanent`, so it survives Turbo Drive navigation
|
|
229
|
+
and morphed refreshes. Tags blocked at first render stay blocked across
|
|
230
|
+
visits until consent is given, and are rendered live from the server on the
|
|
231
|
+
next request after that.
|
|
232
|
+
|
|
233
|
+
## Try it
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
bin/rails server
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
http://localhost:3000 runs the dummy application: four tags configured, a live
|
|
240
|
+
list of what is blocked and what is running, the cookie as it stands, the
|
|
241
|
+
logged decisions and every translation to click through.
|
|
242
|
+
|
|
243
|
+
## Cheat sheet
|
|
244
|
+
|
|
245
|
+
Helpers:
|
|
246
|
+
|
|
247
|
+
| | |
|
|
248
|
+
| --- | --- |
|
|
249
|
+
| `consently_tags` | `<head>`: the stylesheet, consent mode defaults, and every tag (blocked or live) |
|
|
250
|
+
| `consently_noscript_tags` | after `<body>`: GTM and Meta fallbacks, for granted categories only |
|
|
251
|
+
| `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
|
|
252
|
+
| `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
|
|
253
|
+
| `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
|
|
254
|
+
| `consently_data_layer_push(event, **payload)` | a dataLayer event, rendered only with analytics consent |
|
|
255
|
+
| `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
|
|
256
|
+
|
|
257
|
+
Configuration:
|
|
258
|
+
|
|
259
|
+
| | |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| `c.tag key, **options` | `id:`, `domain:`, `category:`, `as:` (name it, for two of a kind) |
|
|
262
|
+
| `c.scope(name) { \|s\| ... }` | tags for one tenant; `c.scope_resolver` decides which one a request is |
|
|
263
|
+
| `c.category :name` | a category of your own beside `necessary`, `analytics`, `marketing` |
|
|
264
|
+
| `c.consent_version` | bump it and every older consent stops counting |
|
|
265
|
+
| `c.enabled` | `true`, `false`, or a callable taking the request |
|
|
266
|
+
| `c.reload_after_choice` | reload once a choice is made; off by default |
|
|
267
|
+
| `c.google_consent_mode` | consent mode v2 defaults and updates; on by default |
|
|
268
|
+
| `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
|
|
269
|
+
| `c.stylesheet` | link the banner's CSS; off if you style it yourself |
|
|
270
|
+
| `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path` | where the choice is kept |
|
|
271
|
+
| `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
|
|
272
|
+
| `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
|
|
273
|
+
|
|
274
|
+
JavaScript: a `consently:change` event fires on `document` with the granted
|
|
275
|
+
categories.
|
|
276
|
+
|
|
277
|
+
## Licence
|
|
278
|
+
|
|
279
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The banner's own styles. Plain CSS on purpose: a gem that only works if the
|
|
3
|
+
* host application runs Tailwind is half a gem.
|
|
4
|
+
*
|
|
5
|
+
* Everything is scoped under .consently and driven by custom properties, so
|
|
6
|
+
* restyling means overriding a handful of variables rather than forking the
|
|
7
|
+
* markup:
|
|
8
|
+
*
|
|
9
|
+
* .consently { --consently-accent: #4f46e5; --consently-radius: 0; }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.consently {
|
|
13
|
+
--consently-background: #ffffff;
|
|
14
|
+
--consently-text: #111827;
|
|
15
|
+
--consently-muted: #4b5563;
|
|
16
|
+
--consently-accent: #111827;
|
|
17
|
+
--consently-accent-text: #ffffff;
|
|
18
|
+
--consently-border: rgba(17, 24, 39, 0.1);
|
|
19
|
+
--consently-radius: 0.75rem;
|
|
20
|
+
--consently-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
|
21
|
+
--consently-max-width: 36rem;
|
|
22
|
+
|
|
23
|
+
position: fixed;
|
|
24
|
+
inset-inline: 0;
|
|
25
|
+
bottom: 0;
|
|
26
|
+
z-index: 50;
|
|
27
|
+
padding: 0 1.5rem 1.5rem;
|
|
28
|
+
/* The strip itself must not swallow clicks meant for the page behind it. */
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.consently__card {
|
|
33
|
+
pointer-events: auto;
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
margin-inline-start: auto;
|
|
36
|
+
max-width: var(--consently-max-width);
|
|
37
|
+
padding: 1.5rem;
|
|
38
|
+
border-radius: var(--consently-radius);
|
|
39
|
+
background: var(--consently-background);
|
|
40
|
+
color: var(--consently-text);
|
|
41
|
+
box-shadow: var(--consently-shadow);
|
|
42
|
+
outline: 1px solid var(--consently-border);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.consently__text {
|
|
46
|
+
margin: 0;
|
|
47
|
+
font-size: 0.875rem;
|
|
48
|
+
line-height: 1.5rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.consently__link {
|
|
52
|
+
color: inherit;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Collapsed to a zero-height grid row rather than display:none, which cannot
|
|
57
|
+
be animated. The inner wrapper does the clipping. */
|
|
58
|
+
.consently__panel {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-rows: 0fr;
|
|
61
|
+
transition: grid-template-rows 0.2s ease-out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.consently__panel--open {
|
|
65
|
+
grid-template-rows: 1fr;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.consently__panel-inner {
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.consently__options {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
gap: 0.75rem;
|
|
76
|
+
padding-top: 1rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.consently__option {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: flex-start;
|
|
82
|
+
gap: 0.75rem;
|
|
83
|
+
font-size: 0.875rem;
|
|
84
|
+
line-height: 1.5rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.consently__option input {
|
|
88
|
+
margin: 0.3rem 0 0;
|
|
89
|
+
width: 1rem;
|
|
90
|
+
height: 1rem;
|
|
91
|
+
accent-color: var(--consently-accent);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.consently__option-name {
|
|
95
|
+
display: block;
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.consently__option-description {
|
|
100
|
+
display: block;
|
|
101
|
+
color: var(--consently-muted);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.consently__actions {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-wrap: wrap;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 0.75rem 1.25rem;
|
|
109
|
+
margin-top: 1rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.consently__button {
|
|
113
|
+
font: inherit;
|
|
114
|
+
font-size: 0.875rem;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
line-height: 1.5rem;
|
|
117
|
+
color: var(--consently-text);
|
|
118
|
+
background: none;
|
|
119
|
+
border: 0;
|
|
120
|
+
padding: 0;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.consently__button:hover {
|
|
125
|
+
opacity: 0.75;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.consently__button--primary {
|
|
129
|
+
padding: 0.5rem 0.75rem;
|
|
130
|
+
border-radius: 0.375rem;
|
|
131
|
+
color: var(--consently-accent-text);
|
|
132
|
+
background: var(--consently-accent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.consently__button--quiet {
|
|
136
|
+
color: var(--consently-muted);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.consently__button:focus-visible,
|
|
140
|
+
.consently__link:focus-visible {
|
|
141
|
+
outline: 2px solid var(--consently-accent);
|
|
142
|
+
outline-offset: 2px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.consently-hidden {
|
|
146
|
+
display: none !important;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* The generated cookie policy. Deliberately quiet: it lands inside someone
|
|
150
|
+
else's page and should inherit its typography. */
|
|
151
|
+
.consently-policy {
|
|
152
|
+
--consently-policy-muted: #4b5563;
|
|
153
|
+
--consently-policy-border: rgba(17, 24, 39, 0.1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.consently-policy__category {
|
|
157
|
+
margin-top: 2.5rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.consently-policy__heading {
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: baseline;
|
|
163
|
+
justify-content: space-between;
|
|
164
|
+
gap: 1rem;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.consently-policy__title {
|
|
168
|
+
margin: 0;
|
|
169
|
+
font-size: 1.125rem;
|
|
170
|
+
font-weight: 600;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.consently-policy__status {
|
|
174
|
+
flex: none;
|
|
175
|
+
padding: 0.125rem 0.5rem;
|
|
176
|
+
border-radius: 999px;
|
|
177
|
+
background: rgba(17, 24, 39, 0.06);
|
|
178
|
+
color: var(--consently-policy-muted);
|
|
179
|
+
font-size: 0.75rem;
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.consently-policy__status--granted {
|
|
184
|
+
background: rgba(22, 163, 74, 0.12);
|
|
185
|
+
color: #15803d;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.consently-policy__description,
|
|
189
|
+
.consently-policy__empty {
|
|
190
|
+
margin: 0.25rem 0 0;
|
|
191
|
+
color: var(--consently-policy-muted);
|
|
192
|
+
font-size: 0.875rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.consently-policy__vendor {
|
|
196
|
+
margin-top: 1rem;
|
|
197
|
+
padding-top: 1rem;
|
|
198
|
+
border-top: 1px solid var(--consently-policy-border);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.consently-policy__vendor-name {
|
|
202
|
+
margin: 0;
|
|
203
|
+
font-weight: 600;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.consently-policy__vendor-id {
|
|
207
|
+
margin-inline-start: 0.5rem;
|
|
208
|
+
color: var(--consently-policy-muted);
|
|
209
|
+
font-weight: 400;
|
|
210
|
+
font-size: 0.875rem;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.consently-policy__table {
|
|
214
|
+
width: 100%;
|
|
215
|
+
margin-top: 0.5rem;
|
|
216
|
+
border-collapse: collapse;
|
|
217
|
+
font-size: 0.875rem;
|
|
218
|
+
text-align: start;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.consently-policy__table th {
|
|
222
|
+
padding: 0.25rem 0.75rem 0.25rem 0;
|
|
223
|
+
color: var(--consently-policy-muted);
|
|
224
|
+
font-weight: 500;
|
|
225
|
+
text-align: start;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.consently-policy__table td {
|
|
229
|
+
padding: 0.25rem 0.75rem 0.25rem 0;
|
|
230
|
+
vertical-align: top;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.consently-policy__footer {
|
|
234
|
+
margin-top: 2rem;
|
|
235
|
+
font-size: 0.875rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@media (max-width: 40rem) {
|
|
239
|
+
.consently {
|
|
240
|
+
padding: 0 1rem 1rem;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.consently__card {
|
|
244
|
+
max-width: none;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@media (prefers-reduced-motion: reduce) {
|
|
249
|
+
.consently__panel {
|
|
250
|
+
transition: none;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Consently
|
|
4
|
+
# Records what the visitor agreed to, when, and against which policy
|
|
5
|
+
# version - the proof the GDPR asks for. The cookie remains the source of
|
|
6
|
+
# truth for what runs; this is bookkeeping.
|
|
7
|
+
class ConsentsController < ApplicationController
|
|
8
|
+
def create
|
|
9
|
+
ConsentRecord.create!(
|
|
10
|
+
categories: Array(params[:categories]).map(&:to_s).join(","),
|
|
11
|
+
consent_version: params[:version].to_s,
|
|
12
|
+
scope: Consently.scope_for(request).to_s.presence,
|
|
13
|
+
subject: consent_subject,
|
|
14
|
+
user_agent: request.user_agent.to_s.first(255),
|
|
15
|
+
ip_hash: hashed_ip
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
head :no_content
|
|
19
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
20
|
+
# Almost always the migration from `rails g consently:consent_log` not
|
|
21
|
+
# having been run. The visitor's choice already took effect in their
|
|
22
|
+
# browser, so this stays quiet and only complains in the log.
|
|
23
|
+
Rails.logger.warn("[consently] could not record consent: #{error.message}")
|
|
24
|
+
head :no_content
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Nil unless the host application says who this is - a public site logs
|
|
30
|
+
# decisions, not people.
|
|
31
|
+
def consent_subject
|
|
32
|
+
resolver = Consently.config.consent_subject
|
|
33
|
+
return nil unless resolver
|
|
34
|
+
|
|
35
|
+
resolver.call(request).to_s.presence
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# An IP address identifies a person; a salted digest of one still tells us
|
|
39
|
+
# two records came from the same visitor without storing who they are.
|
|
40
|
+
def hashed_ip
|
|
41
|
+
Digest::SHA256.hexdigest("consently-#{Rails.application.secret_key_base}-#{request.remote_ip}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|