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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions