Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
70e01bec47 fix(desktop): validate persisted tab slugs against current workspace list
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) <noreply@anthropic.com>
2026-04-16 19:35:11 +08:00
2 changed files with 53 additions and 0 deletions

View File

@@ -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

View File

@@ -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<string>) => void;
}
// ---------------------------------------------------------------------------
@@ -182,6 +191,36 @@ export const useTabStore = create<TabStore>()(
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",