Files
multica/packages/views/layout/use-dashboard-guard.ts
Naiyuan Qing 51bc5a818f fix(onboarding): decouple from workspace state and route invitees correctly (#1936)
PR #1868 conflated "has workspace" with "completed onboarding" —
restore `onboarded_at` as the single signal, and route invited users
through a dedicated /invitations page before they ever see onboarding.

- Backend: CreateWorkspace + AcceptInvitation atomically set
  onboarded_at alongside the member insert, establishing the
  invariant "member row exists ↔ onboarded_at != null" at the DB
  layer.
- Migration 065: one-shot backfill closes the dirty rows produced
  by PR #1868 (users with a workspace but onboarded_at == null).
- Entry points (web callback, login, desktop App): if onboarded_at
  is null, look up pending invitations by email and route to the
  new batch /invitations page; otherwise the resolver picks
  workspace / new-workspace as before.
- OnboardingPage: stops bouncing on hasWorkspaces; only
  hasOnboarded bounces. Unblocks the user from completing
  Step 3 (workspace creation) → Steps 4 / 5.
- StarterContentPrompt: only shows when the user is the solo
  member of the workspace, so invited users never get prompted to
  import starter content into someone else's workspace.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:05:53 +08:00

68 lines
2.3 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 { 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]);
return { user, isLoading, workspace };
}