Skip to main content

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

  1. Backend Implementation (Complete)
  2. Frontend Implementation Guide
  3. Features by Tier
  4. API Endpoints
  5. Data Model
  6. UI Components to Build

✅ Backend Implementation (Complete)

Files Created:

  1. app/schemas/user_management.py - All Pydantic schemas
  2. app/services/user_management_service.py - Business logic service
  3. app/api/v1/user_management.py - API endpoints
  4. app/db/models/membership.py - Updated with site_id field

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:

MethodEndpointDescriptionTier
POST/inviteInvite single userStarter
POST/invite/bulkBulk invite via CSVStandard
GET/invites/pendingGet pending invitationsStarter
POST/invites/{invite_id}/resendResend invitationStarter
DELETE/invites/{invite_id}Revoke invitationStarter

User Management Endpoints:

MethodEndpointDescriptionTier
GET/Get users (paginated + filters)Starter
GET/{user_id}Get user detailsStarter
PATCH/{user_id}Update user infoStarter
POST/{user_id}/rolesAssign role to userStarter
POST/{user_id}/deactivateDeactivate userStandard
POST/{user_id}/reactivateReactivate userStandard

📊 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 list
  • adminhq:users:invite - Invite new users
  • adminhq:users:edit - Edit user details
  • adminhq:users:delete - Deactivate users
  • adminhq: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 match company_roles.role_code
  • site_code: Must match company_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

  1. Test Backend: Use Swagger UI at /api/docs to test endpoints
  2. Build Frontend: Follow component structure above
  3. Add Validations: Client-side form validation
  4. Error Handling: Toast notifications for errors
  5. Loading States: Spinners during async operations
  6. 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.