You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a user is signed in but has zero rows in user_workspaces, the sidebar's WorkspaceSwitcher is stuck on "Loading…" forever, the rest of the dashboard half-loads broken (every workspace-scoped API call returns empty/error), and there's no actionable message or recovery path.
Reproducible paths:
A non-admin user is invited via the API but addUserToWorkspace silently fails (e.g. constraint trip, race) — inviteUser and addUserToWorkspace are two non-transactional writes (src/app/api/users/route.ts:47-54).
A workspace admin removes a user from their only workspace.
A workspace gets deleted that was the user's only membership (FK cascade).
Note: global admins don't hit this state because /api/workspaces returns all workspaces for global admins regardless of memberships (src/app/api/workspaces/route.ts:16-19). The bug only bites non-admin users.
Root cause
src/components/workspace-switcher.tsx:38 conflates "still loading" with "loaded but no workspaces":
isLoading flips to false after the API fetch, but activeWorkspace stays null when workspaces.length === 0, so the UI is permanently stuck.
Proposed fix
Layer 1 — distinguish empty state in the switcher
Change WorkspaceSwitcher to render different states for loading vs. empty.
Layer 2 — catch upstream at the dashboard layout (preferred primary fix)
A user with zero memberships should never land in the dashboard. Add a guard in src/app/dashboard/layout.tsx (or in WorkspaceProvider) that, when !isLoading && workspaces.length === 0, renders a dedicated NoWorkspacesPage instead of the dashboard shell.
NoWorkspacesPage:
States the situation plainly ("You don't have access to any workspace. Ask an admin to add you, or sign out and try a different account.")
Does not render the sidebar/topnav (so the WorkspaceSwitcher never has to handle this state).
Does not depend on workspace context.
Includes a sign-out button.
Layer 2 makes layer 1 unnecessary — the switcher never reaches the empty state because the dashboard layout intercepts it first. We can still keep the layer-1 split for defense in depth.
Layer 3 — root-cause hygiene (could be split into a separate issue if preferred)
These prevent the broken state from arising in the first place:
Atomic invite. Wrap inviteUser + addUserToWorkspace in db.transaction() (src/app/api/users/route.ts:47-54). Today they're two independent writes — if the second fails, the user row exists with no membership. Same audit finding #15.
Last-workspace removal guard.removeUserFromWorkspace should warn (or refuse) when it would leave the user with zero memberships. Workspace UI should surface this.
Workspace deletion side-effects.deleteWorkspace should consider users who'd lose their last membership — either prevent the delete, migrate them to Global, or surface the count to the deleting admin.
Acceptance criteria
Layer 2: dashboard layout (or provider) renders a dedicated no-workspaces page instead of the broken dashboard when !isLoading && workspaces.length === 0. Page has a clear message and a sign-out button.
Layer 1: WorkspaceSwitcher no longer renders "Loading…" indefinitely; either renders nothing or a no-access affordance when reached in the empty state (defense in depth).
Manual repro: insert a user row with no user_workspaces row → sign in → land on no-workspaces page, not the dashboard. Sign-out works.
(Optional, can split) Layer 3: invite is transactional; last-workspace removal/deletion guards surfaced.
Problem
When a user is signed in but has zero rows in
user_workspaces, the sidebar'sWorkspaceSwitcheris stuck on "Loading…" forever, the rest of the dashboard half-loads broken (every workspace-scoped API call returns empty/error), and there's no actionable message or recovery path.Reproducible paths:
addUserToWorkspacesilently fails (e.g. constraint trip, race) —inviteUserandaddUserToWorkspaceare two non-transactional writes (src/app/api/users/route.ts:47-54).INSERT INTO "user"without a matchinguser_workspacesrow (test setup pitfall — caught this exact issue during PR fix(auth): unblock invited users and bootstrap first admin #2 testing).Note: global admins don't hit this state because
/api/workspacesreturns all workspaces for global admins regardless of memberships (src/app/api/workspaces/route.ts:16-19). The bug only bites non-admin users.Root cause
src/components/workspace-switcher.tsx:38conflates "still loading" with "loaded but no workspaces":isLoadingflips tofalseafter the API fetch, butactiveWorkspacestaysnullwhenworkspaces.length === 0, so the UI is permanently stuck.Proposed fix
Layer 1 — distinguish empty state in the switcher
Change
WorkspaceSwitcherto render different states for loading vs. empty.Layer 2 — catch upstream at the dashboard layout (preferred primary fix)
A user with zero memberships should never land in the dashboard. Add a guard in
src/app/dashboard/layout.tsx(or inWorkspaceProvider) that, when!isLoading && workspaces.length === 0, renders a dedicatedNoWorkspacesPageinstead of the dashboard shell.NoWorkspacesPage:Layer 2 makes layer 1 unnecessary — the switcher never reaches the empty state because the dashboard layout intercepts it first. We can still keep the layer-1 split for defense in depth.
Layer 3 — root-cause hygiene (could be split into a separate issue if preferred)
These prevent the broken state from arising in the first place:
inviteUser+addUserToWorkspaceindb.transaction()(src/app/api/users/route.ts:47-54). Today they're two independent writes — if the second fails, the user row exists with no membership. Same audit finding #15.removeUserFromWorkspaceshould warn (or refuse) when it would leave the user with zero memberships. Workspace UI should surface this.deleteWorkspaceshould consider users who'd lose their last membership — either prevent the delete, migrate them to Global, or surface the count to the deleting admin.Acceptance criteria
!isLoading && workspaces.length === 0. Page has a clear message and a sign-out button.WorkspaceSwitcherno longer renders "Loading…" indefinitely; either renders nothing or a no-access affordance when reached in the empty state (defense in depth).userrow with nouser_workspacesrow → sign in → land on no-workspaces page, not the dashboard. Sign-out works.