Spaces:
Sleeping
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
- Overview
- Complete Workflow
- API Endpoints Reference
- Frontend Implementation
- Token Expiry & Resend Logic
- Error Handling
- 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
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
// 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)
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:
{
"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)
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:
{
"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:
{
"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
{
"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 organizationclient_admin- Can invite to their client onlycontractor_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
{
"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
{
"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:
{
"invitation_method": "email" // Optional: Override delivery method
}
Response: 200 OK
{
"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
acceptedorcancelled
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:
{
"token": "ABC123XYZ"
}
Response: 200 OK
{
"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:
{
"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
{
"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:
- β Validates token (not expired, still pending)
- β Checks if user already exists (prevents duplicates)
- β Creates Supabase Auth user
- β Creates local user profile with role and organization
- β
Marks invitation as
accepted - β Returns authentication token
- β 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
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
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
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
// 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
// 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
// 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
// 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
// Response: 400 Bad Request
{
"detail": "Invalid or expired invitation token"
}
Frontend Handling:
if (error.detail.includes('Invalid')) {
setError('This invitation link is invalid. Please check your link or contact support.');
}
2. Token Expired
// Response: 400 Bad Request
{
"detail": "Invalid or expired invitation token"
}
Frontend Handling:
if (invitation.is_expired) {
setError('This invitation has expired. Please contact your administrator for a new invitation.');
}
3. User Already Exists
// Response: 400 Bad Request
{
"detail": "User already exists"
}
Frontend Handling:
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
// Response: 422 Unprocessable Entity
{
"detail": [
{
"loc": ["body", "password"],
"msg": "Password must contain at least one uppercase letter",
"type": "value_error"
}
]
}
Frontend Handling:
// Show inline validation before submit
const passwordErrors = validatePassword(password);
if (passwordErrors.length > 0) {
setError('Password must contain: ' + passwordErrors.join(', '));
}
5. Already Accepted
// Response: 404 Not Found
{
"detail": "Invitation not found or already processed"
}
Frontend Handling:
if (error.detail.includes('already processed')) {
setError('This invitation has already been used. Try logging in instead.');
}
6. Network Error
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
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_userspermission - Row-level security on invitations (org-based)
- Cannot invite to other organizations
Frontend Validation
// 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
- Validate token on page load - Don't let user fill form if token is invalid
- Show clear error messages - Help user understand what went wrong
- Pre-fill email - User can't change it (comes from invitation)
- Show password requirements live - Help user create strong password
- Auto-login after signup - Store token and redirect immediately
- Handle network errors gracefully - Show retry option
- Disable submit during processing - Prevent duplicate submissions
- 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-invitationwithout 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
# 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...
# 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 acceptedaccepted- User created accountexpired- 72 hours passed (can be resent with new token)cancelled- Admin cancelled invitation
Key Roles
platform_admin- System administratorclient_admin- Client organization admincontractor_admin- Contractor organization adminproject_manager- Project managerdispatcher- Dispatchersales_manager- Sales managerfield_agent- Field workersales_agent- Sales representative
Last Updated: November 18, 2025
Questions? Check the API responses or backend logs for detailed error messages.