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
- Copy the block files from the block detail page and paste them into your project.
- Install peer dependencies:
npm install @supabase/supabase-js zod resend4. Environment
| Variable | Required | Default | Purpose |
|---|---|---|---|
| NEXT_PUBLIC_SUPABASE_URL | yes | — | Your Supabase project URL |
| SUPABASE_SERVICE_ROLE_KEY | yes | — | Service role key — server-side only |
| RESEND_API_KEY | yes | — | Resend API key for sending invite emails |
| EMAIL_FROM | yes | — | Verified sender address, e.g. team@yourdomain.com |
| INVITE_TTL_HOURS | no | — | Invite token expiry in hours. Defaults to 48 |
5. Database
Run the MIGRATION constant in Supabase SQL Editor. Creates:
workspaces— workspace name, slug, ownerworkspace_members— user ↔ workspace with role columnworkspace_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
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)
}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
- Create a workspace and confirm a row appears in
workspacesand the creator appears inworkspace_memberswith roleowner. - Invite a user via
createInvite(). Confirm aninvitesrow and an invite email. - Accept the invite with the token. Confirm the user appears in
workspace_members. - Check a permission:
can(memberId, workspaceId, 'invite_member')— must returntruefor admin/owner andfalsefor 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_KEYserver-side only — it bypasses all RLS.