Spaces:
Sleeping
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 | |
| 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. | |