mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user