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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user