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>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
|
|
import { useOptionalAuth } from "@/hooks/useAuth";
|
|
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";
|
|
|
|
export type ThemeVarOverrides = Partial<Record<ThemeToken, string>>;
|
|
|
|
interface UseCustomThemeResult {
|
|
vars: ThemeVarOverrides;
|
|
customCss: string;
|
|
/** Update a single token (instant, debounced persist). */
|
|
setVar: (token: ThemeToken, value: string) => void;
|
|
/** Remove a single token override. */
|
|
resetVar: (token: ThemeToken) => void;
|
|
/** Replace all variable overrides at once. */
|
|
setAllVars: (vars: ThemeVarOverrides) => void;
|
|
/** Update the raw CSS. */
|
|
setCustomCss: (css: string) => void;
|
|
/** Reset all custom overrides. */
|
|
resetAll: () => void;
|
|
/** Import a full set of overrides (from file or catalog). */
|
|
importOverrides: (vars: ThemeVarOverrides, css: string) => void;
|
|
/** Whether local state differs from last-persisted state. */
|
|
isDirty: boolean;
|
|
}
|
|
|
|
const EMPTY_VARS: ThemeVarOverrides = {};
|
|
|
|
export function useCustomTheme(): UseCustomThemeResult {
|
|
const auth = useOptionalAuth();
|
|
// 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 });
|
|
const apiVars = apiSettings?.ui_custom_theme_vars;
|
|
const apiCss = apiSettings?.ui_custom_css;
|
|
const settingMutation = useSetSetting();
|
|
|
|
// Local draft state (for instant updates without waiting for API)
|
|
const [localVars, setLocalVars] = useState<ThemeVarOverrides>(() =>
|
|
parseVarsJson(customThemeCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)),
|
|
);
|
|
const [localCss, setLocalCss] = useState<string>(
|
|
() => customThemeCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "",
|
|
);
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
|
|
// Debounce timers
|
|
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);
|
|
customThemeCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed), cacheOwner);
|
|
}
|
|
}, [loadApi, apiVars, cacheOwner]);
|
|
|
|
useEffect(() => {
|
|
if (loadApi && apiCss !== undefined && apiCss !== null) {
|
|
setLocalCss(apiCss);
|
|
customThemeCache.set(storage.KEYS.UI_CUSTOM_CSS, apiCss, cacheOwner);
|
|
}
|
|
}, [loadApi, apiCss, cacheOwner]);
|
|
|
|
const persistVars = useCallback(
|
|
(vars: ThemeVarOverrides) => {
|
|
const json = JSON.stringify(vars);
|
|
customThemeCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, json, cacheOwner);
|
|
settingMutation.mutate({ key: "ui_custom_theme_vars", value: json });
|
|
},
|
|
[settingMutation, cacheOwner],
|
|
);
|
|
|
|
const persistCss = useCallback(
|
|
(css: string) => {
|
|
const safe = sanitizeCss(css);
|
|
customThemeCache.set(storage.KEYS.UI_CUSTOM_CSS, safe, cacheOwner);
|
|
settingMutation.mutate({ key: "ui_custom_css", value: safe });
|
|
},
|
|
[settingMutation, cacheOwner],
|
|
);
|
|
|
|
const setVar = useCallback(
|
|
(token: ThemeToken, value: string) => {
|
|
const next = { ...localVars, [token]: value };
|
|
setLocalVars(next);
|
|
setIsDirty(true);
|
|
clearTimeout(varsTimerRef.current);
|
|
varsTimerRef.current = setTimeout(() => {
|
|
persistVars(next);
|
|
setIsDirty(false);
|
|
}, 500);
|
|
},
|
|
[localVars, persistVars],
|
|
);
|
|
|
|
const resetVar = useCallback(
|
|
(token: ThemeToken) => {
|
|
const next = { ...localVars };
|
|
delete next[token];
|
|
setLocalVars(next);
|
|
persistVars(next);
|
|
setIsDirty(false);
|
|
},
|
|
[localVars, persistVars],
|
|
);
|
|
|
|
const setAllVars = useCallback(
|
|
(vars: ThemeVarOverrides) => {
|
|
setLocalVars(vars);
|
|
persistVars(vars);
|
|
setIsDirty(false);
|
|
},
|
|
[persistVars],
|
|
);
|
|
|
|
const setCustomCss = useCallback(
|
|
(css: string) => {
|
|
setLocalCss(css);
|
|
setIsDirty(true);
|
|
clearTimeout(cssTimerRef.current);
|
|
cssTimerRef.current = setTimeout(() => {
|
|
persistCss(css);
|
|
setIsDirty(false);
|
|
}, 1000);
|
|
},
|
|
[persistCss],
|
|
);
|
|
|
|
const resetAll = useCallback(() => {
|
|
setLocalVars({});
|
|
setLocalCss("");
|
|
persistVars({});
|
|
persistCss("");
|
|
setIsDirty(false);
|
|
}, [persistVars, persistCss]);
|
|
|
|
const importOverrides = useCallback(
|
|
(vars: ThemeVarOverrides, css: string) => {
|
|
setLocalVars(vars);
|
|
setLocalCss(css);
|
|
persistVars(vars);
|
|
persistCss(css);
|
|
setIsDirty(false);
|
|
},
|
|
[persistVars, persistCss],
|
|
);
|
|
|
|
return {
|
|
// 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,
|
|
setCustomCss,
|
|
resetAll,
|
|
importOverrides,
|
|
isDirty,
|
|
};
|
|
}
|