Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/settings/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { useConsentStore } from "@/app/state/consent"

// ─────────────────────────────────────────────────────────────
// Types
Expand All @@ -47,7 +48,8 @@ export default function AccountSettingsPage() {
// Privacy controls
const [publicProfile, setPublicProfile] = useState(true)
const [showActivity, setShowActivity] = useState(true)
const [allowAnalytics, setAllowAnalytics] = useState(false)
const analyticsConsent = useConsentStore((s) => s.analyticsConsent)
const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent)
const [showLeaderboard, setShowLeaderboard] = useState(true)

// Save state for live-region announcement
Expand Down Expand Up @@ -288,8 +290,8 @@ export default function AccountSettingsPage() {
id="allow-analytics"
label="Allow usage analytics"
description="Help improve Predictify by sharing anonymised interaction data. No wallet data is included."
checked={allowAnalytics}
onCheckedChange={setAllowAnalytics}
checked={analyticsConsent}
onCheckedChange={setAnalyticsConsent}
/>
</CardContent>
</Card>
Expand Down
36 changes: 33 additions & 3 deletions app/settings/privacy/__tests__/privacy.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import PrivacySettingsPage from "../page"
import { useConsentStore } from "@/app/state/consent"

describe("Settings → Privacy page", () => {
beforeEach(() => {
act(() => {
useConsentStore.setState({ analyticsConsent: false })
})
localStorage.clear()
})

afterEach(() => {
localStorage.clear()
})

it("renders the page heading and description", () => {
render(<PrivacySettingsPage />)

Expand Down Expand Up @@ -114,8 +126,10 @@ describe("Settings → Privacy page", () => {
it("each switch row displays an icon and a badge in the summary card", () => {
render(<PrivacySettingsPage />)

const summaryBadges = screen.getAllByText(/visible|hidden/i)
expect(summaryBadges.length).toBe(5)
// Anchored to the exact badge values so surrounding copy (e.g. "...information is
// visible to other users...") can never inflate the count.
const summaryBadges = screen.getAllByText(/^(visible|hidden)$/i)
expect(summaryBadges).toHaveLength(5)
})

it("all switches have unique ids matching their labels", () => {
Expand All @@ -129,4 +143,20 @@ describe("Settings → Privacy page", () => {
expect(label).toBeInTheDocument()
})
})

it("the analytics toggle is backed by the consent store (single source of truth)", () => {
expect(useConsentStore.getState().analyticsConsent).toBe(false)

render(<PrivacySettingsPage />)

const analytics = screen.getByRole("switch", { name: /allow usage analytics/i })
fireEvent.click(analytics)

expect(useConsentStore.getState().analyticsConsent).toBe(true)
expect(analytics).toBeChecked()

fireEvent.click(analytics)
expect(useConsentStore.getState().analyticsConsent).toBe(false)
expect(analytics).not.toBeChecked()
})
})
10 changes: 6 additions & 4 deletions app/settings/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { useConsentStore } from "@/app/state/consent"

type SaveState = "idle" | "saving" | "saved" | "error"

Expand All @@ -24,7 +25,8 @@ export default function PrivacySettingsPage() {
const [showActivity, setShowActivity] = useState(true)
const [showLeaderboard, setShowLeaderboard] = useState(true)
const [showBalance, setShowBalance] = useState(true)
const [allowAnalytics, setAllowAnalytics] = useState(false)
const analyticsConsent = useConsentStore((s) => s.analyticsConsent)
const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent)

const [saveState, setSaveState] = useState<SaveState>("idle")

Expand Down Expand Up @@ -75,8 +77,8 @@ export default function PrivacySettingsPage() {
icon: Eye,
label: "Allow usage analytics",
description: "Help improve Predictify by sharing anonymised interaction data. No wallet data is included.",
checked: allowAnalytics,
onChange: setAllowAnalytics,
checked: analyticsConsent,
onChange: setAnalyticsConsent,
},
]

Expand Down Expand Up @@ -211,7 +213,7 @@ export default function PrivacySettingsPage() {
setShowActivity(true)
setShowLeaderboard(true)
setShowBalance(true)
setAllowAnalytics(false)
setAnalyticsConsent(false)
}}
>
Reset defaults
Expand Down
127 changes: 127 additions & 0 deletions app/state/__tests__/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* consent.test.ts
*
* Unit tests for the useConsentStore (app/state/consent.ts).
* Covers: default state, explicit opt-in/opt-out, strict boolean coercion,
* persistence, and boundary/invalid inputs.
*/

import { act } from "@testing-library/react"
import {
useConsentStore,
getAnalyticsConsent,
parseConsent,
CONSENT_STORAGE_KEY,
} from "../consent"

/** Reset store to the optimistic default between tests. */
function resetStore() {
act(() => {
useConsentStore.setState({ analyticsConsent: false })
})
}

describe("useConsentStore", () => {
beforeEach(() => {
resetStore()
localStorage.clear()
})

afterEach(() => {
localStorage.clear()
})

// ── Initial state ──────────────────────────────────────────────────────────

it("starts with telemetry consent disabled (optimistic default)", () => {
expect(useConsentStore.getState().analyticsConsent).toBe(false)
expect(getAnalyticsConsent()).toBe(false)
})

// ── setAnalyticsConsent ────────────────────────────────────────────────────

it("setAnalyticsConsent(true) opt-in is persisted", () => {
act(() => useConsentStore.getState().setAnalyticsConsent(true))
expect(useConsentStore.getState().analyticsConsent).toBe(true)
expect(getAnalyticsConsent()).toBe(true)
})

it("setAnalyticsConsent(false) opt-out is respected", () => {
act(() => useConsentStore.getState().setAnalyticsConsent(true))
act(() => useConsentStore.getState().setAnalyticsConsent(false))
expect(useConsentStore.getState().analyticsConsent).toBe(false)
})

it("coerces non-boolean opt-in values to false (no telemetry without explicit consent)", () => {
// @ts-expect-error – intentionally passing invalid runtime input
act(() => useConsentStore.getState().setAnalyticsConsent("yes"))
expect(useConsentStore.getState().analyticsConsent).toBe(false)

// @ts-expect-error – intentionally passing invalid runtime input
act(() => useConsentStore.getState().setAnalyticsConsent(1))
expect(useConsentStore.getState().analyticsConsent).toBe(false)

// @ts-expect-error – intentionally passing invalid runtime input
act(() => useConsentStore.getState().setAnalyticsConsent(null))
expect(useConsentStore.getState().analyticsConsent).toBe(false)
})

// ── toggleAnalyticsConsent ─────────────────────────────────────────────────

it("toggleAnalyticsConsent flips state and returns the new value", () => {
let result1: boolean
act(() => {
result1 = useConsentStore.getState().toggleAnalyticsConsent()
})
expect(result1!).toBe(true)
expect(useConsentStore.getState().analyticsConsent).toBe(true)

let result2: boolean
act(() => {
result2 = useConsentStore.getState().toggleAnalyticsConsent()
})
expect(result2!).toBe(false)
expect(useConsentStore.getState().analyticsConsent).toBe(false)
})

// ── persistence ────────────────────────────────────────────────────────────

it("persists opt-in consent to localStorage under the consent key", () => {
act(() => useConsentStore.getState().setAnalyticsConsent(true))

const stored = localStorage.getItem(CONSENT_STORAGE_KEY)
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed.state.analyticsConsent).toBe(true)
})

it("persists opt-out so telemetry stays disabled across reloads", () => {
act(() => useConsentStore.getState().setAnalyticsConsent(true))
act(() => useConsentStore.getState().setAnalyticsConsent(false))

const stored = localStorage.getItem(CONSENT_STORAGE_KEY)
const parsed = JSON.parse(stored!)
expect(parsed.state.analyticsConsent).toBe(false)
})

// ── parseConsent boundary inputs ───────────────────────────────────────────

it("parseConsent accepts only the literal boolean true", () => {
expect(parseConsent(true)).toBe(true)
expect(parseConsent(false)).toBe(false)
})

it("parseConsent rejects every coercion-flavoured value", () => {
expect(parseConsent("true")).toBe(false)
expect(parseConsent("1")).toBe(false)
expect(parseConsent(1)).toBe(false)
expect(parseConsent(0)).toBe(false)
expect(parseConsent("")).toBe(false)
expect(parseConsent(null)).toBe(false)
expect(parseConsent(undefined)).toBe(false)
expect(parseConsent([])).toBe(false)
expect(parseConsent({})).toBe(false)
expect(parseConsent("TRUE")).toBe(false)
expect(parseConsent(1 as unknown)).toBe(false)
})
})
99 changes: 99 additions & 0 deletions app/state/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* consent.ts
*
* Client-side store for the user's usage-analytics consent preference.
* Gated in optimistic-default style: consent is `false` until the user
* explicitly opts in from the privacy settings page. All recommendation
* telemetry is suppressed while consent is `false`.
*
* Invariants
* ----------
* - `analyticsConsent` is a strict boolean. Any invalid value written to
* localStorage (e.g. a corrupted payload) is coerced via `parseConsent`
* so it can never put the store into an inconsistent state.
* - Consent defaults to `false` ("no telemetry") and can only become `true`
* through an explicit user action (`setAnalyticsConsent`).
* - Reads and writes are failure-tolerant: a blocked or throwing
* localStorage never crashes the app — telemetry simply stays disabled.
*/

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

export const CONSENT_STORAGE_KEY = "predictify-consent";

interface ConsentState {
/** Whether the user has opted in to usage analytics. */
analyticsConsent: boolean;
/** Set the consent value. Call from an explicit opt-in/opt-out UI action. */
setAnalyticsConsent: (consent: boolean) => void;
/** Toggle consent; returns the new value. */
toggleAnalyticsConsent: () => boolean;
}

/**
* Coerce an unknown persisted value into a strict boolean. Anything that is
* not exactly `true` maps to `false` so the optimistic-default invariant
* always holds, even against corrupted or malicious localStorage payloads.
*/
export function parseConsent(value: unknown): boolean {
return value === true;
}

/**
* A no-op storage used on the server (or when localStorage is unavailable)
* so persistence never throws during SSR or privacy-restricted sessions.
*/
const noopStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};

function createSafeStorage(): ReturnType<typeof createJSONStorage> {
if (typeof window === "undefined") {
return createJSONStorage(() => noopStorage);
}
try {
return createJSONStorage(() => window.localStorage);
} catch {
return createJSONStorage(() => noopStorage);
}
}

export const useConsentStore = create<ConsentState>()(
persist(
(set, get) => ({
analyticsConsent: false,

setAnalyticsConsent: (consent) =>
set({ analyticsConsent: parseConsent(consent) }),

toggleAnalyticsConsent: () => {
const next = !get().analyticsConsent;
set({ analyticsConsent: next });
return next;
},
}),
{
name: CONSENT_STORAGE_KEY,
storage: createSafeStorage(),
merge: (persisted, current) => ({
...current,
...(persisted as Partial<ConsentState>),
// Enforce the strict-boolean invariant on any persisted payload so a
// stale or corrupted value can never turn telemetry on without an
// explicit opt-in.
analyticsConsent: parseConsent(
(persisted as Partial<ConsentState> | undefined)?.analyticsConsent
),
}),
partialize: (state) => ({ analyticsConsent: state.analyticsConsent }),
}
)
);

/** Non-hook accessor for reads outside React (e.g. before render). */
export function getAnalyticsConsent(): boolean {
return parseConsent(useConsentStore.getState().analyticsConsent);
}
Loading
Loading