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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +279 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/consently.css +252 -0
  7. data/app/controllers/consently/application_controller.rb +4 -0
  8. data/app/controllers/consently/consents_controller.rb +44 -0
  9. data/app/helpers/consently/application_helper.rb +4 -0
  10. data/app/helpers/consently/tags_helper.rb +176 -0
  11. data/app/javascript/consently/banner_controller.js +190 -0
  12. data/app/models/consently/application_record.rb +5 -0
  13. data/app/models/consently/consent_record.rb +18 -0
  14. data/app/views/consently/_banner.html.erb +93 -0
  15. data/app/views/consently/_policy.html.erb +85 -0
  16. data/config/importmap.rb +3 -0
  17. data/config/locales/cs.yml +33 -0
  18. data/config/locales/de.yml +33 -0
  19. data/config/locales/en.yml +33 -0
  20. data/config/locales/es.yml +33 -0
  21. data/config/locales/fr.yml +33 -0
  22. data/config/locales/hu.yml +33 -0
  23. data/config/locales/it.yml +33 -0
  24. data/config/locales/nl.yml +33 -0
  25. data/config/locales/pl.yml +33 -0
  26. data/config/locales/sk.yml +33 -0
  27. data/config/routes.rb +5 -0
  28. data/lib/consently/configuration.rb +150 -0
  29. data/lib/consently/consent.rb +62 -0
  30. data/lib/consently/cookie.rb +12 -0
  31. data/lib/consently/engine.rb +33 -0
  32. data/lib/consently/providers/base.rb +95 -0
  33. data/lib/consently/providers/clarity.rb +34 -0
  34. data/lib/consently/providers/custom.rb +34 -0
  35. data/lib/consently/providers/google_ads.rb +32 -0
  36. data/lib/consently/providers/google_analytics.rb +45 -0
  37. data/lib/consently/providers/google_tag_manager.rb +34 -0
  38. data/lib/consently/providers/hotjar.rb +39 -0
  39. data/lib/consently/providers/meta_pixel.rb +37 -0
  40. data/lib/consently/providers/plausible.rb +30 -0
  41. data/lib/consently/providers.rb +9 -0
  42. data/lib/consently/script.rb +17 -0
  43. data/lib/consently/version.rb +3 -0
  44. data/lib/consently.rb +74 -0
  45. data/lib/generators/consently/consent_log/consent_log_generator.rb +28 -0
  46. data/lib/generators/consently/consent_log/templates/create_consently_consent_records.rb +19 -0
  47. data/lib/generators/consently/install/install_generator.rb +44 -0
  48. data/lib/generators/consently/install/templates/consently.rb +63 -0
  49. data/lib/generators/consently/views/views_generator.rb +15 -0
  50. data/lib/tasks/consently_tasks.rake +4 -0
  51. 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,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -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,4 @@
1
+ module Consently
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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
@@ -0,0 +1,4 @@
1
+ module Consently
2
+ module ApplicationHelper
3
+ end
4
+ end