mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* fix(core): namespace recent-issues by workspace id in state The recent-issues store was using createWorkspaceAwareStorage, which namespaces the storage key by the current slug. That broke whenever a setter ran before WorkspaceRouteLayout's mount-effect set the slug — child effects fire before parent effects in React, so recordVisit from issue-detail wrote to the un-namespaced bare key, leaking visits across workspaces. The /<slug>/issues page then fanned out a per-id GET for each leaked id, mostly 404s. Move the namespacing into the store state itself (byWorkspace keyed by wsId), so reads/writes pick the right bucket at call time and don't depend on a singleton being set before module hydration. Drop the storage-level namespacing and the rehydration registration for this store. Add pruneWorkspaces to evict buckets for workspaces the user is no longer a member of, wired into useDashboardGuard so it runs whenever the workspace list resolves. As a defense against the prune never firing, cap the total tracked workspaces at 50 (LRU on oldest visit). Bump persist version to 1; the v0 entries don't know which workspace they belonged to, so migrate drops them and the cache repopulates as the user visits issues. * fix(core): fail closed on null slug in workspace-aware storage createWorkspaceAwareStorage used to fall back to the un-namespaced bare key when no workspace was active. That fallback let any setter firing before WorkspaceRouteLayout's mount-effect (e.g. a child component's own mount-effect) leak workspace-scoped data into a global slot visible to every workspace. Initial zustand persist hydration also ran in this null-slug window, so every store would read the polluted bare key on first load. Drop the fallback: null slug → getItem returns null, setItem/removeItem are no-ops. Stores still get a correct read via their registered rehydrate fn once setCurrentWorkspace fires. The remaining nine stores using this storage no longer rely on the bare-key path either; their data has always been intended to be workspace-scoped. --------- Co-authored-by: YYClaw <yyclaw0@gmail.com>
79 lines
2.8 KiB
TypeScript
79 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useNavigationStore } from "@multica/core/navigation";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import {
|
|
paths,
|
|
resolvePostAuthDestination,
|
|
useCurrentWorkspace,
|
|
useHasOnboarded,
|
|
} from "@multica/core/paths";
|
|
import { workspaceListOptions } from "@multica/core/workspace";
|
|
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
|
import { useNavigation } from "../navigation";
|
|
|
|
/**
|
|
* Auth + workspace gate for the dashboard.
|
|
*
|
|
* Redirect logic:
|
|
* - Auth still loading → wait
|
|
* - Not logged in → /login
|
|
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
|
|
* - Logged in but URL slug doesn't resolve to any workspace →
|
|
* `resolvePostAuthDestination(list, hasOnboarded)`:
|
|
* • un-onboarded → /onboarding
|
|
* • onboarded with workspaces → first workspace
|
|
* • onboarded with zero workspaces → /workspaces/new
|
|
*
|
|
* The "un-onboarded but in workspace" state is now physically impossible:
|
|
* CreateWorkspace and AcceptInvitation both atomically set `onboarded_at`
|
|
* inside the same transaction that inserts the `member` row.
|
|
* Existing dirty rows from PR #1868 are cleaned by migration 065.
|
|
*
|
|
* We read the workspace list query state directly (rather than relying on
|
|
* useCurrentWorkspace's null return) so we can distinguish "list loading"
|
|
* from "slug not found". Otherwise users could see a transient redirect
|
|
* before their workspace list arrives.
|
|
*/
|
|
export function useDashboardGuard() {
|
|
const { pathname, replace } = useNavigation();
|
|
const user = useAuthStore((s) => s.user);
|
|
const isLoading = useAuthStore((s) => s.isLoading);
|
|
const workspace = useCurrentWorkspace();
|
|
const hasOnboarded = useHasOnboarded();
|
|
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
|
|
...workspaceListOptions(),
|
|
enabled: !!user,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isLoading) return;
|
|
if (!user) {
|
|
replace(paths.login());
|
|
return;
|
|
}
|
|
if (!workspaceListFetched) return;
|
|
if (!workspace) {
|
|
replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
|
}
|
|
}, [user, isLoading, workspaceListFetched, workspace, workspaces, hasOnboarded, replace]);
|
|
|
|
useEffect(() => {
|
|
useNavigationStore.getState().onPathChange(pathname);
|
|
}, [pathname]);
|
|
|
|
// Drop recent-issues buckets for workspaces the user no longer belongs to.
|
|
// Runs once the workspace list resolves, and again whenever membership
|
|
// changes (workspace deleted, user kicked, user left).
|
|
useEffect(() => {
|
|
if (!workspaceListFetched) return;
|
|
useRecentIssuesStore
|
|
.getState()
|
|
.pruneWorkspaces(workspaces.map((w) => w.id));
|
|
}, [workspaceListFetched, workspaces]);
|
|
|
|
return { user, isLoading, workspace };
|
|
}
|