Grant oversight_admin read-only access to User Management

Allow oversight_admin users to view the User Management screen with
read-only access. They can see users, roles, agencies, and change
history but cannot edit roles, assign agencies, or create agencies.

Backend: open GET /users and GET /users/{id}/change-history to
oversight_admin (PUT /users stays super_admin only).
Frontend: add oversight_admin to sidebar nav and context permission,
render static text instead of dropdowns and hide the add-agency form
for non-super-admin users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael 2026-02-24 13:35:07 -06:00
parent fd934bbb5f
commit a25c7a9d31
4 changed files with 65 additions and 49 deletions

View file

@ -611,9 +611,9 @@ async def get_analytics_by_agency(
@router.get("/users", response_model=list[UserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("super_admin")),
current_user: User = Depends(require_role("super_admin", "oversight_admin")),
):
"""List all users (super_admin only)."""
"""List all users (super_admin and oversight_admin)."""
user_repo = UserRepository(db)
users = await user_repo.list_all()
@ -706,9 +706,9 @@ async def update_user(
async def get_user_change_history(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("super_admin")),
current_user: User = Depends(require_role("super_admin", "oversight_admin")),
):
"""Get change history for a user (super_admin only)."""
"""Get change history for a user (super_admin and oversight_admin)."""
user_repo = UserRepository(db)
logs = await user_repo.get_change_logs(user_id)
return [

View file

@ -22,7 +22,7 @@ const navigation: NavItem[] = [
{ name: 'Analytics', icon: AnalyticsIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin'] },
{ name: 'Auditing', icon: AuditIcon, roles: ['super_admin', 'oversight_admin'] },
{ name: 'Knowledge Base', icon: KnowledgeBaseIcon, roles: ['super_admin'] },
{ name: 'User Management', icon: UserIcon, roles: ['super_admin'] },
{ name: 'User Management', icon: UserIcon, roles: ['super_admin', 'oversight_admin'] },
{ name: 'Settings', icon: SettingsIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin'] },
];

View file

@ -5,6 +5,7 @@ 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 }[] = [
@ -19,6 +20,7 @@ const formatDate = (iso: string) => {
};
export const UserManagement: React.FC = () => {
const { isSuperAdmin } = useUser();
const [users, setUsers] = useState<UserManagementResponse[]>([]);
const [agencies, setAgencies] = useState<AgencyResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -205,7 +207,9 @@ export const UserManagement: React.FC = () => {
<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.
{isSuperAdmin
? 'Manage user roles and agency assignments. Users are provisioned automatically via Azure AD.'
: 'View user roles and agency assignments.'}
</p>
</header>
@ -256,33 +260,43 @@ export const UserManagement: React.FC = () => {
{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>
{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">
<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>
{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)}
@ -453,23 +467,25 @@ export const UserManagement: React.FC = () => {
<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>
{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 => (

View file

@ -78,7 +78,7 @@ export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children
canSeeAuditing: role === 'super_admin' || role === 'oversight_admin',
canSeeKnowledgeBase: role === 'super_admin',
canSeeSettings: role === 'super_admin' || role === 'oversight_admin' || role === 'agency_admin',
canSeeUserManagement: role === 'super_admin',
canSeeUserManagement: role === 'super_admin' || role === 'oversight_admin',
canEditSettings: role === 'super_admin',
isUnassigned: user != null && user.agencyId == null
&& role !== 'super_admin' && role !== 'oversight_admin',