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>
46 lines
1.6 KiB
TypeScript
46 lines
1.6 KiB
TypeScript
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("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("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);
|
|
});
|
|
});
|