Skip to content

User with zero workspace memberships sees stuck "Loading…" instead of an empty state #4

@RisingOrange

Description

@RisingOrange

Problem

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).
  • Manual INSERT INTO "user" without a matching user_workspaces row (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/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":

if (isLoading || !activeWorkspace) {
  return <... "Loading..." />;
}

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions