From 937ded891c0be7d1731d69da04e87d823f0d3e18 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Mon, 27 Jul 2026 13:54:36 -0400 Subject: [PATCH 1/8] =?UTF-8?q?WIP(consent):=20PR=202=20of=20#503=20?= =?UTF-8?q?=E2=80=94=20onboarding=20gate=20+=20account=20privacy=20(half-d?= =?UTF-8?q?one=20snapshot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOT ready to merge. Half-done work parked to finish Mon 2026-07-27. Includes: - onboarding consent gate (no Skip; visibility derived from hasSetConsent) - shared useConsent save hook (gate + account page, one code path) - standalone account & privacy page wiring + navbar Account entry - cross-tab consent sync (consentSync.ts + provider listener) - device-auth tweaks Open review items — recorded, deliberately NOT addressed yet: - in-flight consent choice bug still unfixed - reconsider whole cross-tab sync vs. dropping target="_blank" on the Account link (navigate in-context instead) — reviewer's simpler interim - broadcastConsentChange: `storage = localStorage` default arg evaluates before the try, so catch doesn't cover a throwing global - LIVE-verify Better Auth returns consentUpdatedAt on get-session (merge prereq) Still to do Mon: finish code review, e2e + browser QA (two-tab sync, get-session network check, cross-user gate lifecycle). Co-Authored-By: Claude Opus 4.8 # Conflicts: # backend/.gitignore # frontend/src/components/navbar/index.tsx # frontend/src/pages/app/index.tsx --- frontend/src/__tests__/consentSync.test.ts | 44 +++++++++++++++ frontend/src/account/AccountPage.tsx | 29 +++------- frontend/src/api/__tests__/deviceAuth.test.ts | 4 ++ frontend/src/api/deviceAuth.ts | 13 +++++ frontend/src/components/navbar/index.tsx | 1 - frontend/src/consentSync.ts | 48 ++++++++++++++++ frontend/src/contexts/appAuthContext.tsx | 35 ++++++++++++ frontend/src/hooks/useConsent.ts | 55 +++++++++++++++++++ frontend/src/hooks/useDeviceAuth.ts | 24 +++++++- .../src/pages/app/OnboardingConsentGate.tsx | 46 ++++++++++++++++ frontend/src/pages/app/index.tsx | 10 ++++ 11 files changed, 287 insertions(+), 22 deletions(-) create mode 100644 frontend/src/__tests__/consentSync.test.ts create mode 100644 frontend/src/consentSync.ts create mode 100644 frontend/src/hooks/useConsent.ts create mode 100644 frontend/src/pages/app/OnboardingConsentGate.tsx diff --git a/frontend/src/__tests__/consentSync.test.ts b/frontend/src/__tests__/consentSync.test.ts new file mode 100644 index 00000000..5f363732 --- /dev/null +++ b/frontend/src/__tests__/consentSync.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CONSENT_CHANGED_KEY, + broadcastConsentChange, + onConsentChangeFromOtherTab, +} from '../consentSync'; + +// The cross-tab consent sync is the fix for the leak where a consent change on the +// standalone account page (a separate tab) never reached the still-open app tab, so +// that tab kept logging at the pre-change level. These cover the wiring both ends +// depend on: a save broadcasts, and another tab's listener refreshes only for our key. +describe('consentSync', () => { + it('broadcast writes the change key so other tabs get a storage event', () => { + const setItem = vi.fn(); + broadcastConsentChange({ setItem }); + expect(setItem).toHaveBeenCalledWith( + CONSENT_CHANGED_KEY, + expect.any(String), + ); + }); + + it('fires the handler only for the consent key, and stops after unsubscribe', () => { + const target = new EventTarget(); + const handler = vi.fn(); + const unsubscribe = onConsentChangeFromOtherTab(handler, target); + + // An unrelated storage change (some other key) must not trigger a refresh. + const other = new Event('storage') as Event & { key: string }; + other.key = 'unrelated-key'; + target.dispatchEvent(other); + expect(handler).not.toHaveBeenCalled(); + + // A consent broadcast triggers exactly one refresh. + const consent = new Event('storage') as Event & { key: string }; + consent.key = CONSENT_CHANGED_KEY; + target.dispatchEvent(consent); + expect(handler).toHaveBeenCalledTimes(1); + + // After unsubscribing, no further refreshes. + unsubscribe(); + target.dispatchEvent(consent); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/account/AccountPage.tsx b/frontend/src/account/AccountPage.tsx index 6c464570..90dce82d 100644 --- a/frontend/src/account/AccountPage.tsx +++ b/frontend/src/account/AccountPage.tsx @@ -14,15 +14,14 @@ */ import { useState } from 'react'; import { Button } from 'reshaped'; -import { changeConsent, deleteAccount, eraseActivity } from '@/api/account'; +import { deleteAccount, eraseActivity } from '@/api/account'; import { clearToken, loadToken } from '@/api/authTokenStore'; import { ConsentLevelChooser } from '@/components/ConsentLevelChooser'; -import type { ConsentLevel } from '@/consent'; import { useAppAuth } from '@/contexts/appAuthContext'; +import { useConsent } from '@/hooks/useConsent'; import { DeviceCodePanel } from './DeviceCodePanel'; import classes from './styles.module.css'; -type ConsentStatus = 'idle' | 'saving' | 'saved' | 'error'; type EraseStatus = 'idle' | 'confirm' | 'working' | 'done' | 'error'; type DeleteStatus = | 'idle' @@ -35,8 +34,13 @@ type DeleteStatus = export function AccountPage() { const session = useAppAuth(); - const [level, setLevel] = useState(session.loggingConsent); - const [consentStatus, setConsentStatus] = useState('idle'); + // Consent state + persistence shared with the onboarding gate. onChange saves + // immediately here (each pick persists); the gate stages and commits later. + const { + level, + status: consentStatus, + save: onChangeLevel, + } = useConsent(session.loggingConsent); const [eraseStatus, setEraseStatus] = useState('idle'); const [eraseError, setEraseError] = useState(null); @@ -44,21 +48,6 @@ export function AccountPage() { const [deleteStatus, setDeleteStatus] = useState('idle'); const [deleteError, setDeleteError] = useState(null); - // --- change logging level ------------------------------------------------- - const onChangeLevel = async (next: ConsentLevel) => { - const previous = level; - setLevel(next); // optimistic - setConsentStatus('saving'); - try { - const token = await session.getAccessToken(); - await changeConsent(token, next); - setConsentStatus('saved'); - } catch { - setLevel(previous); // roll back on failure - setConsentStatus('error'); - } - }; - // --- erase activity (withdrawal) ------------------------------------------ const onErase = async () => { setEraseStatus('working'); diff --git a/frontend/src/api/__tests__/deviceAuth.test.ts b/frontend/src/api/__tests__/deviceAuth.test.ts index 2da30dbe..22c103d5 100644 --- a/frontend/src/api/__tests__/deviceAuth.test.ts +++ b/frontend/src/api/__tests__/deviceAuth.test.ts @@ -157,6 +157,7 @@ describe('fetchUserInfo', () => { loggingConsent: 'document', isAllowed: true, isAnonymous: true, + consentUpdatedAt: '2026-07-24T00:00:00.000Z', }, }, true, @@ -169,6 +170,7 @@ describe('fetchUserInfo', () => { loggingConsent: 'document', isAllowed: true, isAnonymous: true, + consentUpdatedAt: '2026-07-24T00:00:00.000Z', }); // hits the framework endpoint on the token-only path, no cookies expect(fetchMock).toHaveBeenCalledWith( @@ -203,6 +205,8 @@ describe('fetchUserInfo', () => { isAllowed: false, // Same fail-closed mapping for the anonymous marker. isAnonymous: false, + // Omitted (or non-string) consentUpdatedAt normalizes to null = "never set". + consentUpdatedAt: null, }); }); diff --git a/frontend/src/api/deviceAuth.ts b/frontend/src/api/deviceAuth.ts index 37c0e46d..b0710f2f 100644 --- a/frontend/src/api/deviceAuth.ts +++ b/frontend/src/api/deviceAuth.ts @@ -184,6 +184,13 @@ export interface UserInfo { * rather than letting them manage consent on a throwaway identity. */ isAnonymous?: boolean; + /** + * When the user last set their consent level (ISO string over JSON), or null + * if they never have. `null` is the first-run signal the onboarding consent + * gate keys on; later it doubles as a "last changed" value on the account + * surface. Set server-side on consent change and on anonymous create. + */ + consentUpdatedAt?: string | null; } /** @@ -199,6 +206,7 @@ interface GetSessionResponse { loggingConsent?: unknown; isAllowed?: unknown; isAnonymous?: unknown; + consentUpdatedAt?: unknown; }; } @@ -237,6 +245,7 @@ export async function fetchUserInfo( loggingConsent: raw, isAllowed, isAnonymous, + consentUpdatedAt, } = data.user; return { id, @@ -247,6 +256,10 @@ export async function fetchUserInfo( loggingConsent: isConsentLevel(raw) ? raw : DEFAULT_CONSENT_LEVEL, isAllowed: isAllowed === true, isAnonymous: isAnonymous === true, + // A Date server-side; JSON gives an ISO string. Normalize anything + // falsy/non-string to null so `null` cleanly means "never set". + consentUpdatedAt: + typeof consentUpdatedAt === 'string' ? consentUpdatedAt : null, }; } diff --git a/frontend/src/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx index 8c5a87b2..12e4c5cd 100644 --- a/frontend/src/components/navbar/index.tsx +++ b/frontend/src/components/navbar/index.tsx @@ -72,7 +72,6 @@ export default function Navbar() { {pageDef.hint} ))} - {labPages.length > 0 ? (
; +type EventTargetLike = Pick< + EventTarget, + 'addEventListener' | 'removeEventListener' +>; + +/** Poke other tabs to re-fetch the user after this tab commits a consent change. */ +export function broadcastConsentChange( + storage: StorageLike = localStorage, +): void { + try { + // The value must change each call so the `storage` event actually fires. + storage.setItem(CONSENT_CHANGED_KEY, String(Date.now())); + } catch { + // localStorage unavailable (private mode / quota). Cross-tab sync is a + // best-effort enhancement; the writing tab already refreshed directly. + } +} + +/** + * Subscribe to consent changes broadcast by other tabs. Returns an unsubscribe fn. + */ +export function onConsentChangeFromOtherTab( + handler: () => void, + target: EventTargetLike = window, +): () => void { + const listener = (event: Event) => { + if ((event as StorageEvent).key === CONSENT_CHANGED_KEY) handler(); + }; + target.addEventListener('storage', listener); + return () => target.removeEventListener('storage', listener); +} diff --git a/frontend/src/contexts/appAuthContext.tsx b/frontend/src/contexts/appAuthContext.tsx index 9ab9f75d..2bc60d0f 100644 --- a/frontend/src/contexts/appAuthContext.tsx +++ b/frontend/src/contexts/appAuthContext.tsx @@ -23,6 +23,7 @@ import { } from 'react'; import { setOpenAITokenProvider } from '@/api/openai'; import { type ConsentLevel, DEFAULT_CONSENT_LEVEL } from '@/consent'; +import { onConsentChangeFromOtherTab } from '@/consentSync'; import { AccessTokenProvider } from '@/contexts/authTokenContext'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import { useAnonymousAuth } from '@/hooks/useAnonymousAuth'; @@ -42,9 +43,18 @@ export interface AppAuthSession { isAllowed?: boolean; /** Demo/anonymous session — account surfaces steer these to real sign-in. */ isAnonymous?: boolean; + /** When consent was last set (ISO string), or null if never. */ + consentUpdatedAt?: string | null; }; /** Resolved consent level (defaults applied) — gate analytics/logging on this. */ loggingConsent: ConsentLevel; + /** + * Whether the user has ever set a consent level. False until they do (a fresh + * real user has consentUpdatedAt === null); the onboarding consent gate shows + * once while this is false. Always true for demo/anonymous (consent set on + * create) and never blocks no-auth modes. + */ + hasSetConsent: boolean; error?: Error; // provider-level error authorization?: { status: 'pending' | 'polling' | 'error'; @@ -63,6 +73,12 @@ export interface AppAuthSession { * `isAuthorizing` so the user has a clean way out. */ cancelLogin: () => void; + /** + * Re-fetch the signed-in user without a re-login, so a server-side change to + * the user record (consent now; usage/key on the future account surface) + * reflects in `user`/`hasSetConsent` live. No-op for the demo provider. + */ + refreshUser: () => Promise; logout: () => Promise; } @@ -72,6 +88,7 @@ const DEFAULT_SESSION: AppAuthSession = { isAuthorizing: false, isAuthenticated: false, loggingConsent: DEFAULT_CONSENT_LEVEL, + hasSetConsent: false, getAccessToken: () => { console.warn( 'getAccessToken called before AppAuthProvider initialized', @@ -80,6 +97,7 @@ const DEFAULT_SESSION: AppAuthSession = { }, login: () => Promise.resolve(), cancelLogin: () => {}, + refreshUser: () => Promise.resolve(), logout: () => Promise.resolve(), }; @@ -91,6 +109,16 @@ export const useAppAuth = (): AppAuthSession => useContext(AppAuthContext); function BetterAuthProvider({ children }: { children: ReactNode }) { const device = useDeviceAuth(); + const { refreshUser } = device; + + // Cross-tab consent propagation. A consent change on the standalone account page + // (a separate tab) would otherwise leave this tab's session — and thus the PostHog + // bridge and useLog — logging at the pre-change level. Refresh when another tab + // broadcasts a change. refreshUser no-ops when there's no active session. + useEffect( + () => onConsentChangeFromOtherTab(() => void refreshUser()), + [refreshUser], + ); const session = useMemo(() => { const isAuthorizing = @@ -122,6 +150,8 @@ function BetterAuthProvider({ children }: { children: ReactNode }) { user: device.user, loggingConsent: device.user?.loggingConsent ?? DEFAULT_CONSENT_LEVEL, + // null (or an unauthenticated session) → false → the consent gate shows. + hasSetConsent: !!device.user?.consentUpdatedAt, authorization, getAccessToken: () => { if (device.token) return Promise.resolve(device.token); @@ -135,6 +165,7 @@ function BetterAuthProvider({ children }: { children: ReactNode }) { }, login: device.start, cancelLogin: device.reset, + refreshUser: device.refreshUser, logout: device.logout, }; }, [device]); @@ -165,6 +196,9 @@ function DemoAuthProvider({ children }: { children: ReactNode }) { // once the session loads. The fallback only applies during the brief // pre-load window, where nothing is logged (isAuthenticated is false). loggingConsent: anon.user?.loggingConsent ?? DEFAULT_CONSENT_LEVEL, + // Anonymous users are created at full consent server-side, so they've + // effectively "set" it — never prompt the demo with the consent gate. + hasSetConsent: true, error: anon.status === 'error' ? new Error(anon.error ?? 'Demo sign-in failed') @@ -172,6 +206,7 @@ function DemoAuthProvider({ children }: { children: ReactNode }) { getAccessToken: anon.getAccessToken, login: () => Promise.resolve(), cancelLogin: () => {}, + refreshUser: () => Promise.resolve(), logout: () => Promise.resolve(), }), [anon], diff --git a/frontend/src/hooks/useConsent.ts b/frontend/src/hooks/useConsent.ts new file mode 100644 index 00000000..63937a4e --- /dev/null +++ b/frontend/src/hooks/useConsent.ts @@ -0,0 +1,55 @@ +/** + * Shared consent-change logic for both the onboarding consent gate and the + * standalone account page, so they run one code path. + * + * `level` is optimistic local state. `save` POSTs the change, then refreshes the + * session so `hasSetConsent`/`consentUpdatedAt` update live (a fresh real user's + * first save is what dismisses the onboarding gate), and rolls back on failure. + * `setLevel` lets a caller stage a choice locally and commit it later: the + * onboarding gate picks first and commits on Continue, while the account page + * wires `onChange` straight to `save` for immediate persistence. + */ +import { useCallback, useState } from 'react'; +import { changeConsent } from '@/api/account'; +import type { ConsentLevel } from '@/consent'; +import { broadcastConsentChange } from '@/consentSync'; +import { useAppAuth } from '@/contexts/appAuthContext'; + +export type ConsentStatus = 'idle' | 'saving' | 'saved' | 'error'; + +export function useConsent(initial: ConsentLevel) { + const session = useAppAuth(); + const [level, setLevel] = useState(initial); + const [status, setStatus] = useState('idle'); + + // Returns whether the save succeeded, so a caller that gates on it (the + // onboarding "Continue") only proceeds on success and keeps the gate up on + // failure. A void-returning onChange (the account page) can ignore the result. + const save = useCallback( + async (next: ConsentLevel): Promise => { + const previous = level; + setLevel(next); // optimistic + setStatus('saving'); + try { + const token = await session.getAccessToken(); + await changeConsent(token, next); + // Propagate consentUpdatedAt/hasSetConsent into the session without a + // re-login (non-critical: a failed refresh leaves the last-known user). + await session.refreshUser(); + // Tell other same-origin tabs (e.g. the app tab, when this save happens + // on the standalone account page) to refresh, so none keeps logging at a + // level the user just lowered. + broadcastConsentChange(); + setStatus('saved'); + return true; + } catch { + setLevel(previous); // roll back on failure + setStatus('error'); + return false; + } + }, + [level, session], + ); + + return { level, setLevel, status, save }; +} diff --git a/frontend/src/hooks/useDeviceAuth.ts b/frontend/src/hooks/useDeviceAuth.ts index 71e3f043..f00c114c 100644 --- a/frontend/src/hooks/useDeviceAuth.ts +++ b/frontend/src/hooks/useDeviceAuth.ts @@ -43,6 +43,14 @@ export interface UseDeviceAuth extends DeviceAuthState { start: () => Promise; /** Cancel any in-flight flow and return to idle, clearing the token. */ reset: () => void; + /** + * Re-fetch the signed-in user with the current token and update state.user, + * without a re-login. Call after a server-side change to the user record + * (e.g. consent) so derived values reflect it live. A failed refresh is a + * no-op (keeps the last-known user); stale-token handling stays with + * hydrate-on-mount and the 401 path on real API calls, not this refresh. + */ + refreshUser: () => Promise; /** Best-effort server sign-out, then clear local state. */ logout: () => Promise; } @@ -75,6 +83,20 @@ export function useDeviceAuth(): UseDeviceAuth { setState(INITIAL); }, [abortInFlight]); + // Re-pull the user with the current token; leave state untouched on failure or + // if there's no token / no active success state. Only patches the user of an + // existing success state, so it can't resurrect a signed-out session. + const refreshUser = useCallback(async () => { + const token = tokenRef.current; + if (!token) return; + try { + const user = await fetchUserInfo(token); + setState((s) => (s.status === 'success' ? { ...s, user } : s)); + } catch { + // Non-critical: keep the last-known user rather than disrupting the UI. + } + }, []); + const start = useCallback(async () => { // Abort any prior attempt and open a fresh controller. abortInFlight(); @@ -206,5 +228,5 @@ export function useDeviceAuth(): UseDeviceAuth { }; }, [safeSet]); - return { ...state, start, reset, logout }; + return { ...state, start, reset, refreshUser, logout }; } diff --git a/frontend/src/pages/app/OnboardingConsentGate.tsx b/frontend/src/pages/app/OnboardingConsentGate.tsx new file mode 100644 index 00000000..e9e8942a --- /dev/null +++ b/frontend/src/pages/app/OnboardingConsentGate.tsx @@ -0,0 +1,46 @@ +/** + * First-run consent gate, shown right after sign-in while the user has never set a + * consent level (session.hasSetConsent === false). Reuses the shared + * ConsentLevelChooser + useConsent so it stays in lockstep with the account page. + * + * An affirmative choice is REQUIRED: Continue is the only way out, and it commits + * the current selection (confirming the default counts as a choice), stamping + * consentUpdatedAt. That refreshes the session's hasSetConsent, which unmounts this + * gate — so on success the app renders, and on failure the gate stays up with its + * error rather than proceeding with consent unset. There is deliberately no Skip — + * the app (and thus any logging) doesn't render until a level is chosen, so + * logging never precedes affirmative consent. A Skip that dismissed the gate while + * leaving the 'usage' default active was considered and rejected for that reason. + */ +import { Button } from 'reshaped'; +import { ConsentLevelChooser } from '@/components/ConsentLevelChooser'; +import { useAppAuth } from '@/contexts/appAuthContext'; +import { useConsent } from '@/hooks/useConsent'; +import classes from './styles.module.css'; + +export function OnboardingConsentGate() { + const session = useAppAuth(); + const { level, setLevel, status, save } = useConsent(session.loggingConsent); + + return ( +
+

Choose your privacy level

+

+ How much this app records about your usage. You can change this + anytime from Account & privacy. +

+ + {status === 'error' ? ( +

Could not save your choice. Please try again.

+ ) : null} + +
+ ); +} diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index e2321c0d..47bdce1c 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -23,6 +23,7 @@ import { } from '@/contexts/pageContext'; import { OnboardingCarousel } from '../carousel/OnboardingCarousel'; import { resolvePage } from '../registry'; +import { OnboardingConsentGate } from './OnboardingConsentGate'; import classes from './styles.module.css'; import Navbar from '@/components/navbar'; import { Reshaped, Button } from 'reshaped'; @@ -346,6 +347,15 @@ function AppInner() { ); } + // First-run consent gate: right after sign-in (and the allowlist check), while + // the user has never set a consent level. Derived straight from the session — a + // successful save updates hasSetConsent directly, so there is no dismissal state + // to leak across a logout → different-user login. Demo/no-auth modes are never + // gated. + if (!noAuthMode && !session.hasSetConsent) { + return ; + } + // Which page renders is the registry's call, not a switch here — see // pages/registry.tsx. resolvePage also handles a stored selection that is no // longer visible (a lab page whose flag was turned off) by falling back to the From 00602b64193fecd88c0df8680974a86788908539 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Mon, 27 Jul 2026 13:45:53 -0400 Subject: [PATCH 2/8] fix(consent): freeze chooser mid-save and keep cross-tab broadcast best-effort Two failure paths found reviewing the onboarding gate: - The gate's chooser stayed live while the save was in flight. Continue commits the level as it read at click time, so a pick made mid-request rendered as selected while the server recorded the earlier one -- and on success the gate unmounts, hiding the divergence. Disable it while saving, matching the account page. - broadcastConsentChange resolved localStorage in a default parameter, which is evaluated outside the function's try. Merely touching localStorage throws in a partitioned context (Office task-pane iframe, Safari private mode), and that throw escaped into useConsent.save's catch -- rolling the level back and reporting failure after the POST had already succeeded. Resolve storage inside the try so the broadcast stays best-effort. Two regression tests cover the storage cases: a throwing setItem and a throwing localStorage getter. Co-Authored-By: Claude --- frontend/src/__tests__/consentSync.test.ts | 36 +++++++++++++++++++ frontend/src/consentSync.ts | 24 +++++++++---- .../src/pages/app/OnboardingConsentGate.tsx | 10 +++++- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/consentSync.test.ts b/frontend/src/__tests__/consentSync.test.ts index 5f363732..4f7a6679 100644 --- a/frontend/src/__tests__/consentSync.test.ts +++ b/frontend/src/__tests__/consentSync.test.ts @@ -19,6 +19,42 @@ describe('consentSync', () => { ); }); + it('swallows a storage write that throws', () => { + const setItem = vi.fn(() => { + throw new Error('quota exceeded'); + }); + expect(() => broadcastConsentChange({ setItem })).not.toThrow(); + }); + + // Regression: `storage` must not be a default parameter. A default is evaluated + // outside this function's try, and merely *touching* localStorage throws where + // storage is partitioned or blocked (the Office task-pane iframe, Safari private + // mode). That escaping throw lands in useConsent.save's catch, which rolls the + // level back and reports failure — after the POST has already succeeded. The user + // would be told their choice failed, and the onboarding gate would stay up, while + // the server had recorded it. + it('swallows a throw from merely accessing localStorage', () => { + const original = Object.getOwnPropertyDescriptor( + globalThis, + 'localStorage', + ); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + get() { + throw new Error('access denied in a partitioned context'); + }, + }); + try { + expect(() => broadcastConsentChange()).not.toThrow(); + } finally { + if (original) { + Object.defineProperty(globalThis, 'localStorage', original); + } else { + delete (globalThis as { localStorage?: unknown }).localStorage; + } + } + }); + it('fires the handler only for the consent key, and stops after unsubscribe', () => { const target = new EventTarget(); const handler = vi.fn(); diff --git a/frontend/src/consentSync.ts b/frontend/src/consentSync.ts index 4470908a..536940b3 100644 --- a/frontend/src/consentSync.ts +++ b/frontend/src/consentSync.ts @@ -20,16 +20,26 @@ type EventTargetLike = Pick< 'addEventListener' | 'removeEventListener' >; -/** Poke other tabs to re-fetch the user after this tab commits a consent change. */ -export function broadcastConsentChange( - storage: StorageLike = localStorage, -): void { +/** + * Poke other tabs to re-fetch the user after this tab commits a consent change. + * + * `storage` deliberately has no default parameter: a default is evaluated at call + * time *outside* this function's `try`, and merely touching `localStorage` throws + * where storage is partitioned or blocked — the Office task-pane iframe, Safari + * private mode. That throw would escape into the caller, and the caller here is + * useConsent.save's try block, whose catch rolls the level back and reports + * failure. The POST has already succeeded at that point, so the user would be told + * their choice failed (and the onboarding gate would stay up) while the server had + * recorded it. Resolving storage inside the try keeps this best-effort. + */ +export function broadcastConsentChange(storage?: StorageLike): void { try { + const target = storage ?? localStorage; // The value must change each call so the `storage` event actually fires. - storage.setItem(CONSENT_CHANGED_KEY, String(Date.now())); + target.setItem(CONSENT_CHANGED_KEY, String(Date.now())); } catch { - // localStorage unavailable (private mode / quota). Cross-tab sync is a - // best-effort enhancement; the writing tab already refreshed directly. + // Storage unavailable (partitioned iframe / private mode / quota). Cross-tab + // sync is an enhancement; this tab already refreshed its own session directly. } } diff --git a/frontend/src/pages/app/OnboardingConsentGate.tsx b/frontend/src/pages/app/OnboardingConsentGate.tsx index e9e8942a..bec18118 100644 --- a/frontend/src/pages/app/OnboardingConsentGate.tsx +++ b/frontend/src/pages/app/OnboardingConsentGate.tsx @@ -29,7 +29,15 @@ export function OnboardingConsentGate() { How much this app records about your usage. You can change this anytime from Account & privacy.

