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:
parent
fd934bbb5f
commit
a25c7a9d31
4 changed files with 65 additions and 49 deletions
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue