# 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 (

Validating invitation...

); } if (error) { return (

Invitation Error

{error}

); } return (
{/* Welcome Header */}

Welcome to SwiftOps!

You've been invited to join {invitation.organization_name}

Role: {invitation.invited_role.replace('_', ' ')}

{/* Registration Form */}
); } ``` --- ### 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 (
{/* Email (read-only) */}
{/* First Name */}
{errors.first_name && (

{errors.first_name}

)}
{/* Last Name */}
{errors.last_name && (

{errors.last_name}

)}
{/* Password */}
{/* Password Requirements */} {formData.password && (

Password must contain:

  • = 8 ? 'text-green-600' : 'text-gray-500'}> At least 8 characters
  • One uppercase letter
  • One number
)} {errors.password && (

{errors.password}

)}
{/* Confirm Password */}
{errors.confirmPassword && (

{errors.confirmPassword}

)}
{/* Phone (Optional) */}

Include country code (e.g., +254 for Kenya)

{errors.phone && (

{errors.phone}

)}
{/* Submit Error */} {errors.submit && (

{errors.submit}

)} {/* Submit Button */} {/* Terms */}

By creating an account, you agree to our Terms of Service and Privacy Policy.

); } ``` --- ### 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'; {/* Public Route */} } /> {/* Other 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 ( {invitation.email} {invitation.status} {isExpired && invitation.status === 'pending' && ( Expired )} {invitation.invited_role} {new Date(invitation.expires_at).toLocaleDateString()} {invitation.status === 'pending' && ( )} ); } ``` --- ## 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 (

Error

{error}

{isUserExists && ( )} {isExpired && (

Contact your administrator for a new invitation.

)} {!isUserExists && !isExpired && onRetry && ( )}
); } ``` --- ## 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.