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:
- Validates the email and role (only
memberoradminallowed) - Checks the user isn't already a team member
- Inserts a
team_invitationsrecord with a unique token - Constructs the invite URL:
{PUBLIC_APP_URL}/invite/{token} - Sends the invitation email via Resend (non-blocking)
- Returns
{ success, invitation_id, invite_url, podcast_title }
Invitation Record:
| Field | Value |
|---|---|
token | Unique random string |
status | pending |
expires_at | 7 days from creation |
podcast_id | Target podcast |
email | Invitee's email |
role | member 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:
memberdisplays 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)admindisplays 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.
Step 3: Invitee Visits the Link
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:
| Condition | canAccept | needsAuth | emailMismatch | Action Shown |
|---|---|---|---|---|
| Valid + Authenticated + Email matches | true | false | false | "Accept Invitation" button |
| Valid + Not authenticated | false | true | false | "Log In to Accept" button |
| Valid + Authenticated + Wrong email | false | false | true | "Log In with Different Account" button |
| Expired | false | false | false | Error: "This invitation has expired" |
| Already accepted | false | false | false | Error: "This invitation has already been accepted" |
| Cancelled/Invalid | false | false | false | Error: "This invitation is no longer valid" |
Client Page (+page.svelte)
The Svelte component renders:
- Invitation Details Card - Inviter, target email, role badge, expiration date
- Personal Message - Optional message from the host (if provided)
- Status-Specific Actions:
- Accept button (form POST to
?/accept) - Login/signup links with redirect preservation
- Dashboard link (for authenticated users who can't accept)
- Accept button (form POST to
Role Labels displayed to the invitee:
| Database Role | Display Label |
|---|---|
owner | Owner |
admin | Producer |
member | Co-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
- User clicks "Log In to Accept" from
/invite/[token] - Redirects to
/login?redirect=/invite/[token] - User enters credentials, form submits to login action
- Login action calls
supabase.auth.signInWithPassword() - On success, redirects to the
redirectparameter value viagetSafeRedirectTarget() - User lands back on
/invite/[token], now authenticated - Server load re-evaluates:
canAccept: true - User clicks "Accept Invitation"
Signup Flow
- User clicks "Don't have an account? Sign up" from
/invite/[token] - Redirects to
/signup?redirect=/invite/[token]&email={invitee_email} - Email is pre-filled from the invitation
- User completes signup, email verification sent
- Verification link points to
/auth/callback?next=/invite/[token] - 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:
- Exchanges the auth
codefor a session viaexchangeCodeForSession() - Validates the redirect target with
getSafeRedirectTarget() - Optionally adds the user to the Resend audience (non-blocking)
- Redirects to the
nextparameter (the invite URL)
Redirect Safety
All redirect targets are validated by getSafeRedirectTarget() from src/lib/auth/redirect.ts to prevent open redirect attacks.
function getSafeRedirectTarget(target: string | null | undefined, fallback = '/dashboard'): string;Validation Rules:
| Input | Result | Reason |
|---|---|---|
/invite/abc123 | /invite/abc123 | Valid internal path |
/p/my-podcast | /p/my-podcast | Valid internal path |
https://evil.com | /dashboard | External URL rejected |
//evil.com | /dashboard | Protocol-relative URL rejected |
/\evil | /dashboard | Backslash path rejected |
null / undefined / "" | /dashboard | Missing 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:
- Verifies the user is authenticated (401 if not)
- Calls
accept_team_invitation(p_token)SECURITY DEFINER RPC - The RPC atomically:
- Updates
team_invitations.statustoaccepted - Inserts a
podcast_membersrow with the specified role
- Updates
- 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/resendResets 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/:invitationIdSets the invitation status to cancelled. The invite link will show "This invitation is no longer valid."
Database Schema
team_invitations Table
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
podcast_id | UUID | FK to podcasts |
email | string | Invitee email |
role | podcast_role | Role to assign on acceptance |
status | enum | pending, accepted, expired, cancelled |
token | string | Unique token for invite URL |
message | string? | Optional personal message |
invited_by | UUID | FK to auth.users |
expires_at | timestamptz | Expiration (7 days default) |
created_at | timestamptz | Creation timestamp |
Constraints:
- Unique on
(podcast_id, email)for pending invitations - Unique on
tokenfor URL lookup
RPC Functions
| Function | Security | Purpose |
|---|---|---|
get_invitation_by_token(p_token) | SECURITY DEFINER | Fetch invitation details without auth (public invite page) |
accept_team_invitation(p_token) | SECURITY DEFINER | Atomically 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
| Scenario | User Experience |
|---|---|
| Invalid/missing token | 404 page: "Invitation not found" |
| Expired invitation | Error message with expiration info |
| Already accepted | Error: "This invitation has already been accepted" |
| Cancelled invitation | Error: "This invitation is no longer valid" |
| Email mismatch | Warning with "Log In with Different Account" button |
| Email send fails | API succeeds; invitation record created, link works |
| Resend config missing | API 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
Related Documentation
- Team API Reference - Endpoint details and response shapes
- Multi-Tenancy Model - Role hierarchy and permissions
- Resend Service - Email template system
- SvelteKit Routing - Route groups and auth guards