Skip to content

Team Invitation Flow

This guide documents the complete lifecycle of a team invitation in show.fm, covering the host's invite action, the email delivery, the guest's acceptance page, and the authentication handoff.

Key Source Files:

  • API: src/api/routes/team/index.ts
  • Invite Page: src/routes/invite/[token]/+page.server.ts, +page.svelte
  • Auth Pages: src/routes/(auth)/login/, src/routes/(auth)/signup/
  • Auth Callback: src/routes/(auth)/auth/callback/+server.ts
  • Redirect Safety: src/lib/auth/redirect.ts
  • Email: src/lib/email/templates/team-invitation.ts

Flow Overview

Step 1: Host Creates Invitation

From the podcast dashboard, an owner or admin sends an invitation:

POST /api/team/:podcastId/invite
{
  "email": "[email protected]",
  "role": "admin",
  "message": "Welcome to the production team!"
}

The API:

  1. Validates the email and role (only member or admin allowed)
  2. Checks the user isn't already a team member
  3. Inserts a team_invitations record with a unique token
  4. Constructs the invite URL: {PUBLIC_APP_URL}/invite/{token}
  5. Sends the invitation email via Resend (non-blocking)
  6. Returns { success, invitation_id, invite_url, podcast_title }

Invitation Record:

FieldValue
tokenUnique random string
statuspending
expires_at7 days from creation
podcast_idTarget podcast
emailInvitee's email
rolemember or admin

Step 2: Invitation Email

The email is rendered using generateTeamInvitationEmail() from src/lib/email/templates/team-invitation.ts.

