Complete Workflow Example
Admin Invites User
# 1. Admin creates invitation
curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations \
-H "Authorization: Bearer {admin_jwt}" \
-H "Content-Type: application/json" \
-d '{
"email": "newuser@example.com",
"role": "member"
}'
# Response includes plaintext token for email
{
"invitation": { ... },
"inviter": { ... },
"token": "550e8400-e29b-41d4-a716-446655440000"
}
User Receives Email
Email contains:
- Invitation details (organization name, role, inviter)
- Direct acceptance link:
https://sso.example.com/invitations/accept?token=550e8400-e29b-41d4-a716-446655440000 - Expiration date
User Accepts Invitation
# Option 1: Click email link (redirects to frontend)
# User clicks: https://sso.example.com/invitations/accept?token=ABC123
# Frontend displays invitation details
# Frontend calls accept API
# Option 2: Direct API call
curl -X POST https://sso.example.com/api/invitations/accept \
-H "Content-Type: application/json" \
-d '{
"token": "550e8400-e29b-41d4-a716-446655440000"
}'
# Success: User is now a member
Admin Monitors Invitations
# List all invitations
curl -X GET https://sso.example.com/api/organizations/acme-corp/invitations \
-H "Authorization: Bearer {admin_jwt}"
# Cancel unused invitation
curl -X POST https://sso.example.com/api/organizations/acme-corp/invitations/{invitation_id} \
-H "Authorization: Bearer {admin_jwt}"
Integration Examples
Frontend Invitation Acceptance Page
// React example
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function AcceptInvitationPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [invitation, setInvitation] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// Fetch invitation details (you'd need to create this endpoint)
// or decode from JWT token if you implement that
}, [token]);
const handleAccept = async () => {
try {
const response = await fetch('/api/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (response.ok) {
// Redirect to login or dashboard
window.location.href = '/dashboard';
} else {
const data = await response.json();
setError(data.error);
}
} catch (err) {
setError('Failed to accept invitation');
}
};
const handleDecline = async () => {
try {
const response = await fetch('/api/invitations/decline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (response.ok) {
window.location.href = '/';
}
} catch (err) {
setError('Failed to decline invitation');
}
};
return (
<div>
<h1>Invitation to Join Organization</h1>
{error && <div className="error">{error}</div>}
{invitation && (
<div>
<p>You've been invited to join <strong>{invitation.organization.name}</strong></p>
<p>Role: {invitation.role}</p>
<button onClick={handleAccept}>Accept</button>
<button onClick={handleDecline}>Decline</button>
</div>
)}
</div>
);
}
Email Template
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Invitation to Join {{organization_name}}</title>
</head>
<body>
<h1>You're Invited!</h1>
<p>{{inviter_name}} ({{inviter_email}}) has invited you to join <strong>{{organization_name}}</strong> as a {{role}}.</p>
<p>
<a href="{{base_url}}/invitations/accept?token={{token}}"
style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Accept Invitation
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p>{{base_url}}/invitations/accept?token={{token}}</p>
<p><small>This invitation expires on {{expires_at}}.</small></p>
<p><small>If you don't want to join this organization, you can safely ignore this email.</small></p>
</body>
</html>
Example Consumer-App Admin Component
// Vue.js example
<template>
<div class="invitations-manager">
<h2>Pending Invitations</h2>
<form @submit.prevent="createInvitation">
<input v-model="newInvite.email" placeholder="Email" required>
<select v-model="newInvite.role" required>
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit">Send Invitation</button>
</form>
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="invitation in invitations" :key="invitation.invitation.id">
<td>{{ invitation.invitation.email }}</td>
<td>{{ invitation.invitation.role }}</td>
<td>{{ invitation.invitation.status }}</td>
<td>{{ formatDate(invitation.invitation.expires_at) }}</td>
<td>
<button
v-if="invitation.invitation.status === 'pending'"
@click="cancelInvitation(invitation.invitation.id)">
Cancel
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
export default {
data() {
return {
invitations: [],
newInvite: { email: '', role: 'member' }
};
},
async mounted() {
await this.loadInvitations();
},
methods: {
async loadInvitations() {
const response = await fetch(
`/api/organizations/${this.orgSlug}/invitations`,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
this.invitations = await response.json();
},
async createInvitation() {
const response = await fetch(
`/api/organizations/${this.orgSlug}/invitations`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.newInvite)
}
);
if (response.ok) {
this.newInvite = { email: '', role: 'member' };
await this.loadInvitations();
}
},
async cancelInvitation(invitationId) {
const response = await fetch(
`/api/organizations/${this.orgSlug}/invitations/${invitationId}`,
{
method: 'POST',
headers: { Authorization: `Bearer ${this.token}` }
}
);
if (response.ok) {
await this.loadInvitations();
}
},
formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString();
}
}
};
</script>