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) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-26 15:24:33 +00:00
co-authored by Claude Opus 5
parent f4f7cbd8d6
commit 4f59356cf0
8 changed files with 518 additions and 86 deletions
@@ -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<typeof useTheme>;
custom: ReturnType<typeof useCustomTheme>;
}
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(
<ThemeProvider>
<Probe
onRender={(next) => {
captured = next;
}}
/>
</ThemeProvider>,
);
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);
});
});
+17 -9
View File
@@ -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;
}
+36 -15
View File
@@ -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<ThemeVarOverrides>(() =>
parseVarsJson(storage.get(storage.KEYS.UI_CUSTOM_THEME_VARS)),
parseVarsJson(customThemeCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)),
);
const [localCss, setLocalCss] = useState<string>(
() => 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<ReturnType<typeof setTimeout> | undefined>(undefined);
const cssTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
+7 -17
View File
@@ -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<DateTimeFormatContextValue | null>(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],
+40 -9
View File
@@ -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);
});
});
+68 -35
View File
@@ -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<ThemeId>(getInitialTheme);
const [previewThemeState, setPreviewThemeState] = useState<ThemeId | null>(null);
const [textScalePreference, setTextScalePreference] = useState<TextScale>(() =>
parseTextScale(storage.get(storage.KEYS.UI_TEXT_SCALE)),
);
const [textWeightPreference, setTextWeightPreference] = useState<TextWeight>(() =>
parseTextWeight(storage.get(storage.KEYS.UI_TEXT_WEIGHT)),
);
const [highContrastPreference, setHighContrastPreference] = useState<boolean>(() =>
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<ThemeId>(() =>
getInitialTheme(cacheOwner),
);
const [previewThemeState, setPreviewThemeState] = useState<ThemeId | null>(null);
const [textScalePreference, setTextScalePreference] = useState<TextScale>(() =>
parseTextScale(appearanceCache.get(storage.KEYS.UI_TEXT_SCALE, cacheOwner)),
);
const [textWeightPreference, setTextWeightPreference] = useState<TextWeight>(() =>
parseTextWeight(appearanceCache.get(storage.KEYS.UI_TEXT_WEIGHT, cacheOwner)),
);
const [highContrastPreference, setHighContrastPreference] = useState<boolean>(() =>
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;
}
+88
View File
@@ -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();
});
});
+82 -1
View File
@@ -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,
]);