Skip to content

feat(workspace): multi-user workspaces with invites and RBAC#70

Merged
nadyyym merged 3 commits into
stagingfrom
feature/BETON-multi-user-workspaces
Apr 15, 2026
Merged

feat(workspace): multi-user workspaces with invites and RBAC#70
nadyyym merged 3 commits into
stagingfrom
feature/BETON-multi-user-workspaces

Conversation

@nadyyym

@nadyyym nadyyym commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

  • Multi-user workspaces: Users can belong to multiple workspaces. Dropped UNIQUE(user_id) constraint on workspace_members.
  • Invite system: Email invites (via seqd) and shareable invite links with token-based acceptance, expiry, and usage limits.
  • Domain auto-join: Workspace owners can claim email domains. New users with matching domains see a join suggestion instead of auto-creating a workspace.
  • Workspace switching: Active workspace stored in cookie, switcher dropdown in header, /api/workspace/switch endpoint.
  • RBAC: 3-tier permission model (owner > admin > member) enforced on invite, member management, domain, and integration routes.
  • Settings UI: Full member management page replacing the "coming soon" placeholder — members table, pending invites, invite form, domain claims.

New files (17)

  • supabase/migrations/20260406112258_multi_user_workspaces.sql — schema migration
  • src/lib/auth/permissions.ts — RBAC permission map and route guards
  • src/lib/email/client.ts — seqd transactional email HTTP client
  • src/lib/utils/email-domains.ts — shared PUBLIC_EMAIL_DOMAINS set
  • 13 API routes under /api/workspace/ (invites, members, domains, switch, list, join-by-domain, create-personal)
  • 2 pages: /invite/[token] (accept page), /join (domain suggestion page)

Modified files (16)

  • Session layer (constants.ts, session.ts) — multi-workspace SessionUser, active workspace cookie
  • Supabase helpers (server.ts, helpers.ts) — removed .single(), active workspace resolution
  • Auth callback — domain matching before workspace creation
  • Middleware — forwards x-workspace-id header, exempts invite pages
  • Dashboard layout + header — workspace switcher dropdown, workspace list prop
  • Analytics (gtm.ts) — 7 new workspace lifecycle events
  • Integration routes — RBAC guards for write operations

Database changes (staging applied)

  • Dropped workspace_members_user_id_unique constraint
  • Created workspace_invites table with RLS
  • Created workspace_domains table with RLS

Test plan

  • Sign up fresh → workspace auto-created (backward compat)
  • Invite user by email → they receive email → click link → join workspace
  • Generate invite link → share → new user clicks → signs up → joins
  • Claim domain → new user with matching email signs up → sees suggestion → joins
  • Switch between workspaces → data isolation works
  • Remove member → they lose access
  • Change role → permissions update
  • Member tries admin action → 403 Forbidden
  • Build passes (npm run build)

🤖 Generated with Claude Code

Enable multiple users per workspace with email + link invites,
domain-based auto-join suggestions, workspace switching, and
role-based access control (owner/admin/member).

- Drop UNIQUE(user_id) constraint on workspace_members
- Add workspace_invites and workspace_domains tables with RLS
- Refactor session layer for multi-workspace (active workspace cookie)
- Add 13 new API routes for invites, members, domains, switching
- Add workspace switcher in header and member management settings page
- Add invite accept page and domain-based join page
- Add RBAC permissions module applied to workspace and integration routes
- Add seqd email client for transactional invite emails
- Add 7 PostHog/GTM analytics events for workspace lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beton-inspector Ready Ready Preview, Comment Apr 15, 2026 11:06am

Request Review

@nadyyym nadyyym left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Critical (3 runtime bugs)

C1. Column name mismatch: created_at vs joined_at

workspace_members has joined_at, not created_at. Three routes will crash at runtime with PostgREST 400:

  • src/app/api/workspace/members/route.ts -- .select('user_id, role, created_at'), returns m.created_at as joinedAt
  • src/app/api/workspace/members/[userId]/route.ts -- .select('user_id, role, created_at') in PATCH response
  • src/app/api/workspace/list/route.ts -- .order('created_at', ...), .select('workspace_id, role, created_at, ...')

Fix: Replace created_at with joined_at in all three files.

C2. Missing added_by in domain claim INSERT

workspace_domains.added_by is UUID NOT NULL in migration, but the POST handler omits it:

// src/app/api/workspace/domains/route.ts
.insert({ workspace_id: workspaceId, domain: normalizedDomain } as never)

Will crash with NOT NULL constraint violation. Fix: Add added_by: user.id.

C3. verified column not defined in migration

workspace_domains has no verified column in the migration, but domains/route.ts selects it and the UI renders it. Fix: Add verified BOOLEAN DEFAULT false to the CREATE TABLE, or remove from select/UI.


Important

I4. Invite token entropy -- crypto.randomUUID() gives 122 bits. Consider crypto.randomBytes(32).toString('hex') (256 bits) for URL-exposed security tokens.

I5. Race condition on use_count increment

use_count: (invite.use_count ?? 0) + 1  // read-then-write, not atomic

Two concurrent acceptances can both read use_count=0 and both pass the limit check. Fix: Use a Postgres expression for atomic increment, or a WHERE use_count < max_uses guard.

I6. Email invite accepted by wrong user -- No check that user.email === invite.email for email-type invites. Any user with the token can accept. If intentional (token-as-proof), document it. If not, add email match check.

I7. adminClient.auth.admin.listUsers() scalability -- Fetches ALL users in the Supabase project, filters client-side. Default pagination is 50 per page. With more than 50 total users, some workspace members will silently disappear from the list. Fix: Use getUserById() per member, or paginate with filtering.

