modcomms/frontend/components/UserManagement.tsx
michael 8f2f561c71 Fix stale UserContext after agency/role changes and remove hardcoded values in CreateProjectModal
UserManagement now calls refresh() on the global UserContext when the
current user's agency or role is changed, so downstream consumers
(e.g. CreateCampaignModal) immediately reflect the update.

CreateProjectModal now reads the Agency and Agency Lead fields from
the current user's profile instead of hardcoding "OLIVER Agency" and
"Steve O'Donoghue".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:44:55 -06:00

515 lines
29 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import apiService from '../services/apiService';
import type { UserManagementResponse, AgencyResponse, UserChangeLogEntry } from '../services/apiService';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { ClockIcon } from './icons/ClockIcon';
import { useUser } from '../contexts/UserContext';
import type { UserRole } from '../types';
const ROLE_OPTIONS: { value: UserRole; label: string }[] = [
{ value: 'super_admin', label: 'Super Admin' },
{ value: 'oversight_admin', label: 'Oversight Admin' },
{ value: 'agency_admin', label: 'Agency Admin' },
{ value: 'basic_user', label: 'Basic User' },
];
const formatDate = (iso: string) => {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
export const UserManagement: React.FC = () => {
const { user: currentUser, isSuperAdmin, refresh } = useUser();
const [users, setUsers] = useState<UserManagementResponse[]>([]);
const [agencies, setAgencies] = useState<AgencyResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savedUserId, setSavedUserId] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Super Admin confirmation modal
const [pendingSuperAdminUser, setPendingSuperAdminUser] = useState<{ userId: string; userName: string } | null>(null);
const [confirmationText, setConfirmationText] = useState('');
const [confirmationError, setConfirmationError] = useState(false);
// Change history modal
const [historyUser, setHistoryUser] = useState<{ id: string; name: string } | null>(null);
const [historyEntries, setHistoryEntries] = useState<UserChangeLogEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// New agency form
const [newAgencyName, setNewAgencyName] = useState('');
const [isCreatingAgency, setIsCreatingAgency] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [usersData, agenciesData] = await Promise.all([
apiService.getUsers(),
apiService.getAgencies(),
]);
setUsers(usersData);
setAgencies(agenciesData);
} catch (err) {
console.error('Failed to load user management data:', err);
setError('Failed to load data. You may not have permission.');
} finally {
setIsLoading(false);
}
};
const showSavedIndicator = (userId: string) => {
setSavedUserId(userId);
setError(null);
setTimeout(() => setSavedUserId(prev => prev === userId ? null : prev), 2000);
};
const handleRoleChange = async (userId: string, newRole: string) => {
const user = users.find(u => u.id === userId);
if (newRole === 'super_admin' && user?.role !== 'super_admin') {
setPendingSuperAdminUser({ userId, userName: user?.name || 'this user' });
setConfirmationText('');
setConfirmationError(false);
return;
}
try {
const updated = await apiService.updateUser(userId, { role: newRole });
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
showSavedIndicator(userId);
if (userId === currentUser?.id) await refresh();
} catch (err) {
console.error('Failed to update user role:', err);
setError('Failed to update user role.');
}
};
const handleConfirmSuperAdmin = async () => {
if (confirmationText !== 'make this user super admin') {
setConfirmationError(true);
return;
}
if (!pendingSuperAdminUser) return;
const { userId } = pendingSuperAdminUser;
setPendingSuperAdminUser(null);
setConfirmationText('');
setConfirmationError(false);
try {
const updated = await apiService.updateUser(userId, { role: 'super_admin' });
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
showSavedIndicator(userId);
if (userId === currentUser?.id) await refresh();
} catch (err) {
console.error('Failed to update user role:', err);
setError('Failed to update user role.');
}
};
const handleCancelSuperAdmin = () => {
setPendingSuperAdminUser(null);
setConfirmationText('');
setConfirmationError(false);
};
const handleAgencyChange = async (userId: string, agencyId: string | null) => {
try {
const updated = await apiService.updateUser(userId, { agency_id: agencyId });
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
showSavedIndicator(userId);
if (userId === currentUser?.id) await refresh();
} catch (err) {
console.error('Failed to update user agency:', err);
setError('Failed to update user agency.');
}
};
const formatRoleLabel = (role: string): string => {
const found = ROLE_OPTIONS.find(r => r.value === role);
return found ? found.label : role;
};
const formatChangeDescription = (entry: UserChangeLogEntry): string => {
if (entry.change_type === 'user_created') {
return `User account created (${formatRoleLabel(entry.new_value || 'basic_user')})`;
}
if (entry.change_type === 'role_changed') {
return `Role changed from ${formatRoleLabel(entry.old_value || '')} to ${formatRoleLabel(entry.new_value || '')}`;
}
if (entry.change_type === 'agency_changed') {
return `Agency changed from ${entry.old_value || 'None'} to ${entry.new_value || 'None'}`;
}
return entry.change_type;
};
const formatHistoryDate = (iso: string): string => {
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
+ ', '
+ d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
};
const handleOpenHistory = async (user: UserManagementResponse) => {
setHistoryUser({ id: user.id, name: user.name });
setHistoryLoading(true);
setHistoryEntries([]);
try {
const entries = await apiService.getUserChangeHistory(user.id);
setHistoryEntries(entries);
} catch (err) {
console.error('Failed to load change history:', err);
} finally {
setHistoryLoading(false);
}
};
const handleCloseHistory = () => {
setHistoryUser(null);
setHistoryEntries([]);
};
const handleCreateAgency = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAgencyName.trim()) return;
setIsCreatingAgency(true);
try {
const newAgency = await apiService.createAgency(newAgencyName.trim());
setAgencies(prev => [...prev, newAgency].sort((a, b) => a.name.localeCompare(b.name)));
setNewAgencyName('');
setError(null);
setSuccessMessage('Agency created successfully');
setTimeout(() => setSuccessMessage(prev => prev === 'Agency created successfully' ? null : prev), 3000);
} catch (err) {
console.error('Failed to create agency:', err);
setError('Failed to create agency.');
} finally {
setIsCreatingAgency(false);
}
};
if (isLoading) {
return (
<div className="p-8 flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<svg className="animate-spin h-8 w-8 text-active-blue" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="text-grey-700 text-sm">Loading user management...</p>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-white overflow-y-auto">
<header className="mb-6">
<h1 className="text-3xl lg:text-4xl font-semibold text-primary-blue">User Management</h1>
<p className="text-base lg:text-lg text-primary-blue mt-1">
{isSuperAdmin
? 'Manage user roles and agency assignments. Users are provisioned automatically via Azure AD.'
: 'View user roles and agency assignments.'}
</p>
</header>
{error && (
<div className="mb-4 p-3 bg-error/10 border border-error/30 rounded-[10px] text-error text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 font-semibold hover:underline">Dismiss</button>
</div>
)}
{successMessage && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-sm">
{successMessage}
<button onClick={() => setSuccessMessage(null)} className="ml-2 font-semibold hover:underline">Dismiss</button>
</div>
)}
{/* Users Table */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-primary-blue mb-4">Users ({users.length})</h2>
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-lime">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Agency</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Created</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{users.map((user, index) => (
<tr key={user.id} className={index % 2 === 0 ? 'bg-white' : 'bg-grey-100'}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-info-light flex items-center justify-center text-active-blue">
<UserIcon className="h-4 w-4" />
</div>
<div className="ml-3">
<div className="text-sm font-medium text-black-title">{user.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isSuperAdmin ? (
<div className="relative max-w-[180px]">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="w-full bg-white border-2 border-active-blue text-black-title py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
>
{ROLE_OPTIONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-active-blue pointer-events-none" />
</div>
) : (
<span className="text-sm text-black-title">{formatRoleLabel(user.role)}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isSuperAdmin ? (
<div className="relative max-w-[200px]">
<select
value={user.agency_id || ''}
onChange={(e) => handleAgencyChange(user.id, e.target.value || null)}
className="w-full bg-white border-2 border-active-blue text-black-title py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
>
<option value="">None (Unassigned)</option>
{agencies.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-active-blue pointer-events-none" />
</div>
) : (
<span className="text-sm text-black-title">
{user.agency || 'Unassigned'}
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-grey-700">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenHistory(user)}
title="View change history"
className="p-1 text-grey-700 hover:text-active-blue transition-colors rounded-full hover:bg-info-light"
>
<ClockIcon className="h-4 w-4" />
</button>
{savedUserId === user.id && (
<span className="inline-flex items-center gap-1 text-green-700 font-medium">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-grey-700">
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
{/* Super Admin Confirmation Modal */}
{pendingSuperAdminUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleCancelSuperAdmin}
>
<div
className="bg-white rounded-[10px] shadow-xl max-w-md w-full mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-amber-100 flex items-center justify-center">
<svg className="h-5 w-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-black-title">Confirm Super Admin Assignment</h3>
</div>
<p className="text-sm text-grey-700 mb-4">
You are about to grant Super Admin privileges to <strong className="text-black-title">{pendingSuperAdminUser.userName}</strong>. This gives full system access including user management. This action should only be performed intentionally.
</p>
<p className="text-sm text-grey-700 mb-2">
To confirm, type <strong className="text-black-title">make this user super admin</strong> below:
</p>
<input
type="text"
value={confirmationText}
onChange={(e) => {
setConfirmationText(e.target.value);
setConfirmationError(false);
}}
onPaste={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
placeholder="Type the phrase above..."
className={`w-full p-2 border-2 rounded-[10px] text-sm text-black-title focus:outline-none focus:ring-2 transition ${
confirmationError
? 'border-error focus:ring-error'
: 'border-grey-700 focus:ring-active-blue focus:border-active-blue'
}`}
autoFocus
/>
{confirmationError && (
<p className="text-error text-xs mt-1">Please type the exact phrase to confirm.</p>
)}
<div className="flex justify-end gap-3 mt-5">
<button
onClick={handleCancelSuperAdmin}
className="px-4 py-2 text-sm font-medium text-grey-700 hover:text-black-title border border-grey-300 rounded-full hover:bg-grey-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirmSuperAdmin}
disabled={confirmationText !== 'make this user super admin'}
className="px-4 py-2 text-sm font-medium text-white bg-active-blue rounded-full hover:bg-active-blue/90 transition-colors disabled:bg-grey-700 disabled:cursor-not-allowed"
>
Confirm
</button>
</div>
</div>
</div>
)}
{/* Change History Modal */}
{historyUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleCloseHistory}
>
<div
className="bg-white rounded-[10px] shadow-xl max-w-2xl w-full mx-4 p-6 max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-black-title">
Change History &mdash; {historyUser.name}
</h3>
<button
onClick={handleCloseHistory}
className="p-1 text-grey-700 hover:text-black-title transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1">
{historyLoading ? (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin h-6 w-6 text-active-blue" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : historyEntries.length === 0 ? (
<p className="text-center py-8 text-grey-700">No change history recorded.</p>
) : (
<table className="min-w-full">
<thead className="bg-grey-100 sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Date</th>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Change</th>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Changed by</th>
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{historyEntries.map((entry) => (
<tr key={entry.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-grey-700">
{formatHistoryDate(entry.created_at)}
</td>
<td className="px-4 py-3 text-sm text-black-title">
{formatChangeDescription(entry)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-grey-700">
{entry.changed_by_name || 'System'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Agency Management */}
<section>
<h2 className="text-xl font-semibold text-primary-blue mb-4">Agencies ({agencies.length})</h2>
<div className="max-w-xl">
{isSuperAdmin && (
<form onSubmit={handleCreateAgency} className="flex gap-2 mb-4">
<input
type="text"
value={newAgencyName}
onChange={(e) => setNewAgencyName(e.target.value)}
placeholder="New agency name..."
className="flex-grow p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue transition text-black-title"
/>
<button
type="submit"
disabled={!newAgencyName.trim() || isCreatingAgency}
className="bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors disabled:bg-grey-700 disabled:cursor-not-allowed flex items-center gap-2"
>
<PlusIcon className="h-4 w-4" />
Add
</button>
</form>
)}
<div className="space-y-2">
{agencies.map(agency => (
<div
key={agency.id}
className="flex items-center justify-between bg-grey-100 p-3 rounded-[10px] border border-grey-300"
>
<span className="text-black-title font-medium">{agency.name}</span>
<span className="text-xs text-grey-700">
{users.filter(u => u.agency_id === agency.id).length} user(s)
</span>
</div>
))}
{agencies.length === 0 && (
<div className="text-center py-6 text-grey-700 bg-grey-100 rounded-[10px]">
No agencies created yet.
</div>
)}
</div>
</div>
</section>
</div>
);
};