From 70e01bec47bee06b4fa2c0a8df85987bc56ea385 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:35:11 +0800 Subject: [PATCH] fix(desktop): validate persisted tab slugs against current workspace list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop tabs persist their full path to localStorage (multica_tabs), so a tab path like /naiyuan/issues survives app restarts, account switches, and workspace deletions. Any stale slug caused WorkspaceRouteLayout to render NoAccessPage immediately on login — the user saw "Workspace not available" every time they opened the app, with no way to recover except manually opening a new tab or clearing localStorage. Root cause: persisted URL strings outlive the server-state they reference. The auth initializer fetches a fresh workspace list on every startup, but nothing validated the tab paths against it. Fix: add tab-store.validateWorkspaceSlugs(validSlugs). Runs on every change to the workspace list query data (login, background refetch, realtime workspace:deleted). Any tab whose first path segment isn't in the valid slug set is reset to `/`, where IndexRedirect picks a live workspace (or /new-workspace if the user has none). Idempotent, so over-triggering is safe. Tabs on global paths (/login, /new-workspace, /invite/...) are left alone. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/renderer/src/App.tsx | 14 +++++++ .../src/renderer/src/stores/tab-store.ts | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 32dbddb74..d623e1fd1 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -10,6 +10,7 @@ import { Toaster } from "sonner"; import { DesktopLoginPage } from "./pages/login"; import { DesktopShell } from "./components/desktop-layout"; import { UpdateNotification } from "./components/update-notification"; +import { useTabStore } from "./stores/tab-store"; function AppContent() { const user = useAuthStore((s) => s.user); @@ -81,6 +82,19 @@ function AppContent() { enabled: !!user, }); const wsCount = workspaces?.length ?? 0; + + // Validate persisted tab paths against the current user's workspace list. + // Tabs survive across app restarts and account switches (persisted to + // localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may + // reference a workspace the current user can't access — showing + // NoAccessPage every time they open the app. Reset any such tab to `/` + // so IndexRedirect picks a valid workspace. Runs on every workspace list + // change (login, refetch, realtime workspace:deleted); idempotent. + useEffect(() => { + if (!workspaces) return; + const validSlugs = new Set(workspaces.map((w) => w.slug)); + useTabStore.getState().validateWorkspaceSlugs(validSlugs); + }, [workspaces]); // null = undecided (pre-login or list hasn't settled yet) // true = session started with zero workspaces; next transition to >=1 triggers restart // false = session started with >=1 workspace, OR we've already restarted; skip diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index e057f85f7..519b0e483 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -39,6 +39,15 @@ interface TabStore { updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void; /** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */ moveTab: (fromIndex: number, toIndex: number) => void; + /** + * Reset any tab whose first path segment references a workspace slug the + * current user doesn't have access to. Called after login + workspace list + * is populated (and on every subsequent list change, e.g. realtime + * workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks + * a valid workspace; tabs on global paths (/login, /new-workspace, etc.) + * are untouched. + */ + validateWorkspaceSlugs: (validSlugs: Set) => void; } // --------------------------------------------------------------------------- @@ -182,6 +191,36 @@ export const useTabStore = create()( if (fromIndex === toIndex) return; set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) })); }, + + validateWorkspaceSlugs(validSlugs) { + const { tabs } = get(); + let changed = false; + const nextTabs = tabs.map((t) => { + // Skip tabs on non-workspace-scoped paths — nothing to validate. + if (t.path === "/" || isGlobalPath(t.path)) return t; + + const firstSegment = t.path.split("/").filter(Boolean)[0] ?? ""; + if (validSlugs.has(firstSegment)) return t; + + // Stale slug: dispose the old router and replace with a fresh one + // pointing at `/`. IndexRedirect will send the tab to a valid + // workspace (or /new-workspace if the user now has none). + changed = true; + t.router.dispose(); + return { + ...t, + path: DEFAULT_PATH, + title: "Issues", + icon: resolveRouteIcon(DEFAULT_PATH), + router: createTabRouter(DEFAULT_PATH), + historyIndex: 0, + historyLength: 1, + }; + }); + + if (!changed) return; + set({ tabs: nextTabs }); + }, }), { name: "multica_tabs",