Skip to content
5 changes: 4 additions & 1 deletion backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/__tests__/consentSync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 9 additions & 20 deletions frontend/src/account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -35,30 +34,20 @@ type DeleteStatus =
export function AccountPage() {
const session = useAppAuth();

const [level, setLevel] = useState<ConsentLevel>(session.loggingConsent);
const [consentStatus, setConsentStatus] = useState<ConsentStatus>('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<EraseStatus>('idle');
const [eraseError, setEraseError] = useState<string | null>(null);

const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>('idle');
const [deleteError, setDeleteError] = useState<string | null>(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');
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/api/__tests__/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/__tests__/deviceAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ describe('fetchUserInfo', () => {
loggingConsent: 'document',
isAllowed: true,
isAnonymous: true,
consentUpdatedAt: '2026-07-24T00:00:00.000Z',
},
},
true,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
});
});

Expand Down
26 changes: 23 additions & 3 deletions frontend/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void> {
): Promise<ConsentSnapshot> {
const res = await authedFetch('/me/consent', token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -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,
};
}

/**
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/api/deviceAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -199,6 +206,7 @@ interface GetSessionResponse {
loggingConsent?: unknown;
isAllowed?: unknown;
isAnonymous?: unknown;
consentUpdatedAt?: unknown;
};
}

Expand Down Expand Up @@ -237,6 +245,7 @@ export async function fetchUserInfo(
loggingConsent: raw,
isAllowed,
isAnonymous,
consentUpdatedAt,
} = data.user;
return {
id,
Expand All @@ -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,
};
}

Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export default function Navbar() {
<span className={classes.tabHint}>{pageDef.hint}</span>
</button>
))}

{labPages.length > 0 ? (
<div
ref={labsRef}
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/consentSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Cross-tab consent propagation.
*
* The account & privacy page opens in a separate browsing context (a new tab), so a
* consent change made there refreshes only *that* tab's session. Without this, the
* still-open app tab keeps its stale `loggingConsent`: its PostHog bridge stays
* opted in and `useLog` keeps stripping at the old, higher level after the user has
* lowered (or withdrawn) consent. On a successful save we bump a localStorage key;
* other same-origin tabs receive the `storage` event — which fires only in tabs
* *other* than the writer, exactly the stale ones — and refresh their session.
*
* The storage/event-target dependencies are injectable so the wiring is testable in
* a node environment without a DOM; callers use the `window`/`localStorage` defaults.
*/
export const CONSENT_CHANGED_KEY = 'consent:changed';

type StorageLike = Pick<Storage, 'setItem'>;
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);
}
Loading
Loading