Email Contents:

  • Purple gradient header with team icon
  • Inviter name and podcast title
  • Role badge with description:
    • member displays as Co-Host: "You can work on the episodes you're added to, including editing show notes and inviting guests." (#293: no live analytics, no broad episode editing)
    • admin displays as Producer: "You can manage everything except billing and deletion."
  • Optional personal message from the host
  • Podcast info card (with cover image if available)
  • "Accept Invitation" CTA button linking to /invite/{token}
  • Expiration warning with formatted date

Subject Line: You've been invited to join {podcastTitle} as {roleLabel}

Both HTML and plain text versions are generated. See Resend Service for email system details.

The /invite/[token] page (src/routes/invite/[token]/) handles the invitation acceptance flow.

Server Load (+page.server.ts)

The load function calls the get_invitation_by_token SECURITY DEFINER RPC to fetch invitation details without requiring authentication.

Returned States:

ConditioncanAcceptneedsAuthemailMismatchAction Shown
Valid + Authenticated + Email matchestruefalsefalse"Accept Invitation" button
Valid + Not authenticatedfalsetruefalse"Log In to Accept" button
Valid + Authenticated + Wrong emailfalsefalsetrue"Log In with Different Account" button
ExpiredfalsefalsefalseError: "This invitation has expired"
Already acceptedfalsefalsefalseError: "This invitation has already been accepted"
Cancelled/InvalidfalsefalsefalseError: "This invitation is no longer valid"

Client Page (+page.svelte)

The Svelte component renders:

  1. Invitation Details Card - Inviter, target email, role badge, expiration date
  2. Personal Message - Optional message from the host (if provided)
  3. Status-Specific Actions:
    • Accept button (form POST to ?/accept)
    • Login/signup links with redirect preservation
    • Dashboard link (for authenticated users who can't accept)

Role Labels displayed to the invitee:

Database RoleDisplay Label
ownerOwner
adminProducer
memberCo-host

Step 4: Authentication Handoff

When an unauthenticated user visits an invite link, the page shows "Log In to Accept" and "Sign Up" buttons. Both preserve the redirect back to the invite page.

Login Flow

  1. User clicks "Log In to Accept" from /invite/[token]
  2. Redirects to /login?redirect=/invite/[token]
  3. User enters credentials, form submits to login action
  4. Login action calls supabase.auth.signInWithPassword()
  5. On success, redirects to the redirect parameter value via getSafeRedirectTarget()
  6. User lands back on /invite/[token], now authenticated
  7. Server load re-evaluates: canAccept: true
  8. User clicks "Accept Invitation"

Signup Flow

  1. User clicks "Don't have an account? Sign up" from /invite/[token]
  2. Redirects to /signup?redirect=/invite/[token]&email={invitee_email}
  3. Email is pre-filled from the invitation
  4. User completes signup, email verification sent
  5. Verification link points to /auth/callback?next=/invite/[token]
  6. After email verification, user lands back on the invite page

Auth Callback (/auth/callback)

The callback handler at src/routes/(auth)/auth/callback/+server.ts:

  1. Exchanges the auth code for a session via exchangeCodeForSession()
  2. Validates the redirect target with getSafeRedirectTarget()
  3. Optionally adds the user to the Resend audience (non-blocking)
  4. Redirects to the next parameter (the invite URL)

Redirect Safety

All redirect targets are validated by getSafeRedirectTarget() from src/lib/auth/redirect.ts to prevent open redirect attacks.

typescript
function getSafeRedirectTarget(target: string | null | undefined, fallback = '/dashboard'): string;

Validation Rules:

InputResultReason
/invite/abc123/invite/abc123Valid internal path
/p/my-podcast/p/my-podcastValid internal path
https://evil.com/dashboardExternal URL rejected
//evil.com/dashboardProtocol-relative URL rejected
/\evil/dashboardBackslash path rejected
null / undefined / ""/dashboardMissing value, use fallback

The function only allows paths starting with / (absolute internal paths) and rejects anything containing backslashes.

Step 5: Invitation Acceptance

When the authenticated user clicks "Accept Invitation", the form submits to the accept action:

  1. Verifies the user is authenticated (401 if not)
  2. Calls accept_team_invitation(p_token) SECURITY DEFINER RPC
  3. The RPC atomically:
    • Updates team_invitations.status to accepted
    • Inserts a podcast_members row with the specified role
  4. On success, redirects to /p/{podcast_slug}

The user is now a full team member and can access the podcast dashboard.

Invitation Management

Hosts can manage pending invitations from the dashboard:

Resend Invitation

POST /api/team/:podcastId/invitations/:invitationId/resend

Resets expiration to 7 days from now and re-sends the email. Useful when the original email was missed or the invitation is about to expire.

Cancel Invitation

DELETE /api/team/:podcastId/invitations/:invitationId

Sets the invitation status to cancelled. The invite link will show "This invitation is no longer valid."

Database Schema

team_invitations Table

ColumnTypeDescription
idUUIDPrimary key
podcast_idUUIDFK to podcasts
emailstringInvitee email
rolepodcast_roleRole to assign on acceptance
statusenumpending, accepted, expired, cancelled
tokenstringUnique token for invite URL
messagestring?Optional personal message
invited_byUUIDFK to auth.users
expires_attimestamptzExpiration (7 days default)
created_attimestamptzCreation timestamp

Constraints:

  • Unique on (podcast_id, email) for pending invitations
  • Unique on token for URL lookup

RPC Functions

FunctionSecurityPurpose
get_invitation_by_token(p_token)SECURITY DEFINERFetch invitation details without auth (public invite page)
accept_team_invitation(p_token)SECURITY DEFINERAtomically accept and create membership

SECURITY DEFINER

These functions bypass RLS because the invite page must be accessible to unauthenticated users (to show invitation details) and the acceptance must work across table boundaries. Never remove SECURITY DEFINER from these functions.

Error Scenarios

ScenarioUser Experience
Invalid/missing token404 page: "Invitation not found"
Expired invitationError message with expiration info
Already acceptedError: "This invitation has already been accepted"
Cancelled invitationError: "This invitation is no longer valid"
Email mismatchWarning with "Log In with Different Account" button
Email send failsAPI succeeds; invitation record created, link works
Resend config missingAPI succeeds; no email sent, manual link sharing needed

Testing

The team invitation system has tests covering:

  • API Routes (src/api/routes/team/__tests__/index.test.ts): Email wiring, graceful degradation without Resend config
  • Email Templates (src/lib/email/templates/__tests__/team-invitation.test.ts): HTML/text generation, role labels, custom messages, edge cases
  • Redirect Safety (src/lib/auth/__tests__/redirect.test.ts): Open redirect prevention, path validation

Internal documentation - Not for public distribution