From 4f59356cf0b725ceddfe7b9c9ba5dee142b80266 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:53:34 +0000 Subject: [PATCH] fix(web): tag local appearance caches with their owning account The theme, text scale, text weight, high contrast, custom theme variable and custom CSS caches in localStorage were untagged, so on a shared browser a second account inherited the first account's appearance: with no server value of its own, every fallback resolved to whatever the previous account had stored, and the leftover `silo-theme` key also suppressed the admin-configured default theme for the new account. DateTimeFormatProvider already solved this by stamping its cache with the authenticated user id and refusing another account's values. Extract that mechanism into `createOwnedCache` in utils/storage.ts (where key namespacing lives) and put all three groups behind it, so appearance and custom theme get the same protection instead of a third copy of the rule. - Each group carries its own owner stamp. A shared stamp would be unsafe: the groups are written by hooks nested inside each other, and effects run inner-first, so whichever hook stamped first would vouch for the other's still-stale values. - A null owner (auth bootstrapping, or signed out) still trusts the cache, which keeps the warm start and the login screen's last look. - An unstamped cache is not trusted once an account is known, so existing users take a one-time appearance reset on first load rather than a chance of seeing someone else's settings. - When a foreign cache is detected the values are dropped and the empty cache is handed to the new account, so a later single save cannot re-trust the rest of the previous account's state. Owner is the user id because /settings is user-scoped server side; it lives in one helper (`appearanceCacheOwner`) so it can be widened if appearance moves to profile scope. `shouldLoadApiTheme` is gone: it had become a synonym for `appearanceCacheOwner(...) !== null` with no callers left. Part of #376 AI-use disclosure: implemented with Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- .../hooks/appearanceCacheOwnership.test.tsx | 180 ++++++++++++++++++ web/src/hooks/themePreferences.ts | 26 ++- web/src/hooks/useCustomTheme.ts | 51 +++-- web/src/hooks/useDateTimeFormat.tsx | 24 +-- web/src/hooks/useTheme.test.ts | 49 ++++- web/src/hooks/useTheme.tsx | 103 ++++++---- web/src/utils/storage.test.ts | 88 +++++++++ web/src/utils/storage.ts | 83 +++++++- 8 files changed, 518 insertions(+), 86 deletions(-) create mode 100644 web/src/hooks/appearanceCacheOwnership.test.tsx create mode 100644 web/src/utils/storage.test.ts diff --git a/web/src/hooks/appearanceCacheOwnership.test.tsx b/web/src/hooks/appearanceCacheOwnership.test.tsx new file mode 100644 index 00000000..77ee4523 --- /dev/null +++ b/web/src/hooks/appearanceCacheOwnership.test.tsx @@ -0,0 +1,180 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { appearanceCache, customThemeCache, storage } from "@/utils/storage"; +import { DEFAULT_THEME } from "@/lib/themes"; + +const mocks = vi.hoisted(() => ({ + useOptionalAuth: vi.fn(), + useSettings: vi.fn(), + useBranding: vi.fn(), + mutate: vi.fn(), +})); + +vi.mock("@/hooks/useAuth", () => ({ + useOptionalAuth: () => mocks.useOptionalAuth(), +})); + +vi.mock("@/hooks/queries/settings", () => ({ + useSettings: (options?: { enabled?: boolean }) => mocks.useSettings(options), + useSetSetting: () => ({ mutate: mocks.mutate }), +})); + +vi.mock("@/hooks/useBranding", () => ({ + useBranding: () => mocks.useBranding(), +})); + +import { ThemeProvider, useTheme } from "./useTheme"; +import { useCustomTheme } from "./useCustomTheme"; + +const KEYS = storage.KEYS; + +interface Captured { + theme: ReturnType; + custom: ReturnType; +} + +function Probe({ onRender }: { onRender: (captured: Captured) => void }) { + const theme = useTheme(); + const custom = useCustomTheme(); + onRender({ theme, custom }); + return null; +} + +function renderAppearance(): Captured { + let captured: Captured | null = null; + render( + + { + captured = next; + }} + /> + , + ); + if (!captured) throw new Error("probe never rendered"); + return captured; +} + +/** Everything account 1 left behind on this browser. */ +function seedAccountOneAppearance(): void { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + appearanceCache.set(KEYS.UI_TEXT_SCALE, "large", "1"); + appearanceCache.set(KEYS.UI_TEXT_WEIGHT, "strong", "1"); + appearanceCache.set(KEYS.UI_HIGH_CONTRAST, "true", "1"); + customThemeCache.set(KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify({ "color-bg": "#ff0000" }), "1"); + customThemeCache.set(KEYS.UI_CUSTOM_CSS, "body { filter: invert(1); }", "1"); +} + +function signedInAs(id: number): void { + mocks.useOptionalAuth.mockReturnValue({ loading: false, user: { id } }); +} + +describe("appearance cache ownership", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.values(KEYS).forEach((key) => storage.remove(key)); + mocks.useSettings.mockReturnValue({ data: {} }); + mocks.useBranding.mockReturnValue({ defaultTheme: null }); + }); + + it("does not apply another account's cached appearance", () => { + seedAccountOneAppearance(); + signedInAs(2); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe(DEFAULT_THEME); + expect(captured.theme.textScale).toBe("default"); + expect(captured.theme.textWeight).toBe("default"); + expect(captured.theme.highContrast).toBe(false); + expect(captured.custom.vars).toEqual({}); + expect(captured.custom.customCss).toBe(""); + }); + + it("drops another account's cached appearance instead of leaving it to be re-trusted", () => { + seedAccountOneAppearance(); + signedInAs(2); + + renderAppearance(); + + expect(storage.get(KEYS.THEME)).toBeNull(); + expect(storage.get(KEYS.UI_TEXT_SCALE)).toBeNull(); + expect(storage.get(KEYS.UI_TEXT_WEIGHT)).toBeNull(); + expect(storage.get(KEYS.UI_HIGH_CONTRAST)).toBeNull(); + expect(storage.get(KEYS.UI_CUSTOM_THEME_VARS)).toBeNull(); + expect(storage.get(KEYS.UI_CUSTOM_CSS)).toBeNull(); + expect(appearanceCache.isTrusted("2")).toBe(true); + expect(customThemeCache.isTrusted("2")).toBe(true); + }); + + it("still applies the admin default theme to an account that inherited a foreign cache", () => { + seedAccountOneAppearance(); + signedInAs(2); + mocks.useBranding.mockReturnValue({ defaultTheme: "evergreen-studio" }); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe("evergreen-studio"); + }); + + it("keeps the warm start for the account that stored it", () => { + seedAccountOneAppearance(); + signedInAs(1); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe("cobalt-studio"); + expect(captured.theme.textScale).toBe("large"); + expect(captured.theme.textWeight).toBe("strong"); + expect(captured.theme.highContrast).toBe(true); + expect(captured.custom.vars).toEqual({ "color-bg": "#ff0000" }); + expect(captured.custom.customCss).toBe("body { filter: invert(1); }"); + expect(storage.get(KEYS.THEME)).toBe("cobalt-studio"); + }); + + it("keeps the warm start while auth is still bootstrapping", () => { + seedAccountOneAppearance(); + mocks.useOptionalAuth.mockReturnValue({ loading: true, user: null }); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe("cobalt-studio"); + expect(captured.theme.textScale).toBe("large"); + expect(captured.custom.customCss).toBe("body { filter: invert(1); }"); + expect(storage.get(KEYS.THEME)).toBe("cobalt-studio"); + }); + + it("ignores an unstamped legacy cache once an account is known", () => { + storage.set(KEYS.THEME, "cobalt-studio"); + storage.set(KEYS.UI_TEXT_SCALE, "large"); + storage.set(KEYS.UI_CUSTOM_CSS, "body { filter: invert(1); }"); + signedInAs(2); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe(DEFAULT_THEME); + expect(captured.theme.textScale).toBe("default"); + expect(captured.custom.customCss).toBe(""); + }); + + it("lets the signed-in account's own server values win over an empty local cache", () => { + seedAccountOneAppearance(); + signedInAs(2); + mocks.useSettings.mockReturnValue({ + data: { + ui_theme: "oxblood-noir", + ui_text_scale: "x-large", + ui_custom_css: "body { color: blue; }", + }, + }); + + const captured = renderAppearance(); + + expect(captured.theme.theme).toBe("oxblood-noir"); + expect(captured.theme.textScale).toBe("x-large"); + expect(captured.custom.customCss).toBe("body { color: blue; }"); + expect(storage.get(KEYS.UI_CUSTOM_CSS)).toBe("body { color: blue; }"); + expect(customThemeCache.isTrusted("2")).toBe(true); + }); +}); diff --git a/web/src/hooks/themePreferences.ts b/web/src/hooks/themePreferences.ts index a1ddce89..cee0532e 100644 --- a/web/src/hooks/themePreferences.ts +++ b/web/src/hooks/themePreferences.ts @@ -1,18 +1,26 @@ -import { storage } from "@/utils/storage"; +import { appearanceCache, storage } from "@/utils/storage"; import type { ThemeId } from "@/lib/themes"; import { DEFAULT_THEME, THEME_IDS } from "@/lib/themes"; export type TextScale = "default" | "large" | "x-large"; export type TextWeight = "default" | "strong"; -export function shouldLoadApiTheme({ - loading, - user, -}: { +export interface AppearanceAuth { loading: boolean; user: { id: number } | null; -}): boolean { - return !loading && !!user; +} + +/** + * The account that owns the device-local appearance caches, or null while auth + * is bootstrapping or nobody is signed in. + * + * Appearance settings are user-scoped server side (`GET /settings` resolves + * against `user_settings` for the authenticated user), so the user id is the + * right owner token today. Widen this one function if appearance ever moves to + * profile scope — profiles on one account share a user id. + */ +export function appearanceCacheOwner({ loading, user }: AppearanceAuth): string | null { + return !loading && user ? String(user.id) : null; } export function isValidTheme(value: string | null | undefined): value is ThemeId { @@ -31,7 +39,7 @@ export function parseHighContrast(value: string | null | undefined): boolean { return value === "true"; } -export function getInitialTheme(): ThemeId { - const stored = storage.get(storage.KEYS.THEME); +export function getInitialTheme(owner: string | null): ThemeId { + const stored = appearanceCache.get(storage.KEYS.THEME, owner); return isValidTheme(stored) ? stored : DEFAULT_THEME; } diff --git a/web/src/hooks/useCustomTheme.ts b/web/src/hooks/useCustomTheme.ts index 9227e339..2d6be3c5 100644 --- a/web/src/hooks/useCustomTheme.ts +++ b/web/src/hooks/useCustomTheme.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useSettings, useSetSetting } from "@/hooks/queries/settings"; import { useOptionalAuth } from "@/hooks/useAuth"; -import { shouldLoadApiTheme } from "@/hooks/themePreferences"; -import { storage } from "@/utils/storage"; +import { appearanceCacheOwner } from "@/hooks/themePreferences"; +import { customThemeCache, storage } from "@/utils/storage"; import { parseVarsJson } from "@/lib/themeExport"; import { sanitizeCss } from "@/lib/cssSanitizer"; import type { ThemeToken } from "@/lib/themeTokens"; @@ -28,12 +28,20 @@ interface UseCustomThemeResult { isDirty: boolean; } +const EMPTY_VARS: ThemeVarOverrides = {}; + export function useCustomTheme(): UseCustomThemeResult { const auth = useOptionalAuth(); - const loadApi = shouldLoadApiTheme({ + // Owner of the localStorage warm start; null while auth bootstraps or when + // nobody is signed in, which keeps the last look on the login screen. + const cacheOwner = appearanceCacheOwner({ loading: auth?.loading ?? false, user: auth?.user ? { id: auth.user.id } : null, }); + const loadApi = cacheOwner !== null; + // Read once per render, before the effects below re-stamp the cache, so every + // useCustomTheme instance in this render pass agrees on the answer. + const cacheTrusted = customThemeCache.isTrusted(cacheOwner); // API values const { data: apiSettings } = useSettings({ enabled: loadApi }); @@ -43,10 +51,10 @@ export function useCustomTheme(): UseCustomThemeResult { // Local draft state (for instant updates without waiting for API) const [localVars, setLocalVars] = useState(() => - parseVarsJson(storage.get(storage.KEYS.UI_CUSTOM_THEME_VARS)), + parseVarsJson(customThemeCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)), ); const [localCss, setLocalCss] = useState( - () => storage.get(storage.KEYS.UI_CUSTOM_CSS) ?? "", + () => customThemeCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "", ); const [isDirty, setIsDirty] = useState(false); @@ -54,38 +62,49 @@ export function useCustomTheme(): UseCustomThemeResult { const varsTimerRef = useRef | undefined>(undefined); const cssTimerRef = useRef | undefined>(undefined); + // Another account's custom theme is never a valid starting point: drop it (and + // take ownership of the now-empty cache) as soon as we know who is signed in. + // Declared before the API sync effects so it can never wipe values they just + // wrote for the new owner. + useEffect(() => { + if (cacheOwner === null || cacheTrusted) return; + customThemeCache.clear(cacheOwner); + setLocalVars(EMPTY_VARS); + setLocalCss(""); + }, [cacheOwner, cacheTrusted]); + // Sync API values into local state when they arrive useEffect(() => { if (loadApi && apiVars !== undefined) { const parsed = parseVarsJson(apiVars); setLocalVars(parsed); - storage.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed)); + customThemeCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed), cacheOwner); } - }, [loadApi, apiVars]); + }, [loadApi, apiVars, cacheOwner]); useEffect(() => { if (loadApi && apiCss !== undefined && apiCss !== null) { setLocalCss(apiCss); - storage.set(storage.KEYS.UI_CUSTOM_CSS, apiCss); + customThemeCache.set(storage.KEYS.UI_CUSTOM_CSS, apiCss, cacheOwner); } - }, [loadApi, apiCss]); + }, [loadApi, apiCss, cacheOwner]); const persistVars = useCallback( (vars: ThemeVarOverrides) => { const json = JSON.stringify(vars); - storage.set(storage.KEYS.UI_CUSTOM_THEME_VARS, json); + customThemeCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, json, cacheOwner); settingMutation.mutate({ key: "ui_custom_theme_vars", value: json }); }, - [settingMutation], + [settingMutation, cacheOwner], ); const persistCss = useCallback( (css: string) => { const safe = sanitizeCss(css); - storage.set(storage.KEYS.UI_CUSTOM_CSS, safe); + customThemeCache.set(storage.KEYS.UI_CUSTOM_CSS, safe, cacheOwner); settingMutation.mutate({ key: "ui_custom_css", value: safe }); }, - [settingMutation], + [settingMutation, cacheOwner], ); const setVar = useCallback( @@ -155,8 +174,10 @@ export function useCustomTheme(): UseCustomThemeResult { ); return { - vars: localVars, - customCss: localCss, + // Until the cache is proven to be ours, render nothing custom rather than + // the previous account's tokens and CSS. + vars: cacheTrusted ? localVars : EMPTY_VARS, + customCss: cacheTrusted ? localCss : "", setVar, resetVar, setAllVars, diff --git a/web/src/hooks/useDateTimeFormat.tsx b/web/src/hooks/useDateTimeFormat.tsx index 82bab436..ad6ed44a 100644 --- a/web/src/hooks/useDateTimeFormat.tsx +++ b/web/src/hooks/useDateTimeFormat.tsx @@ -15,7 +15,7 @@ import type { } from "@/lib/datetime"; import { useSettings, useSetSetting } from "@/hooks/queries/settings"; import { useOptionalAuth } from "@/hooks/useAuth"; -import { storage } from "@/utils/storage"; +import { dateTimeFormatCache, storage } from "@/utils/storage"; export const DATE_FORMAT_SETTING_KEY = "ui.date_format"; export const TIME_FORMAT_SETTING_KEY = "ui.time_format"; @@ -38,7 +38,7 @@ interface DateTimeFormatContextValue { const DateTimeFormatContext = createContext(null); /** - * Syncs the persisted date/time format settings (profile-scoped, mirrored in + * Syncs the persisted date/time format settings (user-scoped, mirrored in * localStorage like the theme preferences) into the shared formatter state in * lib/datetime, and exposes setters for the settings UI. */ @@ -57,8 +57,7 @@ export function DateTimeFormatProvider({ children }: { children: ReactNode }) { // fails), the localStorage warm start is only trusted if it was mirrored for // this same user; another account's device-local preference must not leak in. const apiLoaded = loadApiSettings && apiSettings !== undefined; - const localTrusted = - authUserId === null || storage.get(storage.KEYS.UI_DATETIME_FORMAT_OWNER) === authUserId; + const localTrusted = dateTimeFormatCache.isTrusted(authUserId); const dateFormat = apiLoaded ? parseDateFormatPreference(apiSettings[DATE_FORMAT_SETTING_KEY]) : localTrusted @@ -76,21 +75,15 @@ export function DateTimeFormatProvider({ children }: { children: ReactNode }) { // this device paints in the right format before the settings request // resolves. if (apiLoaded) { - storage.set(storage.KEYS.UI_DATE_FORMAT, dateFormat); - storage.set(storage.KEYS.UI_TIME_FORMAT, timeFormat); - if (authUserId !== null) { - storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId); - } + dateTimeFormatCache.set(storage.KEYS.UI_DATE_FORMAT, dateFormat, authUserId); + dateTimeFormatCache.set(storage.KEYS.UI_TIME_FORMAT, timeFormat, authUserId); } }, [dateFormat, timeFormat, apiLoaded, authUserId]); const setDateFormat = useCallback( (value: DateFormatPreference) => { setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), dateFormat: value }); - storage.set(storage.KEYS.UI_DATE_FORMAT, value); - if (authUserId !== null) { - storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId); - } + dateTimeFormatCache.set(storage.KEYS.UI_DATE_FORMAT, value, authUserId); settingMutation.mutate({ key: DATE_FORMAT_SETTING_KEY, value }); }, [settingMutation, authUserId], @@ -99,10 +92,7 @@ export function DateTimeFormatProvider({ children }: { children: ReactNode }) { const setTimeFormat = useCallback( (value: TimeFormatPreference) => { setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), timeFormat: value }); - storage.set(storage.KEYS.UI_TIME_FORMAT, value); - if (authUserId !== null) { - storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId); - } + dateTimeFormatCache.set(storage.KEYS.UI_TIME_FORMAT, value, authUserId); settingMutation.mutate({ key: TIME_FORMAT_SETTING_KEY, value }); }, [settingMutation, authUserId], diff --git a/web/src/hooks/useTheme.test.ts b/web/src/hooks/useTheme.test.ts index 0575d55c..926e7ed6 100644 --- a/web/src/hooks/useTheme.test.ts +++ b/web/src/hooks/useTheme.test.ts @@ -1,14 +1,45 @@ -import { describe, expect, it } from "vitest"; -import { shouldLoadApiTheme } from "./themePreferences"; +import { beforeEach, describe, expect, it } from "vitest"; +import { appearanceCache, storage } from "@/utils/storage"; +import { DEFAULT_THEME } from "@/lib/themes"; +import { appearanceCacheOwner, getInitialTheme } from "./themePreferences"; -describe("shouldLoadApiTheme", () => { - it("waits until auth bootstrap finishes before loading the API theme", () => { - expect(shouldLoadApiTheme({ loading: true, user: null })).toBe(false); - expect(shouldLoadApiTheme({ loading: true, user: { id: 1 } })).toBe(false); +describe("appearanceCacheOwner", () => { + // No owner also means no API settings request: the hooks gate the query on + // having resolved an owner. + it("has no owner until auth bootstrap finishes", () => { + expect(appearanceCacheOwner({ loading: true, user: null })).toBeNull(); + expect(appearanceCacheOwner({ loading: true, user: { id: 1 } })).toBeNull(); }); - it("only loads the API theme for authenticated users after bootstrap", () => { - expect(shouldLoadApiTheme({ loading: false, user: null })).toBe(false); - expect(shouldLoadApiTheme({ loading: false, user: { id: 1 } })).toBe(true); + it("has no owner when nobody is signed in", () => { + expect(appearanceCacheOwner({ loading: false, user: null })).toBeNull(); + }); + + it("identifies the cache owner by the authenticated user id", () => { + expect(appearanceCacheOwner({ loading: false, user: { id: 7 } })).toBe("7"); + }); +}); + +describe("getInitialTheme", () => { + beforeEach(() => { + Object.values(storage.KEYS).forEach((key) => storage.remove(key)); + }); + + it("warms up from the cache while the owner is unknown", () => { + appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1"); + + expect(getInitialTheme(null)).toBe("cobalt-studio"); + }); + + it("warms up from the cache for the account that stored it", () => { + appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1"); + + expect(getInitialTheme("1")).toBe("cobalt-studio"); + }); + + it("ignores another account's cached theme", () => { + appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1"); + + expect(getInitialTheme("2")).toBe(DEFAULT_THEME); }); }); diff --git a/web/src/hooks/useTheme.tsx b/web/src/hooks/useTheme.tsx index 61ce11d6..205876c4 100644 --- a/web/src/hooks/useTheme.tsx +++ b/web/src/hooks/useTheme.tsx @@ -1,17 +1,18 @@ import { createContext, useContext, useEffect, useState, useCallback } from "react"; import type { ReactNode } from "react"; import type { ThemeId } from "@/lib/themes"; +import { DEFAULT_THEME } from "@/lib/themes"; import { useSettings, useSetSetting } from "@/hooks/queries/settings"; import { useOptionalAuth } from "@/hooks/useAuth"; import { useBranding } from "@/hooks/useBranding"; -import { storage } from "@/utils/storage"; +import { appearanceCache, storage } from "@/utils/storage"; import { + appearanceCacheOwner, getInitialTheme, isValidTheme, parseHighContrast, parseTextScale, parseTextWeight, - shouldLoadApiTheme, } from "@/hooks/themePreferences"; import type { TextScale, TextWeight } from "@/hooks/themePreferences"; @@ -47,24 +48,45 @@ function applyHighContrastToDOM(value: boolean): void { } export function ThemeProvider({ children }: { children: ReactNode }) { - const [themePreference, setThemePreference] = useState(getInitialTheme); - const [previewThemeState, setPreviewThemeState] = useState(null); - const [textScalePreference, setTextScalePreference] = useState(() => - parseTextScale(storage.get(storage.KEYS.UI_TEXT_SCALE)), - ); - const [textWeightPreference, setTextWeightPreference] = useState(() => - parseTextWeight(storage.get(storage.KEYS.UI_TEXT_WEIGHT)), - ); - const [highContrastPreference, setHighContrastPreference] = useState(() => - parseHighContrast(storage.get(storage.KEYS.UI_HIGH_CONTRAST)), - ); const auth = useOptionalAuth(); - const loadApiTheme = shouldLoadApiTheme({ + // The account that owns the localStorage warm start. Null while auth is + // bootstrapping or nobody is signed in, which still trusts the cache so the + // app paints in the last look this device used. + const cacheOwner = appearanceCacheOwner({ loading: auth?.loading ?? false, user: auth?.user ? { id: auth.user.id } : null, }); + const loadApiTheme = cacheOwner !== null; + // Read once per render, before any effect below re-stamps the cache, so every + // ThemeProvider in this render pass agrees on whether the cache is ours. + const cacheTrusted = appearanceCache.isTrusted(cacheOwner); - // Load persisted setting from API (profile-scoped) + const [themePreference, setThemePreference] = useState(() => + getInitialTheme(cacheOwner), + ); + const [previewThemeState, setPreviewThemeState] = useState(null); + const [textScalePreference, setTextScalePreference] = useState(() => + parseTextScale(appearanceCache.get(storage.KEYS.UI_TEXT_SCALE, cacheOwner)), + ); + const [textWeightPreference, setTextWeightPreference] = useState(() => + parseTextWeight(appearanceCache.get(storage.KEYS.UI_TEXT_WEIGHT, cacheOwner)), + ); + const [highContrastPreference, setHighContrastPreference] = useState(() => + parseHighContrast(appearanceCache.get(storage.KEYS.UI_HIGH_CONTRAST, cacheOwner)), + ); + + // Another account's appearance is never a valid starting point: drop it (and + // take ownership of the now-empty cache) as soon as we know who is signed in. + useEffect(() => { + if (cacheOwner === null || cacheTrusted) return; + appearanceCache.clear(cacheOwner); + setThemePreference(DEFAULT_THEME); + setTextScalePreference("default"); + setTextWeightPreference("default"); + setHighContrastPreference(false); + }, [cacheOwner, cacheTrusted]); + + // Load persisted setting from API (user-scoped) const { data: apiSettings } = useSettings({ enabled: loadApiTheme }); const apiTheme = apiSettings?.ui_theme; const apiTextScale = apiSettings?.ui_text_scale; @@ -76,21 +98,28 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // preference of their own (no stored local choice and no profile ui_theme). // A user's explicit choice always wins, preserving the per-user layering. const { defaultTheme: adminDefaultTheme } = useBranding(); - const hasStoredThemeChoice = storage.get(storage.KEYS.THEME) != null; + // Values cached by another account must not stand in for the signed-in + // account's missing preferences — that would both show them someone else's + // appearance and suppress the admin default theme they should be getting. + const localTheme = cacheTrusted ? themePreference : DEFAULT_THEME; + const localTextScale = cacheTrusted ? textScalePreference : "default"; + const localTextWeight = cacheTrusted ? textWeightPreference : "default"; + const localHighContrast = cacheTrusted ? highContrastPreference : false; + const hasStoredThemeChoice = appearanceCache.get(storage.KEYS.THEME, cacheOwner) != null; const fallbackTheme: ThemeId = - !hasStoredThemeChoice && isValidTheme(adminDefaultTheme) ? adminDefaultTheme : themePreference; + !hasStoredThemeChoice && isValidTheme(adminDefaultTheme) ? adminDefaultTheme : localTheme; const theme = - loadApiTheme && apiTheme ? getInitialThemeFromApi(apiTheme, fallbackTheme) : fallbackTheme; - const textScale = loadApiTheme - ? parseTextScale(apiTextScale ?? textScalePreference) - : textScalePreference; + loadApiTheme && apiTheme + ? getInitialThemeFromApi(apiTheme, fallbackTheme, cacheOwner) + : fallbackTheme; + const textScale = loadApiTheme ? parseTextScale(apiTextScale ?? localTextScale) : localTextScale; const textWeight = loadApiTheme - ? parseTextWeight(apiTextWeight ?? textWeightPreference) - : textWeightPreference; + ? parseTextWeight(apiTextWeight ?? localTextWeight) + : localTextWeight; const highContrast = loadApiTheme - ? parseHighContrast(apiHighContrast ?? String(highContrastPreference)) - : highContrastPreference; + ? parseHighContrast(apiHighContrast ?? String(localHighContrast)) + : localHighContrast; useEffect(() => { applyThemeToDOM(previewThemeState ?? theme); @@ -113,10 +142,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { setPreviewThemeState(null); setThemePreference(newTheme); applyThemeToDOM(newTheme); - storage.set(storage.KEYS.THEME, newTheme); + appearanceCache.set(storage.KEYS.THEME, newTheme, cacheOwner); settingMutation.mutate({ key: "ui_theme", value: newTheme }); }, - [settingMutation], + [settingMutation, cacheOwner], ); const previewTheme = useCallback((newTheme: ThemeId) => { @@ -131,30 +160,30 @@ export function ThemeProvider({ children }: { children: ReactNode }) { (value: TextScale) => { setTextScalePreference(value); applyTextScaleToDOM(value); - storage.set(storage.KEYS.UI_TEXT_SCALE, value); + appearanceCache.set(storage.KEYS.UI_TEXT_SCALE, value, cacheOwner); settingMutation.mutate({ key: "ui_text_scale", value }); }, - [settingMutation], + [settingMutation, cacheOwner], ); const setTextWeight = useCallback( (value: TextWeight) => { setTextWeightPreference(value); applyTextWeightToDOM(value); - storage.set(storage.KEYS.UI_TEXT_WEIGHT, value); + appearanceCache.set(storage.KEYS.UI_TEXT_WEIGHT, value, cacheOwner); settingMutation.mutate({ key: "ui_text_weight", value }); }, - [settingMutation], + [settingMutation, cacheOwner], ); const setHighContrast = useCallback( (value: boolean) => { setHighContrastPreference(value); applyHighContrastToDOM(value); - storage.set(storage.KEYS.UI_HIGH_CONTRAST, String(value)); + appearanceCache.set(storage.KEYS.UI_HIGH_CONTRAST, String(value), cacheOwner); settingMutation.mutate({ key: "ui_high_contrast", value: String(value) }); }, - [settingMutation], + [settingMutation, cacheOwner], ); return ( @@ -183,7 +212,11 @@ export function useTheme(): ThemeContextValue { return ctx; } -function getInitialThemeFromApi(apiTheme: string | null, fallback: ThemeId): ThemeId { +function getInitialThemeFromApi( + apiTheme: string | null, + fallback: ThemeId, + cacheOwner: string | null, +): ThemeId { if (!apiTheme || !isValidTheme(apiTheme)) return fallback; - return storage.get(storage.KEYS.THEME) !== apiTheme ? apiTheme : fallback; + return appearanceCache.get(storage.KEYS.THEME, cacheOwner) !== apiTheme ? apiTheme : fallback; } diff --git a/web/src/utils/storage.test.ts b/web/src/utils/storage.test.ts new file mode 100644 index 00000000..c459f668 --- /dev/null +++ b/web/src/utils/storage.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { appearanceCache, customThemeCache, dateTimeFormatCache, storage } from "./storage"; + +const KEYS = storage.KEYS; + +describe("owned caches", () => { + beforeEach(() => { + Object.values(KEYS).forEach((key) => storage.remove(key)); + }); + + it("trusts the cache while nobody is known to be signed in", () => { + storage.set(KEYS.THEME, "cobalt-studio"); + storage.set(KEYS.UI_APPEARANCE_OWNER, "1"); + + expect(appearanceCache.isTrusted(null)).toBe(true); + expect(appearanceCache.get(KEYS.THEME, null)).toBe("cobalt-studio"); + }); + + it("trusts the cache for the account that stamped it", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + + expect(storage.get(KEYS.UI_APPEARANCE_OWNER)).toBe("1"); + expect(appearanceCache.isTrusted("1")).toBe(true); + expect(appearanceCache.get(KEYS.THEME, "1")).toBe("cobalt-studio"); + }); + + it("hides another account's cached values", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + appearanceCache.set(KEYS.UI_TEXT_SCALE, "large", "1"); + + expect(appearanceCache.isTrusted("2")).toBe(false); + expect(appearanceCache.get(KEYS.THEME, "2")).toBeNull(); + expect(appearanceCache.get(KEYS.UI_TEXT_SCALE, "2")).toBeNull(); + }); + + it("does not trust an unstamped legacy cache for a known account", () => { + storage.set(KEYS.THEME, "cobalt-studio"); + + expect(appearanceCache.isTrusted("1")).toBe(false); + expect(appearanceCache.get(KEYS.THEME, "1")).toBeNull(); + }); + + it("clear() drops every member value and hands the empty cache to the new owner", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + appearanceCache.set(KEYS.UI_TEXT_SCALE, "large", "1"); + appearanceCache.set(KEYS.UI_TEXT_WEIGHT, "strong", "1"); + appearanceCache.set(KEYS.UI_HIGH_CONTRAST, "true", "1"); + + appearanceCache.clear("2"); + + expect(storage.get(KEYS.THEME)).toBeNull(); + expect(storage.get(KEYS.UI_TEXT_SCALE)).toBeNull(); + expect(storage.get(KEYS.UI_TEXT_WEIGHT)).toBeNull(); + expect(storage.get(KEYS.UI_HIGH_CONTRAST)).toBeNull(); + expect(appearanceCache.isTrusted("2")).toBe(true); + expect(appearanceCache.isTrusted("1")).toBe(false); + }); + + it("clear() without an owner leaves the cache unstamped", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + + appearanceCache.clear(null); + + expect(storage.get(KEYS.THEME)).toBeNull(); + expect(storage.get(KEYS.UI_APPEARANCE_OWNER)).toBeNull(); + expect(appearanceCache.isTrusted("1")).toBe(false); + }); + + it("keeps each cache group's ownership independent", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", "1"); + customThemeCache.set(KEYS.UI_CUSTOM_CSS, "body{}", "2"); + dateTimeFormatCache.set(KEYS.UI_DATE_FORMAT, "iso", "3"); + + expect(appearanceCache.isTrusted("1")).toBe(true); + expect(appearanceCache.isTrusted("2")).toBe(false); + expect(customThemeCache.isTrusted("2")).toBe(true); + expect(customThemeCache.isTrusted("1")).toBe(false); + expect(dateTimeFormatCache.isTrusted("3")).toBe(true); + expect(dateTimeFormatCache.isTrusted("1")).toBe(false); + }); + + it("stamps nothing when there is no owner to stamp", () => { + appearanceCache.set(KEYS.THEME, "cobalt-studio", null); + + expect(storage.get(KEYS.THEME)).toBe("cobalt-studio"); + expect(storage.get(KEYS.UI_APPEARANCE_OWNER)).toBeNull(); + }); +}); diff --git a/web/src/utils/storage.ts b/web/src/utils/storage.ts index a3bd31ae..7fbd529a 100644 --- a/web/src/utils/storage.ts +++ b/web/src/utils/storage.ts @@ -19,11 +19,13 @@ const STORAGE_KEYS = { UI_DATE_FORMAT: "silo-ui-date-format", UI_TIME_FORMAT: "silo-ui-time-format", UI_DATETIME_FORMAT_OWNER: "silo-ui-datetime-format-owner", + UI_APPEARANCE_OWNER: "silo-ui-appearance-owner", + UI_CUSTOM_THEME_OWNER: "silo-ui-custom-theme-owner", UI_CUSTOM_CSS: "silo-custom-css", CALENDAR_PRESET: "calendar:preset", } as const; -type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; +export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; function get(key: StorageKey): string | null { try { @@ -50,3 +52,82 @@ function remove(key: StorageKey): void { } export const storage = { KEYS: STORAGE_KEYS, get, set, remove }; + +/** + * A group of localStorage keys that mirror server-side, per-account settings so + * the UI can paint before the settings request resolves. + * + * Every write stamps the account that owns the values, and reads are only + * honored for that same account: browsers are shared, and a second account + * signing in must never inherit the first account's cached values. + */ +export interface OwnedCache { + /** + * Whether the cached values may be applied to `owner`. + * + * A `null` owner (auth still bootstrapping, or signed out) trusts the cache so + * the warm start still paints. An unstamped cache — written before ownership + * tagging existed — is never trusted for a known account; those users take a + * one-time reset instead of a chance of seeing someone else's settings. + */ + isTrusted: (owner: string | null) => boolean; + /** The cached value, or null when the cache belongs to a different account. */ + get: (key: StorageKey, owner: string | null) => string | null; + /** Write a value and stamp `owner` as the cache's owner. */ + set: (key: StorageKey, value: string, owner: string | null) => void; + /** Drop every cached value and hand the empty cache to `owner`. */ + clear: (owner: string | null) => void; +} + +function createOwnedCache(ownerKey: StorageKey, memberKeys: readonly StorageKey[]): OwnedCache { + function stamp(owner: string | null): void { + if (owner === null) return; + set(ownerKey, owner); + } + + function isTrusted(owner: string | null): boolean { + return owner === null || get(ownerKey) === owner; + } + + return { + isTrusted, + get: (key, owner) => (isTrusted(owner) ? get(key) : null), + set: (key, value, owner) => { + set(key, value); + stamp(owner); + }, + clear: (owner) => { + memberKeys.forEach((key) => remove(key)); + if (owner === null) { + remove(ownerKey); + } else { + stamp(owner); + } + }, + }; +} + +// Each group carries its own owner stamp rather than sharing one. The groups are +// written by different hooks whose effects run in a fixed nesting order, so a +// shared stamp would let whichever hook resolved first vouch for another hook's +// still-stale values. + +/** Theme, text scale, text weight and high contrast (written by useTheme). */ +export const appearanceCache = createOwnedCache(STORAGE_KEYS.UI_APPEARANCE_OWNER, [ + STORAGE_KEYS.THEME, + STORAGE_KEYS.UI_TEXT_SCALE, + STORAGE_KEYS.UI_TEXT_WEIGHT, + STORAGE_KEYS.UI_HIGH_CONTRAST, +]); + +/** Custom theme token overrides and raw CSS (written by useCustomTheme). */ +export const customThemeCache = createOwnedCache(STORAGE_KEYS.UI_CUSTOM_THEME_OWNER, [ + STORAGE_KEYS.UI_CUSTOM_THEME_VARS, + STORAGE_KEYS.UI_CUSTOM_CSS, +]); + +/** Date and time format preferences (written by DateTimeFormatProvider). */ +export const dateTimeFormatCache = createOwnedCache(STORAGE_KEYS.UI_DATETIME_FORMAT_OWNER, [ + STORAGE_KEYS.UI_DATE_FORMAT, + STORAGE_KEYS.UI_TIME_FORMAT, +]);