Users and Roles Screen - Implementation Guide
This document provides a complete guide for building the Users and Roles management screen with all requested features.
📋 Table of Contents
- Backend Implementation (Complete)
- Frontend Implementation Guide
- Features by Tier
- API Endpoints
- Data Model
- UI Components to Build
✅ Backend Implementation (Complete)
Files Created:
app/schemas/user_management.py- All Pydantic schemasapp/services/user_management_service.py- Business logic serviceapp/api/v1/user_management.py- API endpointsapp/db/models/membership.py- Updated withsite_idfield
Database Model Updated:
class CompanyUserRole(Base):
company_id = Column(UUID, ForeignKey("companies.company_id"), primary_key=True)
user_id = Column(UUID, ForeignKey("users.user_id"), primary_key=True)
company_role_id = Column(UUID, ForeignKey("company_roles.company_role_id"), primary_key=True)
site_id = Column(UUID, ForeignKey("company_sites.site_id")) # ✅ NEW - Site scoping
assigned_at = Column(DateTime, default=datetime.utcnow)
assigned_by = Column(UUID, ForeignKey("users.user_id"))
🎯 Features by Tier
Starter Tier Features:
✅ Single User Invite
- Invite by email
- Collect: Name, Email, Phone Number
- Assign role (from company's configured roles)
- Optional site scoping (assign to specific site or "All Sites")
- Email invitation sent automatically
✅ Role Management
- View all company roles
- Built-in roles based on system templates
- Company-customized role names
✅ Per-Site Scoping
- Assign user to specific site
- Assign user to "All Sites" (company-wide access)
- View which sites user has access to
✅ View Users
- Paginated user list
- Search by name or email
- Filter by role, site, status
- View user details
Standard Tier Features (includes all Starter):
✅ Bulk Invite via CSV
- Upload CSV file with multiple users
- Columns: Email, First Name, Last Name, Phone, Role Code, Site Code
- Validation and error reporting
- Success/failure summary
✅ Role Templates by Department
- Pre-defined role templates
- Quick setup for departments (Safety, Operations, etc.)
- Apply template to create roles
✅ Deactivate/Reactivate Users
- Soft delete (deactivate) instead of hard delete
- Reactivate previously deactivated users
- Maintain audit trail
✅ Transfer Ownership
- When deactivating a user, transfer their owned items
- Transfer SDS documents, training records, reports, etc.
- Specify target user for transfer
🔌 API Endpoints
Base URL: /api/v1/companies/{company_id}/users
Invitation Endpoints:
| Method | Endpoint | Description | Tier |
|---|---|---|---|
POST | /invite | Invite single user | Starter |
POST | /invite/bulk | Bulk invite via CSV | Standard |
GET | /invites/pending | Get pending invitations | Starter |
POST | /invites/{invite_id}/resend | Resend invitation | Starter |
DELETE | /invites/{invite_id} | Revoke invitation | Starter |
User Management Endpoints:
| Method | Endpoint | Description | Tier |
|---|---|---|---|
GET | / | Get users (paginated + filters) | Starter |
GET | /{user_id} | Get user details | Starter |
PATCH | /{user_id} | Update user info | Starter |
POST | /{user_id}/roles | Assign role to user | Starter |
POST | /{user_id}/deactivate | Deactivate user | Standard |
POST | /{user_id}/reactivate | Reactivate user | Standard |
📊 Data Model
User Invitation Flow:
1. Admin clicks "Invite User"
2. Fills form: Email, Name, Phone, Role, Site (optional)
3. Backend creates Invite record
4. Email sent to user with invite token
5. User clicks link, creates account
6. User becomes CompanyUserMembership
7. Role assigned via CompanyUserRole
Key Tables:
invites
- Stores pending invitations
- Contains user details (name, phone) in metadata
- 7-day expiration
- Statuses: pending, accepted, expired, revoked
company_user_memberships
- User's membership in company
- Statuses: active, inactive, invited, suspended
company_user_roles
- Role assignments
site_id: NULL = company-wide, or specific site UUID
company_roles
- Company's custom roles
- Based on system role templates
- Renameable by company
🎨 UI Components to Build
1. People Page (/adminhq/people)
Main container for Users & Roles management.
// src/pages/adminhq/people/index.tsx
import { useState } from 'react';
import { UsersList } from './components/UsersList';
import { InviteUserModal } from './components/InviteUserModal';
import { BulkInviteModal } from './components/BulkInviteModal';
import { PendingInvites } from './components/PendingInvites';
import { usePermissions } from '@/store/hooks';
export default function PeoplePage() {
const [showInviteModal, setShowInviteModal] = useState(false);
const [showBulkModal, setShowBulkModal] = useState(false);
const { checkTierAccess } = usePermissions();
const canBulkInvite = checkTierAccess('STANDARD');
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Users & Roles</h1>
<div className="flex gap-3">
<button onClick={() => setShowInviteModal(true)}>
Invite User
</button>
{canBulkInvite && (
<button onClick={() => setShowBulkModal(true)}>
Bulk Invite
</button>
)}
</div>
</div>
{/* Tabs */}
<Tabs>
<Tab label="Active Users">
<UsersList />
</Tab>
<Tab label="Pending Invites">
<PendingInvites />
</Tab>
<Tab label="Inactive Users">
<UsersList status="inactive" />
</Tab>
</Tabs>
{showInviteModal && (
<InviteUserModal onClose={() => setShowInviteModal(false)} />
)}
{showBulkModal && (
<BulkInviteModal onClose={() => setShowBulkModal(false)} />
)}
</div>
);
}
2. Invite User Modal
Form to invite a single user (Starter feature).
// src/pages/adminhq/people/components/InviteUserModal.tsx
interface InviteUserFormData {
email: string;
first_name: string;
last_name: string;
phone_number: string;
company_role_id: string;
site_id: string | null;
send_email: boolean;
}
export function InviteUserModal({ onClose }: { onClose: () => void }) {
const [formData, setFormData] = useState<InviteUserFormData>({
email: '',
first_name: '',
last_name: '',
phone_number: '',
company_role_id: '',
site_id: null,
send_email: true
});
const [roles, setRoles] = useState([]);
const [sites, setSites] = useState([]);
useEffect(() => {
// Fetch company roles
fetch(`/api/v1/companies/${companyId}/roles`)
.then(res => res.json())
.then(data => setRoles(data));
// Fetch company sites
fetch(`/api/v1/companies/${companyId}/sites`)
.then(res => res.json())
.then(data => setSites(data));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await fetch(
`/api/v1/companies/${companyId}/users/invite`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
}
);
if (response.ok) {
toast.success('User invited successfully!');
onClose();
} else {
const error = await response.json();
toast.error(error.detail);
}
} catch (error) {
toast.error('Failed to invite user');
}
};
return (
<Modal onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-xl font-bold">Invite User</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label>First Name *</label>
<input
type="text"
required
value={formData.first_name}
onChange={e => setFormData({...formData, first_name: e.target.value})}
/>
</div>
<div>
<label>Last Name *</label>
<input
type="text"
required
value={formData.last_name}
onChange={e => setFormData({...formData, last_name: e.target.value})}
/>
</div>
</div>
<div>
<label>Email *</label>
<input
type="email"
required
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
/>
</div>
<div>
<label>Phone Number</label>
<input
type="tel"
value={formData.phone_number}
onChange={e => setFormData({...formData, phone_number: e.target.value})}
/>
</div>
<div>
<label>Role *</label>
<select
required
value={formData.company_role_id}
onChange={e => setFormData({...formData, company_role_id: e.target.value})}
>
<option value="">Select a role</option>
{roles.map(role => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</div>
<div>
<label>Site Access</label>
<select
value={formData.site_id || ''}
onChange={e => setFormData({
...formData,
site_id: e.target.value || null
})}
>
<option value="">All Sites (Company-Wide)</option>
{sites.map(site => (
<option key={site.site_id} value={site.site_id}>
{site.name}
</option>
))}
</select>
<p className="text-sm text-gray-500 mt-1">
Leave as "All Sites" for company-wide access
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="send_email"
checked={formData.send_email}
onChange={e => setFormData({...formData, send_email: e.target.checked})}
/>
<label htmlFor="send_email">Send invitation email</label>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary">Send Invite</button>
</div>
</form>
</Modal>
);
}
3. Users List Table
Display paginated list of users with filters.
// src/pages/adminhq/people/components/UsersList.tsx
interface User {
user_id: string;
email: string;
full_name: string;
phone_number: string;
is_active: boolean;
membership_status: string;
roles: Array<{
role_name: string;
site_name: string;
}>;
}
export function UsersList({ status }: { status?: string }) {
const [users, setUsers] = useState<User[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filterRole, setFilterRole] = useState('');
const [filterSite, setFilterSite] = useState('');
useEffect(() => {
fetchUsers();
}, [page, search, filterRole, filterSite, status]);
const fetchUsers = async () => {
const params = new URLSearchParams({
page: page.toString(),
page_size: '20',
...(search && { search }),
...(filterRole && { role_id: filterRole }),
...(filterSite && { site_id: filterSite }),
...(status && { status })
});
const response = await fetch(
`/api/v1/companies/${companyId}/users?${params}`
);
const data = await response.json();
setUsers(data.users);
setTotal(data.total);
};
return (
<div>
{/* Filters */}
<div className="flex gap-4 mb-4">
<input
type="search"
placeholder="Search by name or email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}>
<option value="">All Roles</option>
{/* Populate with roles */}
</select>
<select value={filterSite} onChange={e => setFilterSite(e.target.value)}>
<option value="">All Sites</option>
{/* Populate with sites */}
</select>
</div>
{/* Users Table */}
<table className="w-full">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Roles</th>
<th>Site Access</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.user_id}>
<td>{user.full_name}</td>
<td>{user.email}</td>
<td>{user.phone_number || '-'}</td>
<td>
{user.roles.map((r, i) => (
<div key={i}>{r.role_name}</div>
))}
</td>
<td>
{user.roles.map((r, i) => (
<div key={i}>{r.site_name}</div>
))}
</td>
<td>
<StatusBadge status={user.membership_status} />
</td>
<td>
<UserActions user={user} />
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<Pagination
currentPage={page}
totalPages={Math.ceil(total / 20)}
onPageChange={setPage}
/>
</div>
);
}
4. Bulk Invite Modal (Standard Tier)
Upload CSV to invite multiple users.
// src/pages/adminhq/people/components/BulkInviteModal.tsx
export function BulkInviteModal({ onClose }: { onClose: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [sendEmails, setSendEmails] = useState(true);
const [results, setResults] = useState<any>(null);
const downloadTemplate = () => {
const csv = `email,first_name,last_name,phone_number,role_code,site_code
john.doe@example.com,John,Doe,555-1234,ADMIN,
jane.smith@example.com,Jane,Smith,555-5678,MANAGER,SITE-001
bob.jones@example.com,Bob,Jones,,EMPLOYEE,SITE-002`;
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bulk_invite_template.csv';
a.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFile(e.target.files[0]);
}
};
const handleUpload = async () => {
if (!file) return;
// Parse CSV
const text = await file.text();
const rows = text.split('\n').slice(1); // Skip header
const users = rows
.filter(row => row.trim())
.map(row => {
const [email, first_name, last_name, phone_number, role_code, site_code] =
row.split(',').map(s => s.trim());
return {
email,
first_name,
last_name,
phone_number: phone_number || null,
role_code,
site_code: site_code || null
};
});
try {
const response = await fetch(
`/api/v1/companies/${companyId}/users/invite/bulk`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ users, send_emails: sendEmails })
}
);
const data = await response.json();
setResults(data);
toast.success(
`Successfully invited ${data.success_count} users. ${data.failed_count} failed.`
);
} catch (error) {
toast.error('Bulk invite failed');
}
};
return (
<Modal onClose={onClose} size="large">
<h2 className="text-xl font-bold mb-4">Bulk Invite Users</h2>
{!results ? (
<>
<div className="mb-4">
<button onClick={downloadTemplate} className="btn-secondary">
Download CSV Template
</button>
<p className="text-sm text-gray-500 mt-2">
Use the template to format your user data correctly
</p>
</div>
<div className="mb-4">
<label>Upload CSV File</label>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
/>
</div>
<div className="flex items-center gap-2 mb-4">
<input
type="checkbox"
id="send_emails"
checked={sendEmails}
onChange={e => setSendEmails(e.target.checked)}
/>
<label htmlFor="send_emails">Send invitation emails</label>
</div>
<div className="flex justify-end gap-3">
<button onClick={onClose}>Cancel</button>
<button onClick={handleUpload} disabled={!file}>
Upload & Invite
</button>
</div>
</>
) : (
<>
<div className="mb-4">
<h3 className="font-semibold">Results</h3>
<p className="text-green-600">✓ {results.success_count} successful</p>
<p className="text-red-600">✗ {results.failed_count} failed</p>
</div>
{results.failed_count > 0 && (
<div className="mb-4">
<h4 className="font-semibold mb-2">Failed Invites:</h4>
<ul className="space-y-1">
{results.failed_invites.map((fail: any, i: number) => (
<li key={i} className="text-sm text-red-600">
{fail.email}: {fail.error}
</li>
))}
</ul>
</div>
)}
<button onClick={onClose} className="btn-primary">Close</button>
</>
)}
</Modal>
);
}
🔐 Permissions Required
All endpoints are protected by permissions. Users need:
adminhq:users:view- View users listadminhq:users:invite- Invite new usersadminhq:users:edit- Edit user detailsadminhq:users:delete- Deactivate usersadminhq:roles:edit- Assign roles to users
📝 CSV Template Format
email,first_name,last_name,phone_number,role_code,site_code
john.doe@example.com,John,Doe,555-1234,ADMIN,
jane.smith@example.com,Jane,Smith,555-5678,MANAGER,SITE-001
bob.jones@example.com,Bob,Jones,,EMPLOYEE,SITE-002
Notes:
role_code: Must matchcompany_roles.role_codesite_code: Must matchcompany_sites.code(empty = all sites)phone_number: Optional
✅ Implementation Checklist
Backend (Complete):
- Pydantic schemas
- Service layer
- API endpoints
- Database model updated
- Router registered
Frontend (To Do):
- Create People page (
/adminhq/people) - Create InviteUserModal component
- Create BulkInviteModal component
- Create UsersList table component
- Create PendingInvites component
- Create UserActions dropdown
- Add API service functions
- Add TypeScript types
- Test all features
🚀 Next Steps
- Test Backend: Use Swagger UI at
/api/docsto test endpoints - Build Frontend: Follow component structure above
- Add Validations: Client-side form validation
- Error Handling: Toast notifications for errors
- Loading States: Spinners during async operations
- Empty States: Show helpful messages when no users
📚 Additional Resources
- API Documentation: http://localhost:8000/api/docs
- Data Model Diagram:
/docs/DATA_MODEL_VISUALIZATION.md - Permission System:
/app/core/permissions.py - Onboarding Guide:
/ONBOARDING_UNIFICATION.md
All backend code is ready! Just build the frontend components following this guide.