Members & invitations

Spanlens is multi-user out of the box. Invite teammates by email, hand out roles, switch between workspaces, and watch every membership event in the audit log. Nothing here costs extra — it’s the same flow on the Free plan as on Enterprise.

Roles

Every membership row carries one of three roles. The role is checked server-side via the requireRole middleware on every write endpoint, and surfaces in the dashboard via<PermissionGate need="…"> so disabled buttons / hidden settings stay consistent with what the API will actually let through.

RoleReadEdit dataManage members & billing
admin
editor
viewer

The last admin in a workspace is protected — the API rejects any attempt to demote or remove them. Promote a second admin first if you need to leave.

Inviting a teammate

Settings → Members+ Invite member. Enter an email and pick a role. We POST to /api/v1/organizations/:orgId/invitations; the server creates an org_invitations row with a 7-day expiry and a sha256-hashed token. The raw token only ever lives in the email URL — a database leak cannot turn into working invite links.

Two ways the recipient sees it

  1. Email link. If you have RESEND_API_KEY configured, the invitee gets an email with an Accept button. The link goes to /invite?token=…, which verifies the token, checks the email matches the signed-in account, then shows Accept / Decline.
  2. Dashboard banner. Even if the email never arrives — bounce, spam folder, admin DM’d the invite instead — the recipient’s next dashboard navigation surfaces a banner across the top: “Acme Inc. invited you as editor.” with Accept / Decline buttons. The banner queries GET /me/pending-invitations on every dashboard page so this catches every invite regardless of delivery channel.

Both paths converge on the same server-side handler. Accept inserts the org_members row, marks the invitation accepted, sets onboarded_at on the user’s profile (so the dashboard layout’s onboarding gate lets them through), and returns the joined organization id. The client writes that id to the sb-ws cookie and hard-reloads — the user lands in the joined workspace immediately.

Decline vs Dismiss

The dashboard banner has two negative actions and they are not the same:

  • Decline — DELETEs the invitation row. The user will not see this invite again unless an admin re-invites the same email (which creates a new row).
  • ⨯ Dismiss — session-only hide. Refreshing the page brings the banner back. Use this for “I see it, just not now.”

First-signup behaviour

Brand-new users land on /onboarding after sign-up. The page checks for pending invitations on their email before showing the workspace-creation step:

  • Pending invites exist → an “You’ve been invited” screen lists them, each with Accept. There’s also a Skip & create my own workspace → button so a user who wants to keep a personal sandbox alongside their company workspace isn’t forced into either / or.
  • No pending invites → the standard 2-step onboarding (workspace name → optional survey).

Switching workspaces

The active workspace is stored in the sb-ws cookie. Both the Next.js middleware and the Hono authJwt middleware read it on every request to set the org scope, so a switch must be explicit and observable across the whole app.

Click the workspace box at the top-left of the sidebar and pick a workspace from the Workspaces section. The sidebar writes the new id to sb-ws and hard-reloads the page — middleware re-resolves, every TanStack Query cache is cleared, and the dashboard re-renders against the new org.

Need a fresh workspace (consultancy with a new client, separate prod / staging)? + New workspace in the same dropdown opens a modal; the server creates the org + admin membership + a default project in one round-trip.

Audit log

Settings → Audit log records every membership event with the actor, timestamp, and target. Inviting, accepting, declining, role changes, and member removal all show up. Free for all plans on the past 30 days; longer retention on Pro and above.

API reference

The dashboard is a thin client over a stable REST API. Use it directly if you need to script provisioning.

# Admin: list / send / cancel invitations
GET    /api/v1/organizations/:orgId/invitations
POST   /api/v1/organizations/:orgId/invitations    body: { email, role }
DELETE /api/v1/invitations/:id

# Recipient: list / accept / decline (signed-in user's email)
GET    /api/v1/me/pending-invitations
POST   /api/v1/me/pending-invitations/:id/accept
DELETE /api/v1/me/pending-invitations/:id

# Email-link variants (token in body)
GET    /api/v1/invitations/accept?token=...        (public, returns invite metadata)
POST   /api/v1/invitations/accept                  body: { token }
POST   /api/v1/invitations/decline                 body: { token }

# Members
GET    /api/v1/organizations/:orgId/members
PATCH  /api/v1/organizations/:orgId/members/:userId   body: { role }
DELETE /api/v1/organizations/:orgId/members/:userId
http

All endpoints require a Supabase JWT in the Authorization: Bearer … header. Admin-only routes additionally check the role server-side.


Email delivery is best-effort: when RESEND_API_KEY is not configured, the server skips the send and returns the accept URL as devAcceptUrl in the API response so an admin can hand-deliver it. Set up a verified Resend sender to avoid spam folders — see self-host env vars.