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 existing user by email — 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.

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 (first membership at sign-in time).

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.

UI

src/components/overview/org-switcher.tsx — dropdown in sidebar; only shows when user has 2+ memberships.

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 (see open gaps).
  • Members can't be removed yet — only added via invite.
  • No role-based permissions beyond owner / member — both roles currently have the same privileges.