diff --git a/backend/src/__tests__/app.test.ts b/backend/src/__tests__/app.test.ts index 46e56950..57401da9 100644 --- a/backend/src/__tests__/app.test.ts +++ b/backend/src/__tests__/app.test.ts @@ -203,7 +203,10 @@ describe('POST /api/me/consent', () => { body: JSON.stringify({ level: 'ai_output' }), }); expect(good.status).toBe(200); - expect(await good.json()).toEqual({ loggingConsent: 'ai_output' }); + expect(await good.json()).toEqual({ + loggingConsent: 'ai_output', + consentUpdatedAt: expect.any(String), + }); expect(calls.updateUser).toEqual([ { userId: 'usr-1', diff --git a/backend/src/app.ts b/backend/src/app.ts index ea417407..1fb7eeeb 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -243,12 +243,16 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { // Better Auth's documented public API, but its own mechanism. A raw SQL // UPDATE via our db() connection was rejected as more fragile — it would // bypass Better Auth's date serialization and any user hooks. + const consentUpdatedAt = new Date(); const ctx = await auth.$context; await ctx.internalAdapter.updateUser(user.id, { loggingConsent: level, - consentUpdatedAt: new Date(), + consentUpdatedAt, }); - return c.json({ loggingConsent: level }); + // Return the authoritative snapshot written above. The client applies this + // directly, rather than making its ability to leave the onboarding gate + // depend on a second, best-effort get-session request. + return c.json({ loggingConsent: level, consentUpdatedAt }); }); // Erase the authenticated user's logged activity — study logs and analytics diff --git a/frontend/src/__tests__/consentSync.test.ts b/frontend/src/__tests__/consentSync.test.ts new file mode 100644 index 00000000..4f7a6679 --- /dev/null +++ b/frontend/src/__tests__/consentSync.test.ts @@ -0,0 +1,80 @@ +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('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(); + 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__/account.test.ts b/frontend/src/api/__tests__/account.test.ts index 570cbb05..6dbbe6fb 100644 --- a/frontend/src/api/__tests__/account.test.ts +++ b/frontend/src/api/__tests__/account.test.ts @@ -17,9 +17,19 @@ afterEach(() => { describe('account API', () => { it('uses the bearer-only path for consent changes', async () => { - fetchMock.mockResolvedValueOnce({ ok: true } as Response); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + loggingConsent: 'ai_output', + consentUpdatedAt: '2026-07-27T00:00:00.000Z', + }), + } as Response); - await changeConsent('tok', 'ai_output'); + await expect(changeConsent('tok', 'ai_output')).resolves.toEqual({ + loggingConsent: 'ai_output', + consentUpdatedAt: '2026-07-27T00:00:00.000Z', + }); expect(fetchMock).toHaveBeenCalledWith('/api/me/consent', { method: 'POST', 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/account.ts b/frontend/src/api/account.ts index 85a6b5b1..e0cb137b 100644 --- a/frontend/src/api/account.ts +++ b/frontend/src/api/account.ts @@ -6,7 +6,7 @@ * client dependency. */ import { SERVER_URL } from '@/api'; -import type { ConsentLevel } from '@/consent'; +import { isConsentLevel, type ConsentLevel } from '@/consent'; function authedFetch( path: string, @@ -27,12 +27,18 @@ function authedFetch( /** * Change the signed-in user's logging-consent level. - * `POST /api/me/consent { level }` → `{ loggingConsent }`. Throws on non-2xx. + * `POST /api/me/consent { level }` → the authoritative consent snapshot. Throws + * on a non-2xx response or malformed response body. */ +export interface ConsentSnapshot { + loggingConsent: ConsentLevel; + consentUpdatedAt: string; +} + export async function changeConsent( token: string, level: ConsentLevel, -): Promise { +): Promise { const res = await authedFetch('/me/consent', token, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -41,6 +47,20 @@ export async function changeConsent( if (!res.ok) { throw new Error(`consent update failed (${res.status})`); } + const data = (await res.json()) as { + loggingConsent?: unknown; + consentUpdatedAt?: unknown; + }; + if ( + !isConsentLevel(data.loggingConsent) || + typeof data.consentUpdatedAt !== 'string' + ) { + throw new Error('consent update returned an invalid snapshot'); + } + return { + loggingConsent: data.loggingConsent, + consentUpdatedAt: data.consentUpdatedAt, + }; } /** 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. + * + * `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. + target.setItem(CONSENT_CHANGED_KEY, String(Date.now())); + } catch { + // Storage unavailable (partitioned iframe / private mode / quota). Cross-tab + // sync is an enhancement; this tab already refreshed its own session 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..5e6ac913 100644 --- a/frontend/src/contexts/appAuthContext.tsx +++ b/frontend/src/contexts/appAuthContext.tsx @@ -22,7 +22,9 @@ import { type ReactNode, } from 'react'; import { setOpenAITokenProvider } from '@/api/openai'; +import type { ConsentSnapshot } from '@/api/account'; 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 +44,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 +74,20 @@ 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; + /** Apply the authoritative result of a consent update without re-fetching. */ + applyConsentSnapshot: (snapshot: ConsentSnapshot) => void; + /** + * True while `loggingConsent` is the provisional `none` held during another + * tab's consent change, rather than a level the user chose. Gate anything + * irreversible-on-withdrawal (not capture itself — that must stop either way). + */ + consentPending: boolean; logout: () => Promise; } @@ -72,6 +97,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 +106,9 @@ const DEFAULT_SESSION: AppAuthSession = { }, login: () => Promise.resolve(), cancelLogin: () => {}, + refreshUser: () => Promise.resolve(), + applyConsentSnapshot: () => {}, + consentPending: false, logout: () => Promise.resolve(), }; @@ -91,6 +120,22 @@ export const useAppAuth = (): AppAuthSession => useContext(AppAuthContext); function BetterAuthProvider({ children }: { children: ReactNode }) { const device = useDeviceAuth(); + const { reconcileConsentFromOtherTab } = 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. Fails closed for the duration: the reconcile drops this + // tab to `none` before re-fetching, so a refresh that never lands leaves the + // tab silent rather than logging at the pre-change level. No-ops when there's + // no active session. + useEffect( + () => + onConsentChangeFromOtherTab(() => { + void reconcileConsentFromOtherTab(); + }), + [reconcileConsentFromOtherTab], + ); const session = useMemo(() => { const isAuthorizing = @@ -122,6 +167,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 +182,9 @@ function BetterAuthProvider({ children }: { children: ReactNode }) { }, login: device.start, cancelLogin: device.reset, + refreshUser: device.refreshUser, + applyConsentSnapshot: device.applyConsentSnapshot, + consentPending: device.consentPending, logout: device.logout, }; }, [device]); @@ -165,6 +215,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 +225,9 @@ function DemoAuthProvider({ children }: { children: ReactNode }) { getAccessToken: anon.getAccessToken, login: () => Promise.resolve(), cancelLogin: () => {}, + refreshUser: () => Promise.resolve(), + applyConsentSnapshot: () => {}, + consentPending: false, logout: () => Promise.resolve(), }), [anon], diff --git a/frontend/src/hooks/__tests__/useDeviceAuth.consent.test.tsx b/frontend/src/hooks/__tests__/useDeviceAuth.consent.test.tsx new file mode 100644 index 00000000..35890c36 --- /dev/null +++ b/frontend/src/hooks/__tests__/useDeviceAuth.consent.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// vi.hoisted so the spy exists before the hoisted vi.mock factory reads it. +const { fetchUserInfo } = vi.hoisted(() => ({ fetchUserInfo: vi.fn() })); + +vi.mock('@/api/deviceAuth', () => ({ + fetchUserInfo, + pollForToken: vi.fn(), + requestDeviceCode: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock('@/api/authTokenStore', () => ({ + loadToken: () => 'stored-token', + persistToken: vi.fn(), + clearToken: vi.fn(), +})); + +import type { UserInfo } from '@/api/deviceAuth'; +import { useDeviceAuth } from '../useDeviceAuth'; + +const USER: UserInfo = { + id: 'u1', + name: 'Test', + loggingConsent: 'usage', + consentUpdatedAt: '2026-07-27T00:00:00.000Z', +}; + +/** Mount and let hydrate-on-mount settle into a signed-in session. */ +async function mountSignedIn() { + fetchUserInfo.mockResolvedValue(USER); + const hook = renderHook(() => useDeviceAuth()); + await waitFor(() => expect(hook.result.current.status).toBe('success')); + return hook; +} + +/** A promise plus the handle to settle it, so a refresh can be held mid-flight. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('useDeviceAuth — cross-tab consent reconciliation', () => { + beforeEach(() => { + fetchUserInfo.mockReset(); + }); + + it('drops to none while reconciling, then adopts the authoritative level', async () => { + const hook = await mountSignedIn(); + + const pending = deferred(); + fetchUserInfo.mockReturnValueOnce(pending.promise); + + let done!: Promise; + act(() => { + done = hook.result.current.reconcileConsentFromOtherTab(); + }); + + // Fails closed immediately: the tab must not keep logging at the old level + // while the authoritative one is still in flight. + expect(hook.result.current.user?.loggingConsent).toBe('none'); + expect(hook.result.current.consentPending).toBe(true); + + await act(async () => { + pending.resolve({ ...USER, loggingConsent: 'document' }); + await done; + }); + + expect(hook.result.current.user?.loggingConsent).toBe('document'); + expect(hook.result.current.consentPending).toBe(false); + }); + + // The whole point of failing closed: a refresh that never lands must leave the + // tab silent, not back at the level the user may have just withdrawn. + it('stays at none when the refresh fails, and stops being pending', async () => { + const hook = await mountSignedIn(); + + fetchUserInfo.mockRejectedValueOnce(new Error('offline')); + await act(async () => { + await hook.result.current.reconcileConsentFromOtherTab(); + }); + + expect(hook.result.current.user?.loggingConsent).toBe('none'); + // `none` is now a settled answer rather than a provisional one, so consumers + // that defer irreversible work (the PostHog identity reset) may proceed. + expect(hook.result.current.consentPending).toBe(false); + }); + + // Regression: with a bare boolean, the first ping to finish would clear the + // flag while the second was still in flight, briefly presenting a provisional + // `none` as authoritative. + it('stays pending until the last of two overlapping reconciles finishes', async () => { + const hook = await mountSignedIn(); + + const first = deferred(); + const second = deferred(); + fetchUserInfo + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + let firstDone!: Promise; + let secondDone!: Promise; + act(() => { + firstDone = hook.result.current.reconcileConsentFromOtherTab(); + secondDone = hook.result.current.reconcileConsentFromOtherTab(); + }); + + await act(async () => { + first.resolve(USER); + await firstDone; + }); + expect(hook.result.current.consentPending).toBe(true); + + await act(async () => { + second.resolve(USER); + await secondDone; + }); + expect(hook.result.current.consentPending).toBe(false); + }); + + it('applyConsentSnapshot updates the level and the set-consent marker', async () => { + const hook = await mountSignedIn(); + + act(() => { + hook.result.current.applyConsentSnapshot({ + loggingConsent: 'none', + consentUpdatedAt: '2026-07-28T12:00:00.000Z', + }); + }); + + expect(hook.result.current.user?.loggingConsent).toBe('none'); + expect(hook.result.current.user?.consentUpdatedAt).toBe( + '2026-07-28T12:00:00.000Z', + ); + }); +}); diff --git a/frontend/src/hooks/useConsent.ts b/frontend/src/hooks/useConsent.ts new file mode 100644 index 00000000..8634aac1 --- /dev/null +++ b/frontend/src/hooks/useConsent.ts @@ -0,0 +1,56 @@ +/** + * 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(); + const snapshot = await changeConsent(token, next); + // The save endpoint returns the values it just persisted, so update this + // session directly. A transient get-session failure can no longer leave a + // successfully-consented user trapped behind the onboarding gate. + session.applyConsentSnapshot(snapshot); + // 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..3ceca10c 100644 --- a/frontend/src/hooks/useDeviceAuth.ts +++ b/frontend/src/hooks/useDeviceAuth.ts @@ -17,6 +17,7 @@ import { signOut as signOutRequest, } from '@/api/deviceAuth'; import { clearToken, loadToken, persistToken } from '@/api/authTokenStore'; +import type { ConsentSnapshot } from '@/api/account'; export type DeviceAuthStatus = | 'idle' @@ -43,6 +44,27 @@ 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; + /** Apply the authoritative consent response from this session's own save. */ + applyConsentSnapshot: (snapshot: ConsentSnapshot) => void; + /** + * Fail closed, then re-fetch, after another tab changed consent. Resolves once + * the level in `user` is authoritative again. + */ + reconcileConsentFromOtherTab: () => Promise; + /** + * True while the above is in flight — i.e. `loggingConsent` is the provisional + * `none`, not a level the user chose. Consumers that do something irreversible + * on withdrawal (PostHog identity reset) must wait this out. + */ + consentPending: boolean; /** Best-effort server sign-out, then clear local state. */ logout: () => Promise; } @@ -51,6 +73,9 @@ const INITIAL: DeviceAuthState = { status: 'idle' }; export function useDeviceAuth(): UseDeviceAuth { const [state, setState] = useState(INITIAL); + // Kept outside DeviceAuthState: it describes how much to trust the consent + // level in `user`, not where the device flow is. + const [consentPending, setConsentPending] = useState(false); const abortRef = useRef(null); // Mirror the token in a ref so logout() can read it without a stale closure. const tokenRef = useRef(undefined); @@ -75,6 +100,65 @@ 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. + } + }, []); + + // The consent endpoint returns exactly the values it persisted. Apply that + // snapshot synchronously so an onboarding gate never depends on a follow-up + // get-session request succeeding. + const applyConsentSnapshot = useCallback((snapshot: ConsentSnapshot) => { + setState((s) => { + if (s.status !== 'success' || !s.user) return s; + return { + ...s, + user: { ...s.user, ...snapshot }, + }; + }); + }, []); + + // A different tab changed consent, possibly lowering it. Drop to the most + // privacy-protective level first so this tab cannot keep logging on a stale + // cached user, then re-fetch the authoritative one. + // + // The two steps are one function rather than two calls at the call site so the + // provisional window has a defined end: `consentPending` brackets it exactly. + // A consumer cannot otherwise distinguish this transient `none` from a level + // the user actually chose, and acting on the difference matters (see the + // PostHog bridge in pages/app/index.tsx). + const pendingReconciles = useRef(0); + const reconcileConsentFromOtherTab = useCallback(async () => { + pendingReconciles.current += 1; + setConsentPending(true); + setState((s) => { + if (s.status !== 'success' || !s.user) return s; + return { + ...s, + user: { ...s.user, loggingConsent: 'none' }, + }; + }); + try { + await refreshUser(); + } finally { + // Counted, not a bare boolean: with two pings in flight the first to + // finish would otherwise declare the level authoritative while the + // second is still provisional. A failed refresh still clears — we stay + // at `none`, and that is now a settled answer, not a pending one. + pendingReconciles.current -= 1; + if (pendingReconciles.current === 0) setConsentPending(false); + } + }, [refreshUser]); + const start = useCallback(async () => { // Abort any prior attempt and open a fresh controller. abortInFlight(); @@ -206,5 +290,14 @@ export function useDeviceAuth(): UseDeviceAuth { }; }, [safeSet]); - return { ...state, start, reset, logout }; + return { + ...state, + start, + reset, + refreshUser, + applyConsentSnapshot, + reconcileConsentFromOtherTab, + consentPending, + logout, + }; } diff --git a/frontend/src/pages/app/OnboardingConsentGate.tsx b/frontend/src/pages/app/OnboardingConsentGate.tsx new file mode 100644 index 00000000..bec18118 --- /dev/null +++ b/frontend/src/pages/app/OnboardingConsentGate.tsx @@ -0,0 +1,54 @@ +/** + * 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. +

+ {/* 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} + +
+ ); +} diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index e2321c0d..abe82b0d 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 @@ -364,8 +374,31 @@ function AppInner() { {!noAuthMode && user ? (
-
- User: {user.name} +
+ {/* 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. In an Office task pane this opens the system + browser, which has a separate storage partition and may require + another sign-in. Do not pass a bearer token in the URL; an + in-app account screen is the eventual solution. */} + + User: {user.name} + + + Opens in browser; sign-in may be required. +
{authErrorType !== null && ( @@ -468,12 +501,13 @@ function AppWithProviders({ * Bridges logging consent → PostHog. Mounted inside both PostHogProvider and * AppAuthProvider. Opts capturing in and identifies the user (by stable Better * Auth id, matching server-side log keying + deletion) once an authenticated - * session at consent >= 'usage' loads; otherwise stays opted out and clears any - * prior identity. Renders nothing. + * session at consent >= 'usage' loads; otherwise stays opted out. Renders + * nothing. */ function PostHogConsentBridge(): null { const posthog = usePostHog(); - const { isAuthenticated, loggingConsent, user } = useAppAuth(); + const { isAuthenticated, loggingConsent, user, consentPending } = + useAppAuth(); useEffect(() => { if (!posthog) return; @@ -486,11 +520,25 @@ function PostHogConsentBridge(): null { if (analyticsAllowed && user?.id) { posthog.identify(user.id); posthog.opt_in_capturing(); - } else { - posthog.opt_out_capturing(); - posthog.reset(); // drop any identity captured under a prior session + return; + } + + // Opting out is unconditional — whatever the reason for being here, this + // tab must stop capturing now. + posthog.opt_out_capturing(); + + // reset() is a separate question, because it is about *identity*, not + // capture: it rotates the anonymous distinct_id and drops feature flags and + // any session recording. Skip it while a cross-tab change is still being + // reconciled, where `loggingConsent` is a provisional `none` this tab + // assumed rather than a level the user chose. Every ping passes through + // that state, including a consent *raise*, so resetting on it would churn + // a person profile for a user who withdrew nothing. Once the refresh lands + // and `none` turns out to be real, this effect re-runs and does reset. + if (!consentPending) { + posthog.reset(); // drop identity for a withdrawn or ended session } - }, [posthog, isAuthenticated, loggingConsent, user?.id]); + }, [posthog, isAuthenticated, loggingConsent, user?.id, consentPending]); return null; } diff --git a/frontend/src/pages/app/styles.module.css b/frontend/src/pages/app/styles.module.css index 3f7f8400..5f93549b 100644 --- a/frontend/src/pages/app/styles.module.css +++ b/frontend/src/pages/app/styles.module.css @@ -101,6 +101,22 @@ hr { gap: 0.5rem; } +.accountEntry { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; +} + +.accountBrowserNote { + color: #686868; + font-size: 0.6rem; + font-weight: 400; + line-height: 1.2; + text-align: center; + white-space: nowrap; +} + .profilePicContainer { display: flex; flex-direction: row; @@ -116,16 +132,39 @@ hr { box-shadow: 0 0 0.3rem 0 rgba(0, 0, 0, 0.2); } +/* Also the account & privacy link (see pages/app/index.tsx), so it carries the + interactive affordances the plain text chip never needed. */ .userNameContainer { display: flex; flex-direction: column; align-items: center; justify-content: center; - /* border: 1px solid black; */ - background-color: white; + border: 1px solid #bfdbfe; + background-color: #f5f9ff; border-radius: 1rem; padding: 0.2rem 0.4rem; - box-shadow: 0 0 0.3rem 0 rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px rgba(37, 99, 235, 0.2); + color: #2563eb; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: + box-shadow 0.15s ease, + background-color 0.15s ease; +} + +.userNameContainer:hover { + border-color: #2563eb; + 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 + shadow alone does not read as focus. */ +.userNameContainer:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + background-color: #dbeafe; } .widthAlert { @@ -197,4 +236,4 @@ hr { .footer > a { font-size: 0.65rem; -} \ No newline at end of file +}