fix(dashboard): scope localStorage key by userId to prevent cross-user layout bleed (#27)

New users on a shared device were picking up a previous user's stale
(potentially empty) dashboard layout from localStorage because the key
"capakraken_dashboard_v1" was not user-scoped.

- useDashboardLayout: key is now capakraken_dashboard_v1_{userId};
  userId is resolved via trpc.user.me before touching localStorage
- Initial state falls back to createDefaultDashboardLayout() until
  userId resolves, then hydrates from the user-scoped key
- DB layout still wins over localStorage when it has data (unchanged)
- E2E test suite covers: new-user flow, modal widget list, add widget
  persists after reload, cross-user localStorage isolation
- plan.md: added ticket #27 implementation plan

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-01 22:44:41 +02:00
parent d3bfa8ca98
commit 745be7ee8b
3 changed files with 360 additions and 11 deletions
+34 -10
View File
@@ -13,16 +13,19 @@ import {
} from "@capakraken/shared/schemas";
import { trpc } from "~/lib/trpc/client.js";
const STORAGE_KEY = "capakraken_dashboard_v1";
/** Returns a user-scoped localStorage key to prevent cross-user data bleed. */
function storageKey(userId: string): string {
return `capakraken_dashboard_v1_${userId}`;
}
function generateWidgetId() {
return `widget-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function loadFromStorage(): DashboardLayoutConfig | null {
function loadFromStorage(userId: string): DashboardLayoutConfig | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return null;
return normalizeDashboardLayout(JSON.parse(raw));
} catch {
@@ -30,10 +33,10 @@ function loadFromStorage(): DashboardLayoutConfig | null {
}
}
function saveToStorage(config: DashboardLayoutConfig) {
function saveToStorage(userId: string, config: DashboardLayoutConfig) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
localStorage.setItem(storageKey(userId), JSON.stringify(config));
} catch {}
}
@@ -50,10 +53,31 @@ export function shouldHydrateDashboardFromDb(params: {
}
export function useDashboardLayout() {
const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined };
const userId = meData?.id ?? null;
// Initial state: load from user-scoped localStorage once we have the userId.
// Before userId resolves, fall back to the default layout so the page is not blank.
const [config, setConfig] = useState<DashboardLayoutConfig>(createDefaultDashboardLayout);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasHydratedFromDbRef = useRef(false);
const hasLocalChangesBeforeHydrationRef = useRef(false);
const hasHydratedFromStorageRef = useRef(false);
// Once userId is known, hydrate from user-scoped localStorage (if no DB data yet).
useEffect(() => {
if (!userId || hasHydratedFromStorageRef.current || hasHydratedFromDbRef.current || hasLocalChangesBeforeHydrationRef.current) {
return;
}
const stored = loadFromStorage(userId);
if (stored) {
hasHydratedFromStorageRef.current = true;
setConfig(stored);
} else {
hasHydratedFromStorageRef.current = true;
}
}, [userId]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
@@ -81,8 +105,8 @@ export function useDashboardLayout() {
const dbConfig = normalizeDashboardLayout(remoteLayout);
hasHydratedFromDbRef.current = true;
setConfig(dbConfig);
saveToStorage(dbConfig);
}, [dbData]);
if (userId) saveToStorage(userId, dbConfig);
}, [dbData, userId]);
useEffect(() => () => {
if (saveTimeoutRef.current) {
@@ -96,12 +120,12 @@ export function useDashboardLayout() {
hasLocalChangesBeforeHydrationRef.current = true;
}
const newConfig = normalizeDashboardLayout(nextConfig);
saveToStorage(newConfig);
if (userId) saveToStorage(userId, newConfig);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
saveMutation.mutate({ layout: newConfig });
}, 2000);
}, [saveMutation]);
}, [saveMutation, userId]);
const addWidget = useCallback((type: DashboardWidgetType) => {
setConfig((prev) => {