Teams and invitations
Team accounts, roles and permissions, invitations by email, ownership transfer and team deletion.
Every user has a personal account. Team accounts are shared workspaces that several users belong to, each with a role. The code is in packages/features/team-accounts, the pages in apps/web/app/[locale]/home/[account]/, and the tables and rules in apps/web/supabase/schemas/03-accounts.sql to 07-invitations.sql.
Switches
| Variable | Default in .env | Effect |
|---|---|---|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS | true | Team accounts on or off |
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION | true | Users can create teams |
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY | false | Skip the personal workspace; users land in a team |
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION | true | The owner can delete a team |
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING | true | Teams have a billing page |
Creating a team
Users create a team at /home/create-team. The team gets a slug from its name, and its pages live at /home/<slug>: home, members, settings and billing. The creator becomes the primary owner.
Roles and permissions
The seed creates two roles:
| Role | Can |
|---|---|
owner | Manage roles, billing, settings, members and invitations |
member | Manage settings and invitations |
Permissions are checked by the database, through the has_permission function in the row-level security policies, so they also hold for API calls. Members act only on people ranked below them, and nobody can remove or demote the primary owner. To add a role or change what a role can do, write a migration that inserts into public.roles and public.role_permissions.
Invitations
- A member with the
invites.managepermission invites one or more people by email, each with a role. - The app sends the invitation email itself (template
packages/email-templates/src/emails/invite.email.tsx, sent through the configured mailer). The link goes to/join/acceptand carries the invitation token plus a signature made on the server. - The invited person signs in or creates an account with that email and accepts on
/join. If the app has no email-only sign-in method (magic link or code), a new user is then asked to set up a password or another method (/identities).
Details worth knowing:
- Invitations expire after 7 days (
expires_atdefault in07-invitations.sql). From the members page you can renew, change the role of, or delete a pending invitation. - The invitation token is readable by members of the team, so the token alone is not enough for one-click sign-in. The accept route also needs the signature, an HMAC made with a key derived from
SUPABASE_SECRET_KEY(invitation-signature.ts). Only the link that was emailed can sign the invitee in. - On a per-seat plan, the seat quantity is set to the current member count when someone accepts an invitation or a member is removed (
account-per-seat-billing.service.ts). - You can add rules that block invitations, for example "team must have a subscription", in the policy registry at
packages/features/team-accounts/src/server/policies/policies.ts. None are switched on by default.
Ownership and deletion
- Transfer ownership. The primary owner can hand the team to another member. It needs a one-time code sent by email.
- Delete the team. The primary owner can delete the team when
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true. It also needs an emailed code. - Leave the team. Any member except the primary owner can leave.
The codes are stored hashed in public.nonces, work once, and are revoked after 5 wrong attempts. Deleting a personal account uses the same kind of code.
Tests
team-accounts.test.sql, memberships.test.sql, invitations.test.sql, update-membership.test.sql, delete-membership.test.sql and transfer-ownership.test.sql cover these rules in the database. The Playwright specs in apps/e2e/tests/team-accounts and apps/e2e/tests/invitations cover the pages.