I9. No DELETE endpoint for workspace domains -- RLS allows DELETE, but no API route or UI button exists. Once claimed, a domain cannot be unclaimed without direct DB access.

Minor

  • 16x as any casts (expected until types regenerated, but track cleanup)
  • No email format validation on invite creation
  • InvitesSection uses key={inviteKey} for refresh instead of callback pattern

Positive

  • Clean RBAC architecture -- static permission map, requireRole() helper, owner-only for role changes and domain management
  • Idempotent invite acceptance -- composite PK prevents duplicate membership
  • Well-designed domain auto-join -- redirects to choice page, public email domains blocked
  • Secure workspace switching -- httpOnly cookie, verified on every request via getUserWorkspace()
  • Backward compatibility preserved -- single-workspace users unaffected, auth callback still creates workspaces

Security Assessment

  • RBAC prevents escalation (members cannot self-promote)
  • Cookie is httpOnly with sameSite: lax
  • No stale cache risk (role queries DB on every request)
  • IDOR protection via requireWorkspace() on all routes
  • Domain claims have no DNS/email verification (acceptable for MVP, document as limitation)

Verdict

Not ready to merge. The 3 critical bugs (C1-C3) are P0 -- they will crash the members list, domain claiming, and workspace list for all users. All are simple fixes (column rename, add field, add column). After fixing, this PR is solid for staging.

Review by Claude Code

@nadyyym nadyyym self-assigned this Apr 15, 2026
@nadyyym nadyyym marked this pull request as draft April 15, 2026 07:26
@nadyyym

nadyyym commented Apr 15, 2026

Copy link
Copy Markdown
Member Author

Browser Testing — Vercel Preview

Tested the preview deployment at beton-inspector-git-feature-beton-multi-user-wo-2b573c-getbeton.vercel.app.

Findings

1. Workspace settings page: infinite loading spinner

Navigating to /settings/workspace renders the page layout (sidebar with the new "Workspace" nav item) but the content area shows an infinite spinner. The page never loads workspace members, invites, or domains.

2. Invite accept page: stuck on "Loading invite..."

Navigating to /invite/test-fake-token renders the page but stays on "Loading invite..." forever. The page correctly renders outside the dashboard layout (no sidebar), but the API call to fetch invite info never returns.

3. ALL API routes hang indefinitely (not just workspace routes)

Tested from the browser console using fetch() with 8-second timeout:

Endpoint Result
/api/workspace/members Timeout (no response)
/api/workspace/list Timeout (no response)
/api/workspace/domains Timeout (no response)
/api/workspace/invites Timeout (no response)
/api/signals (pre-existing) Timeout (no response)
/api/integrations/posthog (pre-existing) Timeout (no response)
/api/user/session 404 in 423ms (responds normally)

The homepage HTML renders fine (200 in 0.7s from curl). Only API route serverless functions hang.

4. Cannot confirm if PR-specific or Vercel environment issue

Other PR preview deployments (PR #66, PR #69) have expired (DEPLOYMENT_NOT_FOUND), so a direct comparison was not possible. The middleware code looks correct — it calls supabase.auth.getUser() and returns the response regardless of auth state. The hanging is likely caused by:

  • Supabase connection timeout from the preview's serverless functions (env var misconfiguration or connection pool exhaustion)
  • OR Vercel preview deployment protection blocking serverless function execution

5. UI observations (from what rendered)

  • New "Workspace" nav item appears correctly in Settings sidebar
  • Sidebar navigation structure is clean (Integrations, MCP, Billing, Workspace, Danger Zone)
  • Invite page renders outside dashboard layout as expected (no sidebar)
  • Demo mode banner ("Viewing demo data") displays correctly

Recommendation

To properly test the multi-user workspace features, either:

  1. Sign in with Google on the preview to test with an authenticated session
  2. Redeploy with AUTH_BYPASS=true env var for testing
  3. Test locally with npm run dev against staging Supabase

Browser test by Claude Code

…handlers

Rename invites/[token]/info to invites/[id]/info to match the sibling
[id] dynamic segment. Next.js requires consistent param names at the
same path level — the mismatch caused a runtime router crash that
silently broke every route.ts handler on Vercel (pages unaffected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
C1: workspace_members uses `joined_at` not `created_at` — fixes 500 on
    /members, /members/[userId] PATCH, and /list endpoints
C2: add missing `added_by: user.id` to workspace_domains INSERT — fixes
    NOT NULL constraint violation on domain claim
C3: remove references to non-existent `verified` column on
    workspace_domains — fixes 500 on /domains endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nadyyym

nadyyym commented Apr 15, 2026

Copy link
Copy Markdown
Member Author

@Sashmark97 TL;DR — 3 critical runtime bugs fixed, all verified:

  1. created_atjoined_atworkspace_members uses joined_at but 3 routes selected created_at. Caused 500 on /members, /list, and /members/[userId] PATCH.
  2. Missing added_by — domain claim INSERT omitted the NOT NULL added_by field. Every domain claim would crash.
  3. Phantom verified column — code selected a column that doesn't exist on workspace_domains. Caused 500 on /domains.

Tested via direct Supabase queries (correct columns return rows, old columns return error 42703) and Vercel preview deploy (all routes return clean 401 instead of 500). Browser confirmed the old code shows "Failed to fetch members" / "Failed to fetch domains" on the settings page.

Ready to merge to staging.

@nadyyym nadyyym marked this pull request as ready for review April 15, 2026 13:21
@nadyyym nadyyym merged commit 04063d7 into staging Apr 15, 2026
4 of 5 checks passed
@nadyyym nadyyym deleted the feature/BETON-multi-user-workspaces branch April 15, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants