modcomms/frontend/components/UserManagement.tsx
michael a5d5d51d2a Add confirmation modal for Super Admin role assignment
Prevents accidental Super Admin privilege grants by requiring users to
type "make this user super admin" before the role change is applied.
Modal blocks paste/drag input and reverts the dropdown on cancel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:25:56 -06:00

373 lines
20 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import apiService from '../services/apiService';
import type { UserManagementResponse, AgencyResponse } from '../services/apiService';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
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 [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);
// 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);
} 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);
} 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);
} catch (err) {
console.error('Failed to update user agency:', err);
setError('Failed to update user agency.');
}
};
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">
Manage user roles and agency assignments. Users are provisioned automatically via Azure AD.
</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">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</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">
{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>
)}
</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>
)}
{/* Agency Management */}
<section>
<h2 className="text-xl font-semibold text-primary-blue mb-4">Agencies ({agencies.length})</h2>
<div className="max-w-xl">
<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>
);
};