Auth & Users · Advanced

Team Workspaces

Multi-tenant workspace management with RBAC (owner / admin / member / viewer), member invitations, role management, and a full dashboard UI — backed by Supabase with row-level security.

Read the Getting Access guideif you haven't yet.

1. Get the file

Sign in at marrowstack.dev, open the Team Workspaces block, and click Copy all files. Paste the files into your project. Exports include server functions, React components, and a SQL migration.

2. Prerequisites

  • Next.js 14 or 15, App Router
  • Supabase project
  • Resend account with a verified sender domain for invite emails
  • Auth session already implemented (to identify the acting user)

3. Install

  1. Copy the block files from the block detail page and paste them into your project.
  2. Install peer dependencies:
bash
npm install @supabase/supabase-js zod resend

4. Environment

VariableRequiredDefaultPurpose
NEXT_PUBLIC_SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — server-side only
RESEND_API_KEYyesResend API key for sending invite emails
EMAIL_FROMyesVerified sender address, e.g. team@yourdomain.com
INVITE_TTL_HOURSnoInvite token expiry in hours. Defaults to 48

5. Database

Run the MIGRATION constant in Supabase SQL Editor. Creates:

  • workspaces — workspace name, slug, owner
  • workspace_members — user ↔ workspace with role column
  • workspace_invites — pending invites with expiring tokens

RLS is enabled on all tables. Service role writes bypass RLS. Anon reads are blocked.

6. Wire it in

app/api/workspace/invite/route.ts
import { createInvite, acceptInvite } from '@/lib/team'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'

// POST — actor must be owner or admin
export async function POST(req: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })

  const { workspaceId, email, role } = await req.json()
  const invite = await createInvite({
    workspaceId,
    actorId: session.user.id,
    email,
    role,
  })
  return NextResponse.json(invite)
}

// PATCH — called with the invite token from the email link
export async function PATCH(req: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })

  const { token } = await req.json()
  const member = await acceptInvite({ token, userId: session.user.id })
  return NextResponse.json(member)
}
app/api/workspace/members/route.ts
import { setMemberRole, removeMember, can } from '@/lib/team'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'

export async function PATCH(req: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })

  const { workspaceId, memberId, role } = await req.json()
  // can() enforces that only owners/admins can change roles
  const allowed = await can(session.user.id, workspaceId, 'manage_roles')
  if (!allowed) return new NextResponse('Forbidden', { status: 403 })

  const updated = await setMemberRole({ workspaceId, memberId, role })
  return NextResponse.json(updated)
}

7. Verify it works

  1. Create a workspace and confirm a row appears in workspaces and the creator appears in workspace_members with role owner.
  2. Invite a user via createInvite(). Confirm an invites row and an invite email.
  3. Accept the invite with the token. Confirm the user appears in workspace_members.
  4. Check a permission: can(memberId, workspaceId, 'invite_member') — must return true for admin/owner and false for viewer.

8. Failure modes & fixes

InviteExpiredError

Cause: The invite token is older than the configured TTL (48 hours by default).

Fix: Re-send the invite. Adjust INVITE_TTL_HOURS if your flow needs longer.

InsufficientPermissionsError

Cause: The acting user does not have the required role for this operation.

Fix: Check role with can() before calling privileged functions.

Email delivery failed

Cause: RESEND_API_KEY is wrong, or the sender domain is not verified in Resend.

Fix: Verify your domain in Resend Dashboard → Domains.

relation 'workspaces' does not exist

Cause: The migration hasn't been run.

Fix: Run the MIGRATION constant in Supabase SQL Editor.

9. Security responsibilities

  • Always call can() before any privileged operation — the block does not apply middleware-level protection.
  • Deliver invite tokens over email only, never as visible URL query params in server logs.
  • Keep SUPABASE_SERVICE_ROLE_KEY server-side only — it bypasses all RLS.