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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user