- + {/* Frozen while the save is in flight: Continue committed the level as + it read at click time, so a pick made mid-request would be shown as + selected while the server records the earlier one — and on success + the gate unmounts, hiding the divergence. Matches the account page. */} + {status === 'error' ? (

Could not save your choice. Please try again.

) : null} From 7d5d68e5c799c462941369b8f0836b66f8e14f8a Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Mon, 27 Jul 2026 13:55:04 -0400 Subject: [PATCH 3/8] feat(consent): move the Account entry point from the navbar to the identity chip The nav strip switches in-app pages; Account is an outbound link to /account.html, so it was a category error there. It also competed for the strip's ~300px budget with the Labs overflow button that main's page registry introduced -- and the hunk anchored on a `{tabs.map(...)}` block that registry rewrite deleted, so it was a hard rebase conflict besides. Account affordances belong next to identity, so the footer's existing "User: {name}" chip becomes the link rather than adding a fifth nav item or a new icon. Target stays /account.html: same-origin, so the persisted token is shared and there is no second sign-in. Only the entry point moves. The chip picks up hover, focus-visible, and cursor affordances it did not need as plain text. Co-Authored-By: Claude # Conflicts: # frontend/src/components/navbar/index.tsx --- frontend/src/pages/app/index.tsx | 18 ++++++++++++++++-- frontend/src/pages/app/styles.module.css | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 47bdce1c..ce2cefd1 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -374,9 +374,23 @@ function AppInner() { {!noAuthMode && user ? (
-
+ {/* The identity chip doubles as the entry point to account & + privacy — account affordances belong next to identity, and the + nav strip switches in-app pages, so an outbound link had no + business there. Same-origin, so /account.html shares the + persisted auth token and there is no second sign-in. The + eventual direction is an in-sidebar account screen sharing + this app's auth directly; this is the interim door. */} + User: {user.name} -
+
{authErrorType !== null && (
diff --git a/frontend/src/pages/app/styles.module.css b/frontend/src/pages/app/styles.module.css index b458e406..e69fc9eb 100644 --- a/frontend/src/pages/app/styles.module.css +++ b/frontend/src/pages/app/styles.module.css @@ -109,12 +109,12 @@ hr { } .accountBrowserNote { - max-width: 15rem; color: #686868; - font-size: 0.65rem; + font-size: 0.6rem; font-weight: 400; line-height: 1.2; text-align: center; + white-space: nowrap; } .profilePicContainer { @@ -139,12 +139,13 @@ hr { flex-direction: column; align-items: center; justify-content: center; - /* border: 1px solid black; */ - background-color: white; + border: 1px solid #93c5fd; + background-color: #eff6ff; border-radius: 1rem; padding: 0.2rem 0.4rem; - box-shadow: 0 0 0.3rem 0 rgba(0, 0, 0, 0.2); - color: inherit; + box-shadow: 0 1px 3px rgba(37, 99, 235, 0.2); + color: #1d4ed8; + font-weight: 600; text-decoration: none; cursor: pointer; transition: @@ -153,8 +154,9 @@ hr { } .userNameContainer:hover { - background-color: #f2f6ff; - box-shadow: 0 0 0.4rem 0 rgba(0, 0, 0, 0.3); + border-color: #2563eb; + background-color: #dbeafe; + box-shadow: 0 2px 5px rgba(37, 99, 235, 0.28); } /* Keyboard users get the hover cue plus a real ring — on this light footer the @@ -162,7 +164,7 @@ hr { .userNameContainer:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; - background-color: #f2f6ff; + background-color: #dbeafe; } .widthAlert { From fddea0fb3dfc5d3b7febf1ec5db62ea5eaf43949 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Mon, 27 Jul 2026 17:42:36 -0400 Subject: [PATCH 8/8] style(account): soften account chip emphasis --- frontend/src/pages/app/styles.module.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/app/styles.module.css b/frontend/src/pages/app/styles.module.css index e69fc9eb..5f93549b 100644 --- a/frontend/src/pages/app/styles.module.css +++ b/frontend/src/pages/app/styles.module.css @@ -139,13 +139,13 @@ hr { flex-direction: column; align-items: center; justify-content: center; - border: 1px solid #93c5fd; - background-color: #eff6ff; + border: 1px solid #bfdbfe; + background-color: #f5f9ff; border-radius: 1rem; padding: 0.2rem 0.4rem; box-shadow: 0 1px 3px rgba(37, 99, 235, 0.2); - color: #1d4ed8; - font-weight: 600; + color: #2563eb; + font-weight: 500; text-decoration: none; cursor: pointer; transition: @@ -155,8 +155,8 @@ hr { .userNameContainer:hover { border-color: #2563eb; - background-color: #dbeafe; - box-shadow: 0 2px 5px rgba(37, 99, 235, 0.28); + background-color: #eaf3ff; + box-shadow: 0 2px 5px rgba(37, 99, 235, 0.22); } /* Keyboard users get the hover cue plus a real ring — on this light footer the