swiftops-backend / docs /api /auth /INVITATION_SYSTEM_COMPLETE_GUIDE.md
kamau1's picture
feat: ticket progress reports, tickets incidents
dad7dc2
# User Invitation System - Complete Implementation Guide
**Version:** 2.0
**Last Updated:** November 18, 2025
**Backend Status:** βœ… Fully Implemented
**Frontend Status:** πŸ“ Ready for Implementation
---
## Table of Contents
1. [Overview](#overview)
2. [Complete Workflow](#complete-workflow)
3. [API Endpoints Reference](#api-endpoints-reference)
4. [Frontend Implementation](#frontend-implementation)
5. [Token Expiry & Resend Logic](#token-expiry--resend-logic)
6. [Error Handling](#error-handling)
7. [Security & Validation](#security--validation)
---
## Overview
### System Architecture
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Admin Creates β”‚
β”‚ Invitation β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Backend Sends │─────▢│ WhatsApp/Email β”‚
β”‚ Notification β”‚ β”‚ with Invite Link β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User Clicks β”‚
β”‚ Link β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Frontend β”‚
β”‚ Validates β”‚
β”‚ Token β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User Fills β”‚
β”‚ Registration β”‚
β”‚ Form β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Account β”‚
β”‚ Created & β”‚
β”‚ Auto Login β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
### Key Features
βœ… **Smart Token Management** - Expired tokens auto-regenerate on resend
βœ… **Dual Notification** - WhatsApp primary, Email fallback
βœ… **One-Time Use** - Tokens can't be reused after acceptance
βœ… **Auto Login** - Users logged in immediately after signup
βœ… **Role-Based** - Pre-assigned role and organization
βœ… **72-Hour Expiry** - Tokens valid for 3 days (regenerate on resend)
---
## Complete Workflow
### Phase 1: Admin Creates Invitation
**Endpoint:** `POST /api/v1/invitations`
```javascript
const response = await fetch('/api/v1/invitations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: "john.doe@example.com",
phone: "+254712345678",
invited_role: "field_agent",
contractor_id: "uuid-of-contractor",
invitation_method: "whatsapp" // or "email" or "both"
})
});
// Response
{
"id": "uuid",
"email": "john.doe@example.com",
"phone": "+254712345678",
"invited_role": "field_agent",
"status": "pending",
"invited_at": "2025-11-18T10:00:00Z",
"expires_at": "2025-11-21T10:00:00Z",
"whatsapp_sent": true,
"email_sent": false,
"organization_name": "ABC Contractors"
}
```
### Phase 2: User Receives Notification
**WhatsApp/Email contains:**
```
πŸŽ‰ You're invited to join SwiftOps!
ABC Contractors has invited you to join as a Field Agent.
Click here to accept: https://swiftops.atomio.tech/accept-invitation?token=ABC123XYZ
This link expires in 72 hours.
```
### Phase 3: User Clicks Link
**Frontend Route:** `/accept-invitation?token=ABC123XYZ`
```javascript
// Extract token from URL
const searchParams = new URLSearchParams(window.location.search);
const token = searchParams.get('token');
if (!token) {
// Show error: "Invalid invitation link"
}
```
### Phase 4: Validate Token
**Endpoint:** `POST /api/v1/invitations/validate` (PUBLIC - No Auth)
```javascript
const response = await fetch('/api/v1/invitations/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (response.ok) {
const invitation = await response.json();
// Show registration form with pre-filled data
} else {
// Show error message
}
```
**Response:**
```json
{
"id": "uuid",
"email": "john.doe@example.com",
"invited_role": "field_agent",
"status": "pending",
"expires_at": "2025-11-21T10:00:00Z",
"organization_name": "ABC Contractors",
"organization_type": "contractor",
"is_expired": false,
"is_valid": true
}
```
### Phase 5: User Completes Registration
**Endpoint:** `POST /api/v1/invitations/accept` (PUBLIC - No Auth)
```javascript
const response = await fetch('/api/v1/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: "ABC123XYZ",
first_name: "John",
last_name: "Doe",
password: "SecurePass123!",
phone: "+254712345678" // Optional
})
});
const data = await response.json();
// Store token and redirect
localStorage.setItem('access_token', data.access_token);
window.location.href = '/dashboard';
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"user": {
"id": "uuid",
"email": "john.doe@example.com",
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"role": "field_agent",
"is_active": true
}
}
```
---
## API Endpoints Reference
### 1. Create Invitation
**POST** `/api/v1/invitations`
**Authorization:** Required (`platform_admin`, `client_admin`, `contractor_admin`)
**Request Body:**
```json
{
"email": "user@example.com",
"phone": "+254712345678",
"invited_role": "field_agent",
"client_id": "uuid", // For client roles
"contractor_id": "uuid", // For contractor roles
"invitation_method": "whatsapp" // "whatsapp", "email", "both"
}
```
**Response:** `201 Created`
```json
{
"id": "uuid",
"email": "user@example.com",
"phone": "+254712345678",
"invited_role": "field_agent",
"status": "pending",
"invited_at": "2025-11-18T10:00:00Z",
"expires_at": "2025-11-21T10:00:00Z",
"whatsapp_sent": true,
"email_sent": false,
"organization_name": "ABC Contractors"
}
```
**Authorization Rules:**
- `platform_admin` - Can invite to any organization
- `client_admin` - Can invite to their client only
- `contractor_admin` - Can invite to their contractor only
---
### 2. List Invitations
**GET** `/api/v1/invitations?page=1&per_page=20&status=pending`
**Authorization:** Required (`invite_users` permission)
**Query Parameters:**
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 20)
- `status` - Filter by status: `pending`, `accepted`, `expired`, `cancelled`
**Response:** `200 OK`
```json
{
"items": [
{
"id": "uuid",
"email": "user@example.com",
"invited_role": "field_agent",
"status": "pending",
"invited_at": "2025-11-18T10:00:00Z",
"expires_at": "2025-11-21T10:00:00Z",
"organization_name": "ABC Contractors"
}
],
"total": 50,
"page": 1,
"per_page": 20,
"pages": 3
}
```
---
### 3. Get Invitation Details
**GET** `/api/v1/invitations/{invitation_id}`
**Authorization:** Required (`invite_users` permission)
**Response:** `200 OK`
```json
{
"id": "uuid",
"email": "user@example.com",
"phone": "+254712345678",
"invited_role": "field_agent",
"status": "pending",
"invited_at": "2025-11-18T10:00:00Z",
"expires_at": "2025-11-21T10:00:00Z",
"accepted_at": null,
"whatsapp_sent": true,
"whatsapp_sent_at": "2025-11-18T10:00:05Z",
"email_sent": false,
"organization_name": "ABC Contractors"
}
```
---
### 4. Resend Invitation
**POST** `/api/v1/invitations/{invitation_id}/resend`
**Authorization:** Required (`invite_users` permission)
**Request Body:**
```json
{
"invitation_method": "email" // Optional: Override delivery method
}
```
**Response:** `200 OK`
```json
{
"id": "uuid",
"email": "user@example.com",
"status": "pending",
"expires_at": "2025-11-21T16:00:00Z", // Extended if was expired
"whatsapp_sent": false,
"email_sent": true
}
```
**Behavior:**
- βœ… If **not expired**: Resends with same token
- βœ… If **expired**: Generates NEW token + extends expiry by 72 hours + resends
- ❌ Cannot resend if status is `accepted` or `cancelled`
---
### 5. Cancel Invitation
**DELETE** `/api/v1/invitations/{invitation_id}`
**Authorization:** Required (`invite_users` permission)
**Response:** `204 No Content`
**Behavior:**
- Sets status to `cancelled`
- User can no longer use the token
- Invitation record remains in database for audit trail
---
### 6. Validate Token (PUBLIC)
**POST** `/api/v1/invitations/validate`
**Authorization:** None (Public endpoint)
**Request Body:**
```json
{
"token": "ABC123XYZ"
}
```
**Response:** `200 OK`
```json
{
"id": "uuid",
"email": "john.doe@example.com",
"invited_role": "field_agent",
"status": "pending",
"expires_at": "2025-11-21T10:00:00Z",
"organization_name": "ABC Contractors",
"organization_type": "contractor",
"is_expired": false,
"is_valid": true
}
```
**Use Case:**
- Call this when user lands on `/accept-invitation?token=...`
- Pre-fill email in registration form
- Show organization name and role
- Catch invalid/expired tokens early
---
### 7. Accept Invitation (PUBLIC)
**POST** `/api/v1/invitations/accept`
**Authorization:** None (Public endpoint)
**Request Body:**
```json
{
"token": "ABC123XYZ",
"first_name": "John",
"last_name": "Doe",
"password": "SecurePass123!",
"phone": "+254712345678" // Optional
}
```
**Password Requirements:**
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 digit
**Phone Requirements:**
- Must start with `+` and country code
- Example: `+254712345678`
**Response:** `200 OK`
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"user": {
"id": "uuid",
"email": "john.doe@example.com",
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"role": "field_agent",
"is_active": true
}
}
```
**What Happens:**
1. βœ… Validates token (not expired, still pending)
2. βœ… Checks if user already exists (prevents duplicates)
3. βœ… Creates Supabase Auth user
4. βœ… Creates local user profile with role and organization
5. βœ… Marks invitation as `accepted`
6. βœ… Returns authentication token
7. βœ… User is logged in immediately
---
## Frontend Implementation
### Component Structure
```
src/
β”œβ”€β”€ pages/
β”‚ β”œβ”€β”€ AcceptInvitationPage.jsx # Main invitation acceptance page
β”‚ └── InvitationExpiredPage.jsx # Show when token expired
β”œβ”€β”€ components/
β”‚ β”œβ”€β”€ InvitationValidation.jsx # Token validation logic
β”‚ └── RegistrationForm.jsx # User signup form
└── services/
└── invitationService.js # API calls
```
---
### AcceptInvitationPage.jsx
```javascript
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { validateInvitation, acceptInvitation } from '@/services/invitationService';
import RegistrationForm from '@/components/RegistrationForm';
export default function AcceptInvitationPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [loading, setLoading] = useState(true);
const [invitation, setInvitation] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!token) {
setError('Invalid invitation link');
setLoading(false);
return;
}
validateToken();
}, [token]);
const validateToken = async () => {
try {
const data = await validateInvitation(token);
if (!data.is_valid) {
setError('This invitation is no longer valid');
return;
}
if (data.is_expired) {
setError('This invitation has expired. Please contact your administrator for a new invitation.');
return;
}
setInvitation(data);
} catch (err) {
setError(err.message || 'Failed to validate invitation');
} finally {
setLoading(false);
}
};
const handleSubmit = async (formData) => {
try {
const response = await acceptInvitation({
token,
...formData
});
// Store auth token
localStorage.setItem('access_token', response.access_token);
// Store user info
localStorage.setItem('user', JSON.stringify(response.user));
// Redirect to dashboard
navigate('/dashboard');
// Optional: Show success message
// toast.success('Account created successfully!');
} catch (err) {
throw err; // Let form handle error display
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="spinner" />
<p className="mt-4">Validating invitation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center max-w-md p-8 bg-red-50 rounded-lg">
<h2 className="text-2xl font-bold text-red-800 mb-4">
Invitation Error
</h2>
<p className="text-red-600">{error}</p>
<button
onClick={() => navigate('/login')}
className="mt-6 btn-primary"
>
Go to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-md mx-auto">
{/* Welcome Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">Welcome to SwiftOps!</h1>
<p className="text-gray-600">
You've been invited to join <strong>{invitation.organization_name}</strong>
</p>
<p className="text-sm text-gray-500 mt-2">
Role: <span className="font-semibold capitalize">
{invitation.invited_role.replace('_', ' ')}
</span>
</p>
</div>
{/* Registration Form */}
<RegistrationForm
email={invitation.email}
onSubmit={handleSubmit}
/>
</div>
</div>
);
}
```
---
### RegistrationForm.jsx
```javascript
import React, { useState } from 'react';
export default function RegistrationForm({ email, onSubmit }) {
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
password: '',
confirmPassword: '',
phone: ''
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) errors.push('At least 8 characters');
if (!/[A-Z]/.test(password)) errors.push('One uppercase letter');
if (!/[0-9]/.test(password)) errors.push('One number');
return errors;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error for this field
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validation
const newErrors = {};
if (!formData.first_name.trim()) {
newErrors.first_name = 'First name is required';
}
if (!formData.last_name.trim()) {
newErrors.last_name = 'Last name is required';
}
const passwordErrors = validatePassword(formData.password);
if (passwordErrors.length > 0) {
newErrors.password = 'Password must contain: ' + passwordErrors.join(', ');
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
if (formData.phone && !formData.phone.startsWith('+')) {
newErrors.phone = 'Phone must start with + and country code';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setLoading(true);
try {
await onSubmit({
first_name: formData.first_name,
last_name: formData.last_name,
password: formData.password,
phone: formData.phone || undefined
});
} catch (err) {
setErrors({
submit: err.response?.data?.detail || 'Failed to create account'
});
} finally {
setLoading(false);
}
};
const passwordStrength = validatePassword(formData.password);
return (
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-lg shadow-md">
{/* Email (read-only) */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
value={email}
disabled
className="w-full px-4 py-2 border rounded-lg bg-gray-100"
/>
</div>
{/* First Name */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="first_name"
value={formData.first_name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg"
required
/>
{errors.first_name && (
<p className="text-red-500 text-sm mt-1">{errors.first_name}</p>
)}
</div>
{/* Last Name */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="last_name"
value={formData.last_name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg"
required
/>
{errors.last_name && (
<p className="text-red-500 text-sm mt-1">{errors.last_name}</p>
)}
</div>
{/* Password */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-2.5 text-gray-500"
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{/* Password Requirements */}
{formData.password && (
<div className="mt-2 text-sm">
<p className={passwordStrength.length === 0 ? 'text-green-600' : 'text-gray-600'}>
Password must contain:
</p>
<ul className="list-disc list-inside text-xs mt-1">
<li className={formData.password.length >= 8 ? 'text-green-600' : 'text-gray-500'}>
At least 8 characters
</li>
<li className={/[A-Z]/.test(formData.password) ? 'text-green-600' : 'text-gray-500'}>
One uppercase letter
</li>
<li className={/[0-9]/.test(formData.password) ? 'text-green-600' : 'text-gray-500'}>
One number
</li>
</ul>
</div>
)}
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
</div>
{/* Confirm Password */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Confirm Password <span className="text-red-500">*</span>
</label>
<input
type={showPassword ? "text" : "password"}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg"
required
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
{/* Phone (Optional) */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
Phone Number (Optional)
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="+254712345678"
className="w-full px-4 py-2 border rounded-lg"
/>
<p className="text-xs text-gray-500 mt-1">
Include country code (e.g., +254 for Kenya)
</p>
{errors.phone && (
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
)}
</div>
{/* Submit Error */}
{errors.submit && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{errors.submit}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400"
>
{loading ? 'Creating Account...' : 'Create Account'}
</button>
{/* Terms */}
<p className="text-xs text-gray-500 text-center mt-4">
By creating an account, you agree to our Terms of Service and Privacy Policy.
</p>
</form>
);
}
```
---
### invitationService.js
```javascript
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:8000/api/v1';
export const validateInvitation = async (token) => {
const response = await fetch(`${API_BASE}/invitations/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to validate invitation');
}
return response.json();
};
export const acceptInvitation = async (data) => {
const response = await fetch(`${API_BASE}/invitations/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to accept invitation');
}
return response.json();
};
export const createInvitation = async (token, data) => {
const response = await fetch(`${API_BASE}/invitations`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create invitation');
}
return response.json();
};
export const listInvitations = async (token, params = {}) => {
const queryString = new URLSearchParams(params).toString();
const response = await fetch(`${API_BASE}/invitations?${queryString}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to fetch invitations');
}
return response.json();
};
export const resendInvitation = async (token, invitationId, method = null) => {
const response = await fetch(`${API_BASE}/invitations/${invitationId}/resend`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ invitation_method: method })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to resend invitation');
}
return response.json();
};
export const cancelInvitation = async (token, invitationId) => {
const response = await fetch(`${API_BASE}/invitations/${invitationId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to cancel invitation');
}
return true;
};
```
---
### Router Configuration
```javascript
// In your main router file (App.jsx or routes.jsx)
import AcceptInvitationPage from '@/pages/AcceptInvitationPage';
<Routes>
{/* Public Route */}
<Route path="/accept-invitation" element={<AcceptInvitationPage />} />
{/* Other routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
{/* ... */}
</Routes>
```
---
## Token Expiry & Resend Logic
### Token Lifecycle
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Token Created β”‚
β”‚ Expires: +72h β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Status: Pending│◄───── User can accept
β”‚ Not Expired β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Accepted β”‚ β”‚ Expired β”‚
β”‚ (Used) β”‚ β”‚ (72h past) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Resend β”‚
β”‚ Clicked β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ New Token β”‚
β”‚ Generated β”‚
β”‚ Expires: +72h β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
### Resend Behavior
**Scenario 1: Not Expired**
```javascript
// Day 1: Invitation sent, expires Day 4
// Day 2: Admin clicks "Resend"
β†’ Same token sent again
β†’ Expiry unchanged (still Day 4)
β†’ Notification resent via WhatsApp/Email
```
**Scenario 2: Expired**
```javascript
// Day 1: Invitation sent, expires Day 4
// Day 5: Token expired, Admin clicks "Resend"
β†’ NEW token generated
β†’ Expiry set to Day 8 (72 hours from now)
β†’ Notification sent with new link
β†’ Old token no longer works
```
### Admin Dashboard Implementation
```javascript
// InvitationsList.jsx
function InvitationRow({ invitation, onResend }) {
const isExpired = new Date(invitation.expires_at) < new Date();
return (
<tr>
<td>{invitation.email}</td>
<td>
<span className={`badge ${
invitation.status === 'pending' ? 'badge-warning' :
invitation.status === 'accepted' ? 'badge-success' :
'badge-gray'
}`}>
{invitation.status}
</span>
{isExpired && invitation.status === 'pending' && (
<span className="badge badge-danger ml-2">Expired</span>
)}
</td>
<td>{invitation.invited_role}</td>
<td>{new Date(invitation.expires_at).toLocaleDateString()}</td>
<td>
{invitation.status === 'pending' && (
<button
onClick={() => onResend(invitation.id)}
className="btn-sm btn-primary"
>
{isExpired ? 'Resend (New Token)' : 'Resend'}
</button>
)}
</td>
</tr>
);
}
```
---
## Error Handling
### Common Error Scenarios
#### 1. Invalid Token
```json
// Response: 400 Bad Request
{
"detail": "Invalid or expired invitation token"
}
```
**Frontend Handling:**
```javascript
if (error.detail.includes('Invalid')) {
setError('This invitation link is invalid. Please check your link or contact support.');
}
```
---
#### 2. Token Expired
```json
// Response: 400 Bad Request
{
"detail": "Invalid or expired invitation token"
}
```
**Frontend Handling:**
```javascript
if (invitation.is_expired) {
setError('This invitation has expired. Please contact your administrator for a new invitation.');
}
```
---
#### 3. User Already Exists
```json
// Response: 400 Bad Request
{
"detail": "User already exists"
}
```
**Frontend Handling:**
```javascript
if (error.detail.includes('already exists')) {
setError('An account with this email already exists. Try logging in instead.');
// Show "Go to Login" button
}
```
---
#### 4. Password Too Weak
```json
// Response: 422 Unprocessable Entity
{
"detail": [
{
"loc": ["body", "password"],
"msg": "Password must contain at least one uppercase letter",
"type": "value_error"
}
]
}
```
**Frontend Handling:**
```javascript
// Show inline validation before submit
const passwordErrors = validatePassword(password);
if (passwordErrors.length > 0) {
setError('Password must contain: ' + passwordErrors.join(', '));
}
```
---
#### 5. Already Accepted
```json
// Response: 404 Not Found
{
"detail": "Invitation not found or already processed"
}
```
**Frontend Handling:**
```javascript
if (error.detail.includes('already processed')) {
setError('This invitation has already been used. Try logging in instead.');
}
```
---
#### 6. Network Error
```javascript
try {
await acceptInvitation(data);
} catch (err) {
if (err.message.includes('Failed to fetch')) {
setError('Connection failed. Please check your internet and try again.');
}
}
```
---
### Error Display Component
```javascript
function ErrorMessage({ error, onRetry, onGoToLogin }) {
if (!error) return null;
const isUserExists = error.includes('already exists');
const isExpired = error.includes('expired');
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="text-red-800 font-semibold mb-2">Error</h3>
<p className="text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
{isUserExists && (
<button onClick={onGoToLogin} className="btn-primary">
Go to Login
</button>
)}
{isExpired && (
<p className="text-sm text-red-600">
Contact your administrator for a new invitation.
</p>
)}
{!isUserExists && !isExpired && onRetry && (
<button onClick={onRetry} className="btn-secondary">
Try Again
</button>
)}
</div>
</div>
);
}
```
---
## Security & Validation
### Backend Validation
βœ… **Token Security**
- 32-byte cryptographically secure random tokens
- Unique constraint in database
- Single-use (marked as accepted after use)
- Time-limited (72-hour expiry)
βœ… **Password Requirements**
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 digit
- No maximum length (within reason)
βœ… **Email Validation**
- Valid email format
- Must match invitation email
- Cannot be changed by user
βœ… **Phone Validation**
- Optional field
- Must start with `+` and country code
- Example: `+254712345678`
βœ… **Authorization**
- Only admins with `invite_users` permission
- Row-level security on invitations (org-based)
- Cannot invite to other organizations
### Frontend Validation
```javascript
// Validate before submit
const validate = (formData) => {
const errors = {};
// Required fields
if (!formData.first_name.trim()) {
errors.first_name = 'First name is required';
}
if (!formData.last_name.trim()) {
errors.last_name = 'Last name is required';
}
// Password strength
if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!/[A-Z]/.test(formData.password)) {
errors.password = 'Password must contain an uppercase letter';
}
if (!/[0-9]/.test(formData.password)) {
errors.password = 'Password must contain a number';
}
// Password match
if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
// Phone format
if (formData.phone && !formData.phone.startsWith('+')) {
errors.phone = 'Phone must start with + and country code';
}
return errors;
};
```
### Best Practices
1. **Validate token on page load** - Don't let user fill form if token is invalid
2. **Show clear error messages** - Help user understand what went wrong
3. **Pre-fill email** - User can't change it (comes from invitation)
4. **Show password requirements live** - Help user create strong password
5. **Auto-login after signup** - Store token and redirect immediately
6. **Handle network errors gracefully** - Show retry option
7. **Disable submit during processing** - Prevent duplicate submissions
8. **Show organization name** - User knows what they're joining
---
## Testing Checklist
### Backend Testing
- [ ] Create invitation with WhatsApp method
- [ ] Create invitation with Email method
- [ ] Create invitation with Both methods
- [ ] Validate valid token
- [ ] Validate expired token
- [ ] Validate invalid token
- [ ] Accept valid invitation
- [ ] Try to accept twice (should fail)
- [ ] Try to accept expired token (should fail)
- [ ] Resend non-expired invitation (same token)
- [ ] Resend expired invitation (new token generated)
- [ ] Cancel invitation
- [ ] Try to accept cancelled invitation (should fail)
### Frontend Testing
- [ ] Land on `/accept-invitation` without token (show error)
- [ ] Land on page with invalid token (show error)
- [ ] Land on page with expired token (show error)
- [ ] Land on page with valid token (show form)
- [ ] Submit form with empty fields (show validation errors)
- [ ] Submit form with weak password (show validation errors)
- [ ] Submit form with mismatched passwords (show validation errors)
- [ ] Submit form with invalid phone format (show validation errors)
- [ ] Submit form with valid data (create account & redirect)
- [ ] Try to accept same invitation twice (show error)
- [ ] Handle network errors gracefully
---
## Environment Variables
```env
# Backend (.env)
APP_DOMAIN=swiftops.atomio.tech
APP_PROTOCOL=https
INVITATION_TOKEN_EXPIRY_HOURS=72
# Notification Services
RESEND_API_KEY=re_xxxxxxxxxxxxx
RESEND_FROM_EMAIL=swiftops@atomio.tech
WASENDER_API_KEY=xxxxxxxxxxxxx
# Supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
```env
# Frontend (.env)
REACT_APP_API_URL=https://api.swiftops.atomio.tech/api/v1
```
---
## Quick Reference
### Key URLs
- Invitation Link: `https://swiftops.atomio.tech/accept-invitation?token=ABC123XYZ`
- API Base: `https://api.swiftops.atomio.tech/api/v1`
### Key Timings
- Token Expiry: 72 hours (3 days)
- Token Regeneration: On resend if expired
- Auto Login: Immediate after account creation
### Key Status Values
- `pending` - Invitation sent, not yet accepted
- `accepted` - User created account
- `expired` - 72 hours passed (can be resent with new token)
- `cancelled` - Admin cancelled invitation
### Key Roles
- `platform_admin` - System administrator
- `client_admin` - Client organization admin
- `contractor_admin` - Contractor organization admin
- `project_manager` - Project manager
- `dispatcher` - Dispatcher
- `sales_manager` - Sales manager
- `field_agent` - Field worker
- `sales_agent` - Sales representative
---
**Last Updated:** November 18, 2025
**Questions?** Check the API responses or backend logs for detailed error messages.