Team & Multi-Org

Team invites

Dashboard: /overview/settings/team. Internal API: /api/internal/team/*.

Invite flow

  1. Owner submits email + role at POST /api/internal/team/invite.
  2. Server:
    • Rejects if target already a member (409).
    • Upserts a team_invites row with a 48-char nanoid token, 7-day expiry.
    • Emails the invitee via the app's own sendEmail() using SYSTEM_FROM_EMAIL (or fallback).
    • Writes audit log entry (member.invited).
  3. Invitee lands on /invite?token=....
  4. POST /api/internal/team/accept { token, name?, password? }:
    • If the address already has an account — the caller must hold a session for that address. No session → 401 SIGN_IN_REQUIRED; a session for anyone else → 403 WRONG_ACCOUNT. The page turns both into a sign-in / switch-account button that returns to the invite via ?callbackUrl=. Then add as org_member.
    • Else create user (name + password required) with email_verified: now() and add as member.
  5. Invite marked accepted_at. Audit member.joined.
  6. The response carries org_id and points the active-org cookie at the invite's org, so the invitee lands in the org they just joined instead of their oldest membership (the sign-in default). Set only when a membership really stands, and only for the account that holds it: the signed-in invitee, or a brand-new account accepted with no session. A browser signed in as somebody else keeps its own active org. The invite page then does a full load into /overview.

The token proves invitation, not identity

The endpoint stays reachable without a session — a first-time invitee has no account to sign in to. But once the invited address exists, the token alone is not enough.

Without that split, whoever held the token could POST it and the membership was written for the existing account: the real person was joined to a stranger's org having never opened the email. That is not merely a nuisance membership — organizations.owner_id can be re-pointed onto a surviving member when an account is deleted, and ownership now also carries an uncapped billing entitlement (Billing → Comped accounts). The identity check is what keeps a non-consenting person out of that position.

Both refusals return before the claiming UPDATE, so a rejected attempt never burns a single-use invite.

The token is never written to the audit log

member.invited records the invited address in resource_id, not the token. The token is bearer-equivalent and GET /api/internal/audit is readable by every member of the org down to viewer. Rows written before this carry the raw token; they are not rewritten (resource_id is covered by the row signature) — the value is withheld on read instead, in the audit list and in both org exports. See Audit log → Invite tokens.

The address is also the better identifier: (org_id, email) is unique and the token rotates on every re-invite.

Schema

team_invites (src/lib/db/schema/team-invites.ts):

Column Notes
token PK — the link value
org_id scope
email invited address
role member / owner
invited_by user id
expires_at 7d default
accepted_at nullable

Unique (org_id, email) prevents duplicate invites; re-inviting rotates the token.

Multi-org membership

A user can belong to multiple orgs. The active org is tracked via a cookie (sendoka_active_org) rather than JWT — no re-login required to switch.

Resolution

getActiveOrgId(userId, fallbackOrgId) in src/lib/auth/active-org.ts:

  1. Read sendoka_active_org cookie.
  2. Verify the user has a matching org_members row.
  3. Use it; else fall back to the session's default org (oldest membership at sign-in time) while the user still belongs to it, else their oldest surviving membership. "" when they belong to no org at all (see No organization).

Endpoints

  • GET /api/internal/org — list the user's orgs + current active.
  • POST /api/internal/org/switch { org_id } — verify membership, set cookie, return success. Client should refresh.
  • POST /api/internal/team/accept — also points the cookie at the joined org (step 6 above).
  • DELETE /api/internal/org — after the delete, resolves where the owner lands exactly as their next request would (the sign-in default if they are still in it, else the oldest surviving membership), returns it as next_org: { id, name } | null, and points the cookie at it. The danger zone then reloads /overview with "Switching to name…", or signs out when next_org is null.

The cookie's attributes live in one place, setActiveOrgCookie() in src/lib/auth/active-org.ts, shared by all three writers.

UI

src/components/dashboard/org-switcher.tsx — an Organizations group at the top of the sidebar's account menu, rendered only for a user with 2+ memberships. The account button then shows the active org's name instead of "Account". Choosing an org calls /api/internal/org/switch and does a full load of /overview (not the current path: a resource URL from the old org would 404 in the new one). The command palette offers the same switch as Switch organization: name for every other org. Both go through src/components/dashboard/org-navigation.ts. The org list comes from the same GET /api/internal/org read RoleProvider already makes (useOrgs()).

The switcher was unmounted in the April 2026 shell redesign. With the sign-in default being the oldest membership, a user who accepted an invite into a second org had no way to reach it.

No organization

A signed-in user can belong to no org: they deleted the only one they owned, their last org was deleted by its owner, or they were removed from it. requireSession() is null for them, and every page under /overview used to send them to /login, which forwarded any session holder straight back to /overview: an ERR_TOO_MANY_REDIRECTS loop.

  • /no-org (src/app/no-org/) is where they land now. The overview layout and page send an org-less session there, and /login sends a session holder there instead of to its callback, unless the callback is /invite, since accepting an invite is how they get back into an org (signedInLanding() in src/lib/auth/landing.ts). The page explains the situation and offers Sign out and Delete my account (the DELETE /api/internal/user confirmation contract: password, or the account's email for a passwordless account). It sits outside /overview and outside the proxy matcher. Its one mutation still goes through /api/internal/*, which the 2FA confinement covers, so a session that owes the OAuth sign-in code is asked for it on the page first.
  • requireUserSession() (src/lib/auth/session.ts) is identity only ({ userId, email }, re-read from users) for the personal endpoints that must work without an org: DELETE /api/internal/user, POST /api/internal/user/data-export, /api/internal/sessions (list, revoke one, revoke others), and /api/internal/two-factor/*. Their audit rows are filed by logPersonalAudit() under the user's active org, or, with no org to file them in, written to the operational log as audit.personal_no_org. Removing a member (from the team page or by SCIM) revokes none of their sessions, so the data export keeps the audit rows they authored in an org they have left but withholds those rows' metadata (metadata: null, metadata_withheld: "no_longer_a_member"): that is the org's data, not theirs.
  • Deleting an org lands the owner in their next org (next_org above), or signs them out when there is none. Erasing an account always signs out.

Caveats

  • Dashboard pages and most internal APIs still use session.user.orgId directly — they see the sign-in-time default, not the cookie-overridden value. Migrate them to getActiveOrgId() as a follow-up (tracked in the team's internal gap register).
  • Members can't be removed yet — only added via invite.
  • Role privileges are documented in permissions — four roles (owner / developer / member / viewer), gated by requireOwnerSession / requireDeveloperSession / requireSession.