-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add GDPR cookie consent #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import React, { useEffect, useState } from "react"; | ||
|
|
||
| const STORAGE_KEY = "allora-cookie-consent"; | ||
|
|
||
| function CookieConsent() { | ||
| const [visible, setVisible] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| if (typeof window === "undefined") return; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] Redundant
Suggested fix: useEffect(() => {
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
setVisible(true);
}
} catch (e) {
setVisible(true);
}
}, []);Why: Removes a no-op check and tightens the effect body. |
||
| try { | ||
| if (!window.localStorage.getItem(STORAGE_KEY)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [high] Consent banner is write-only — nothing reads the stored preference 4 review dimensions independently flagged this. A repo-wide grep for This means the stored Suggested resolution: Either delete this PR (the site has nothing that needs consent), or land it together with (a) the analytics/tracking integration that the banner is supposed to govern, and (b) gating code that actually checks the stored value before initialising those scripts. Recommend keeping the work on a branch until the second half is ready. Why: A consent banner with no enforcement is theatrical compliance — under GDPR scrutiny it can be worse than no banner. [medium] Existence-only check passes any truthy value Separately, |
||
| setVisible(true); | ||
| } | ||
| } catch (e) { | ||
| setVisible(true); | ||
| } | ||
| }, []); | ||
|
|
||
| const respond = (choice) => { | ||
| try { | ||
| window.localStorage.setItem(STORAGE_KEY, choice); | ||
| } catch (e) {} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] Silent 4 dimensions agreed on this path: when The consent record is also lost — the user thinks they declined but the preference is gone. Suggested fix: Only hide the banner if persistence succeeded; otherwise fall back to an in-memory flag so at least the current session is quiet: let sessionConsent = null; // module scope, outside the component
const respond = (choice) => {
let persisted = false;
try {
window.localStorage.setItem(STORAGE_KEY, choice);
persisted = true;
} catch (e) {
// Storage blocked (private mode, quota, ITP). Track for this session only.
sessionConsent = choice;
// Optional: surface a one-time message that the choice could not be saved.
}
setVisible(false);
};And in the read effect, also consult Why: Eliminates the private-mode infinite-banner regression while keeping the silent-failure case observable internally. [low] Empty Even if the silent failure stays, the unused binding can use the bare form [low] The only callers pass const respond = (choice) => {
if (choice !== "accepted" && choice !== "declined") return;
// ...
}; |
||
| setVisible(false); | ||
| }; | ||
|
|
||
| if (!visible) return null; | ||
|
|
||
| return ( | ||
| <div | ||
| role="dialog" | ||
| aria-live="polite" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] Dialog ARIA contract is incomplete and self-contradictory Three issues on this element:
Suggested fix: <div
role="dialog"
aria-modal="false"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-desc"
// drop aria-live entirely
style={...}
>
<span id="cookie-consent-title" style={visuallyHidden}>Cookie consent</span>
<span id="cookie-consent-desc" style={{ flex: "1 1 240px" }}>...</span>
...
</div>Add a Why: Brings the dialog up to ARIA spec, makes the banner discoverable to keyboard / screen-reader users, and resolves the role-vs-live-region conflict. |
||
| aria-label="Cookie consent" | ||
| style={{ | ||
| position: "fixed", | ||
| left: "20px", | ||
| right: "20px", | ||
| bottom: "20px", | ||
| maxWidth: "520px", | ||
| margin: "0 auto", | ||
| padding: "16px 20px", | ||
| background: "#1f1f1f", | ||
| color: "#fff", | ||
| borderRadius: "8px", | ||
| boxShadow: "0 4px 16px rgba(0,0,0,0.25)", | ||
| zIndex: 1001, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] z-index 1001 over AiButton (1000) makes AiButton click target unreachable on mobile The banner is On viewports ≥ 560px the banner is centered ( Suggested fix: Move the banner above the AiButton vertically rather than overlapping (e.g. Why: Two stacked fixed-position UI elements in the same corner is a known interaction trap — the higher-z one blocks the lower one's click target. |
||
| fontSize: "14px", | ||
| lineHeight: 1.4, | ||
| display: "flex", | ||
| flexWrap: "wrap", | ||
| gap: "12px", | ||
| alignItems: "center", | ||
| justifyContent: "space-between", | ||
| }} | ||
| > | ||
| <span style={{ flex: "1 1 240px" }}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [high] Banner says "We use cookies" but the site sets no cookies The copy declares cookie usage and asks the user to accept or decline "non-essential cookies." The PR adds no cookies — it adds This creates two distinct problems:
Suggested fix: If the long-term plan is to add analytics that DO require consent, change the copy to be accurate about the future state ("This site may use analytics to help us understand how the docs are used.") and ship the banner together with the actual analytics integration. If the long-term plan is NO analytics, remove the banner entirely. Why: A consent banner that lies about what it consents to is itself a GDPR compliance issue, not a fix for one. |
||
| We use cookies to improve your experience. You can accept or decline | ||
| non-essential cookies. | ||
| </span> | ||
| <div style={{ display: "flex", gap: "8px" }}> | ||
| <button | ||
| onClick={() => respond("declined")} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] Both buttons (Decline at line 61, Accept at line 75) omit the Suggested fix: <button type="button" onClick={() => respond("declined")} ...>Decline</button>
<button type="button" onClick={() => respond("accepted")} ...>Accept</button>Why: One-line defensive fix; eliminates a fragility that will never trigger as the code stands today but is a recognised React best practice. |
||
| style={{ | ||
| padding: "8px 14px", | ||
| fontSize: "14px", | ||
| cursor: "pointer", | ||
| background: "transparent", | ||
| color: "#fff", | ||
| border: "1px solid #888", | ||
| borderRadius: "4px", | ||
| }} | ||
| > | ||
| Decline | ||
| </button> | ||
| <button | ||
| onClick={() => respond("accepted")} | ||
| style={{ | ||
| padding: "8px 14px", | ||
| fontSize: "14px", | ||
| cursor: "pointer", | ||
| background: "#22c55e", | ||
| color: "#000", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [high] Asymmetric Accept/Decline styling is a recognised GDPR dark pattern The Decline button (line 60-72) is a transparent ghost button with a gray border; the Accept button (line 74-86) is a bright green ( Suggested fix: Give both buttons identical visual weight — same background treatment (both filled or both ghost), same font weight, same border treatment. If you must visually distinguish, the safer pattern is to make the Decline button the primary one (since under GDPR Decline is the default-safe choice). // Both buttons styled identically; user is not nudged either direction.
const buttonStyle = {
padding: "8px 14px",
fontSize: "14px",
cursor: "pointer",
background: "transparent",
color: "#fff",
border: "1px solid #888",
borderRadius: "4px",
fontWeight: 500,
};Why: Symmetric styling is a documented prerequisite for GDPR-valid consent. The bright-green Accept / gray-ghost Decline pairing is the canonical example of a non-compliant dark pattern. [medium] Hardcoded Separately, |
||
| border: "none", | ||
| borderRadius: "4px", | ||
| fontWeight: 600, | ||
| }} | ||
| > | ||
| Accept | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default CookieConsent; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[medium] No schema version on the stored consent value
The stored value is a bare string (
"accepted"/"declined"). Any future change — adding granular categories (analytics/functional/marketing), moving to IAB TCF v2, distinguishing per-region — will need to migrate or invalidate existing entries, and the existence-only check at line 11 (!getItem(STORAGE_KEY)) means there's no way to detect a legacy entry and re-prompt.Suggested fix: Store an object with a version tag, e.g.:
Why: Forward-compatibility — when this banner inevitably evolves, the schema check lets the new version re-prompt users with the old entry rather than silently treating it as valid.