Add Phase 7 admin portal frontend (IAM, Config, Dashboard)
Creates 7 files: useAdmin hook with TanStack Query v5, AdminPortal layout with horizontal tab nav, IAMPage with user table + stat cards + system settings, UserActionsMenu with two-click confirms, CreateUserDialog, ConfigPage with paginated audit log + action filter, AdminDashboardPage with stats + recent logins/actions tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
464b8b911f
commit
2ec70d9344
241
frontend/src/components/admin/AdminDashboardPage.tsx
Normal file
241
frontend/src/components/admin/AdminDashboardPage.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Activity,
|
||||
Smartphone,
|
||||
LogIn,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
iconBg?: string;
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function actionColor(action: string): string {
|
||||
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
|
||||
return 'bg-red-500/15 text-red-400';
|
||||
}
|
||||
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
|
||||
return 'bg-green-500/15 text-green-400';
|
||||
}
|
||||
if (action.includes('config') || action.includes('role') || action.includes('password')) {
|
||||
return 'bg-orange-500/15 text-orange-400';
|
||||
}
|
||||
return 'bg-blue-500/15 text-blue-400';
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { data: dashboard, isLoading } = useAdminDashboard();
|
||||
const { data: auditData } = useAuditLog(1, 10);
|
||||
|
||||
const mfaPct = dashboard ? Math.round(dashboard.mfa_adoption_rate * 100) : null;
|
||||
const disabledUsers =
|
||||
dashboard ? dashboard.total_users - dashboard.active_users : null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-5">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<StatCard
|
||||
icon={<Users className="h-5 w-5 text-accent" />}
|
||||
label="Total Users"
|
||||
value={dashboard?.total_users ?? '—'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<UserCheck className="h-5 w-5 text-green-400" />}
|
||||
label="Active Users"
|
||||
value={dashboard?.active_users ?? '—'}
|
||||
iconBg="bg-green-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<UserX className="h-5 w-5 text-red-400" />}
|
||||
label="Disabled Users"
|
||||
value={disabledUsers ?? '—'}
|
||||
iconBg="bg-red-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="h-5 w-5 text-blue-400" />}
|
||||
label="Active Sessions"
|
||||
value={dashboard?.active_sessions ?? '—'}
|
||||
iconBg="bg-blue-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
|
||||
label="MFA Adoption"
|
||||
value={mfaPct !== null ? `${mfaPct}%` : '—'}
|
||||
iconBg="bg-purple-500/10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{/* Recent logins */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-green-500/10">
|
||||
<LogIn className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
<CardTitle>Recent Logins</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="px-5 pb-5 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !dashboard?.recent_logins?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent logins.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
IP
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.recent_logins.map((entry, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={cn(
|
||||
'border-b border-border hover:bg-card-elevated/50 transition-colors',
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-2.5 font-medium">{entry.username}</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{getRelativeTime(entry.last_login_at)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip_address ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent admin actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<ShieldAlert className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
<CardTitle>Recent Admin Actions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{!auditData?.entries?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent actions.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditData.entries.slice(0, 10).map((entry, idx) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'border-b border-border hover:bg-card-elevated/50 transition-colors',
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
actionColor(entry.action)
|
||||
)}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs font-medium">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/admin/AdminPortal.tsx
Normal file
64
frontend/src/components/admin/AdminPortal.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { NavLink, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Users, Settings2, LayoutDashboard, ShieldCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import IAMPage from './IAMPage';
|
||||
import ConfigPage from './ConfigPage';
|
||||
import AdminDashboardPage from './AdminDashboardPage';
|
||||
|
||||
const tabs = [
|
||||
{ label: 'IAM Management', path: '/admin/iam', icon: Users },
|
||||
{ label: 'Configuration', path: '/admin/config', icon: Settings2 },
|
||||
{ label: 'Management Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
export default function AdminPortal() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Portal header with tab navigation */}
|
||||
<div className="shrink-0 border-b bg-card">
|
||||
<div className="px-6 h-16 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 mr-6">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Admin Portal</h1>
|
||||
</div>
|
||||
|
||||
{/* Horizontal tab navigation */}
|
||||
<nav className="flex items-center gap-1 h-full">
|
||||
{tabs.map(({ label, path, icon: Icon }) => {
|
||||
const isActive = location.pathname.startsWith(path);
|
||||
return (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'text-accent border-accent'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="iam" replace />} />
|
||||
<Route path="iam" element={<IAMPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/admin/ConfigPage.tsx
Normal file
231
frontend/src/components/admin/ConfigPage.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuditLog } from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ACTION_TYPES = [
|
||||
'user.create',
|
||||
'user.login',
|
||||
'user.logout',
|
||||
'user.login_failed',
|
||||
'user.locked',
|
||||
'user.unlocked',
|
||||
'user.role_changed',
|
||||
'user.disabled',
|
||||
'user.enabled',
|
||||
'user.password_reset',
|
||||
'user.totp_disabled',
|
||||
'user.mfa_enforced',
|
||||
'user.mfa_enforcement_removed',
|
||||
'user.sessions_revoked',
|
||||
'config.updated',
|
||||
];
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return action
|
||||
.split('.')
|
||||
.map((p) => p.replace(/_/g, ' '))
|
||||
.join(' — ');
|
||||
}
|
||||
|
||||
function actionColor(action: string): string {
|
||||
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
|
||||
return 'bg-red-500/15 text-red-400';
|
||||
}
|
||||
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
|
||||
return 'bg-green-500/15 text-green-400';
|
||||
}
|
||||
if (action.includes('config') || action.includes('role') || action.includes('password')) {
|
||||
return 'bg-orange-500/15 text-orange-400';
|
||||
}
|
||||
return 'bg-blue-500/15 text-blue-400';
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [filterAction, setFilterAction] = useState<string>('');
|
||||
const PER_PAGE = 25;
|
||||
|
||||
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<FileText className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<CardTitle>Audit Log</CardTitle>
|
||||
{data && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.total} entries
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Filter:</span>
|
||||
</div>
|
||||
<div className="w-52">
|
||||
<Select
|
||||
value={filterAction}
|
||||
onChange={(e) => {
|
||||
setFilterAction(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{actionLabel(a)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{filterAction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
setFilterAction('');
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="px-5 pb-5 space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data?.entries?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
IP
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Detail
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.entries.map((entry, idx) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors hover:bg-card-elevated/50',
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs font-medium">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
actionColor(entry.action)
|
||||
)}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip_address ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
{entry.detail ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/admin/CreateUserDialog.tsx
Normal file
121
frontend/src/components/admin/CreateUserDialog.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { UserPlus, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCreateUser, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import type { UserRole } from '@/types';
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialogProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<UserRole>('standard');
|
||||
|
||||
const createUser = useCreateUser();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password.trim()) return;
|
||||
|
||||
try {
|
||||
await createUser.mutateAsync({ username: username.trim(), password, role });
|
||||
toast.success(`User "${username.trim()}" created successfully`);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('standard');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Failed to create user'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-accent" />
|
||||
Create User
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogClose onClick={() => onOpenChange(false)} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-username">Username</Label>
|
||||
<Input
|
||||
id="new-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-password">Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Must be at least 8 characters. The user will be prompted to change it on first login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-role">Role</Label>
|
||||
<Select
|
||||
id="new-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="public_event_manager">Public Event Manager</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={createUser.isPending || !username.trim() || !password.trim()}
|
||||
>
|
||||
{createUser.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
298
frontend/src/components/admin/IAMPage.tsx
Normal file
298
frontend/src/components/admin/IAMPage.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Plus,
|
||||
Loader2,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
useAdminUsers,
|
||||
useAdminDashboard,
|
||||
useAdminConfig,
|
||||
useUpdateConfig,
|
||||
getErrorMessage,
|
||||
} from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import type { AdminUserDetail, UserRole } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import UserActionsMenu from './UserActionsMenu';
|
||||
import CreateUserDialog from './CreateUserDialog';
|
||||
|
||||
// ── Role badge ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RoleBadge({ role }: { role: UserRole }) {
|
||||
const styles: Record<UserRole, string> = {
|
||||
admin: 'bg-red-500/15 text-red-400',
|
||||
standard: 'bg-blue-500/15 text-blue-400',
|
||||
public_event_manager: 'bg-purple-500/15 text-purple-400',
|
||||
};
|
||||
const labels: Record<UserRole, string> = {
|
||||
admin: 'Admin',
|
||||
standard: 'Standard',
|
||||
public_event_manager: 'Pub. Events',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
|
||||
styles[role]
|
||||
)}
|
||||
>
|
||||
{labels[role]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
iconBg?: string;
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function IAMPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const { data: users, isLoading: usersLoading } = useAdminUsers();
|
||||
const { data: dashboard } = useAdminDashboard();
|
||||
const { data: config, isLoading: configLoading } = useAdminConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({ [key]: value });
|
||||
toast.success('System settings updated');
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Failed to update settings'));
|
||||
}
|
||||
};
|
||||
|
||||
const mfaPct = dashboard
|
||||
? Math.round(dashboard.mfa_adoption_rate * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Users className="h-5 w-5 text-accent" />}
|
||||
label="Total Users"
|
||||
value={dashboard?.total_users ?? '—'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="h-5 w-5 text-green-400" />}
|
||||
label="Active Sessions"
|
||||
value={dashboard?.active_sessions ?? '—'}
|
||||
iconBg="bg-green-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ShieldCheck className="h-5 w-5 text-red-400" />}
|
||||
label="Admins"
|
||||
value={dashboard?.admin_count ?? '—'}
|
||||
iconBg="bg-red-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
|
||||
label="MFA Adoption"
|
||||
value={mfaPct !== null ? `${mfaPct}%` : '—'}
|
||||
iconBg="bg-purple-500/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User table */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Users className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<CardTitle>Users</CardTitle>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{usersLoading ? (
|
||||
<div className="px-5 pb-5 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !users?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No users found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
MFA
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Sessions
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user: AdminUserDetail, idx) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors hover:bg-card-elevated/50',
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-5 py-3">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
|
||||
user.is_active
|
||||
? 'bg-green-500/15 text-green-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
)}
|
||||
>
|
||||
{user.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{user.totp_enabled ? (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
|
||||
On
|
||||
</span>
|
||||
) : user.mfa_enforce_pending ? (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-orange-500/15 text-orange-400">
|
||||
Pending
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
|
||||
{user.active_sessions}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{getRelativeTime(user.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<UserActionsMenu user={user} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<ShieldCheck className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<CardTitle>System Settings</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{configLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Allow New Account Registration</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the /register page accepts new sign-ups.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.allow_registration ?? false}
|
||||
onCheckedChange={(v) => handleConfigToggle('allow_registration', v)}
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Enforce MFA on New Users</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Newly registered users will be required to set up TOTP before accessing the app.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.enforce_mfa_new_users ?? false}
|
||||
onCheckedChange={(v) => handleConfigToggle('enforce_mfa_new_users', v)}
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateUserDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
frontend/src/components/admin/UserActionsMenu.tsx
Normal file
296
frontend/src/components/admin/UserActionsMenu.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
KeyRound,
|
||||
UserX,
|
||||
UserCheck,
|
||||
LogOut,
|
||||
Smartphone,
|
||||
SmartphoneOff,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import {
|
||||
useUpdateRole,
|
||||
useResetPassword,
|
||||
useDisableMfa,
|
||||
useEnforceMfa,
|
||||
useRemoveMfaEnforcement,
|
||||
useToggleUserActive,
|
||||
useRevokeSessions,
|
||||
getErrorMessage,
|
||||
} from '@/hooks/useAdmin';
|
||||
import type { AdminUserDetail, UserRole } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UserActionsMenuProps {
|
||||
user: AdminUserDetail;
|
||||
}
|
||||
|
||||
const ROLES: { value: UserRole; label: string }[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'public_event_manager', label: 'Public Event Manager' },
|
||||
];
|
||||
|
||||
export default function UserActionsMenu({ user }: UserActionsMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false);
|
||||
const [showResetPassword, setShowResetPassword] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateRole = useUpdateRole();
|
||||
const resetPassword = useResetPassword();
|
||||
const disableMfa = useDisableMfa();
|
||||
const enforceMfa = useEnforceMfa();
|
||||
const removeMfaEnforcement = useRemoveMfaEnforcement();
|
||||
const toggleActive = useToggleUserActive();
|
||||
const revokeSessions = useRevokeSessions();
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setRoleSubmenuOpen(false);
|
||||
}
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handleOutside);
|
||||
return () => document.removeEventListener('mousedown', handleOutside);
|
||||
}, [open]);
|
||||
|
||||
const handleAction = async (fn: () => Promise<unknown>, successMsg: string) => {
|
||||
try {
|
||||
await fn();
|
||||
toast.success(successMsg);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Action failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Two-click confirms
|
||||
const disableMfaConfirm = useConfirmAction(() => {
|
||||
handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled');
|
||||
});
|
||||
|
||||
const toggleActiveConfirm = useConfirmAction(() => {
|
||||
handleAction(
|
||||
() => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }),
|
||||
user.is_active ? 'Account disabled' : 'Account enabled'
|
||||
);
|
||||
});
|
||||
|
||||
const revokeSessionsConfirm = useConfirmAction(() => {
|
||||
handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked');
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
updateRole.isPending ||
|
||||
resetPassword.isPending ||
|
||||
disableMfa.isPending ||
|
||||
enforceMfa.isPending ||
|
||||
removeMfaEnforcement.isPending ||
|
||||
toggleActive.isPending ||
|
||||
revokeSessions.isPending;
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="User actions"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-8 z-50 min-w-[200px] rounded-lg border bg-card shadow-lg py-1">
|
||||
{/* Edit Role */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
||||
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
||||
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
||||
onClick={() => setRoleSubmenuOpen((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
Edit Role
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{roleSubmenuOpen && (
|
||||
<div
|
||||
className="absolute left-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
||||
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
||||
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
||||
>
|
||||
{ROLES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors',
|
||||
user.role === value && 'text-accent'
|
||||
)}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
() => updateRole.mutateAsync({ userId: user.id, role: value }),
|
||||
`Role updated to ${label}`
|
||||
)
|
||||
}
|
||||
>
|
||||
{user.role === value && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset Password */}
|
||||
{!showResetPassword ? (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
||||
onClick={() => setShowResetPassword(true)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
Reset Password
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<input
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded-md bg-accent/15 px-2 py-1 text-xs text-accent hover:bg-accent/25 transition-colors"
|
||||
onClick={() => {
|
||||
if (!newPassword.trim()) return;
|
||||
handleAction(
|
||||
() => resetPassword.mutateAsync({ userId: user.id, new_password: newPassword }),
|
||||
'Password reset'
|
||||
);
|
||||
setNewPassword('');
|
||||
setShowResetPassword(false);
|
||||
}}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setShowResetPassword(false);
|
||||
setNewPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* MFA actions */}
|
||||
{user.mfa_enforce_pending ? (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
() => removeMfaEnforcement.mutateAsync(user.id),
|
||||
'MFA enforcement removed'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SmartphoneOff className="h-4 w-4 text-muted-foreground" />
|
||||
Remove MFA Enforcement
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
||||
onClick={() =>
|
||||
handleAction(() => enforceMfa.mutateAsync(user.id), 'MFA enforcement set')
|
||||
}
|
||||
>
|
||||
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
||||
Enforce MFA
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.totp_enabled && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
disableMfaConfirm.confirming
|
||||
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||
: 'hover:bg-card-elevated'
|
||||
)}
|
||||
onClick={disableMfaConfirm.handleClick}
|
||||
>
|
||||
<SmartphoneOff className="h-4 w-4" />
|
||||
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* Disable / Enable Account */}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
toggleActiveConfirm.confirming
|
||||
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||
: 'hover:bg-card-elevated'
|
||||
)}
|
||||
onClick={toggleActiveConfirm.handleClick}
|
||||
>
|
||||
{user.is_active ? (
|
||||
<>
|
||||
<UserX className="h-4 w-4" />
|
||||
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Account'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="h-4 w-4 text-green-400" />
|
||||
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Enable Account'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Revoke Sessions */}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
revokeSessionsConfirm.confirming
|
||||
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||
: 'hover:bg-card-elevated'
|
||||
)}
|
||||
onClick={revokeSessionsConfirm.handleClick}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{revokeSessionsConfirm.confirming ? 'Sure? Click to confirm' : 'Revoke All Sessions'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/hooks/useAdmin.ts
Normal file
165
frontend/src/hooks/useAdmin.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AdminDashboardData,
|
||||
SystemConfig,
|
||||
AuditLogEntry,
|
||||
UserRole,
|
||||
} from '@/types';
|
||||
|
||||
interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
interface CreateUserPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
interface UpdateRolePayload {
|
||||
userId: number;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
interface ResetPasswordPayload {
|
||||
userId: number;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAdminUsers() {
|
||||
return useQuery<AdminUserDetail[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<AdminUserDetail[]>('/admin/users');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDashboard() {
|
||||
return useQuery<AdminDashboardData>({
|
||||
queryKey: ['admin', 'dashboard'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<AdminDashboardData>('/admin/dashboard');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminConfig() {
|
||||
return useQuery<SystemConfig>({
|
||||
queryKey: ['admin', 'config'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SystemConfig>('/admin/config');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLog(
|
||||
page: number,
|
||||
perPage: number,
|
||||
action?: string,
|
||||
targetUserId?: number
|
||||
) {
|
||||
return useQuery<AuditLogResponse>({
|
||||
queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, unknown> = { page, per_page: perPage };
|
||||
if (action) params.action = action;
|
||||
if (targetUserId) params.target_user_id = targetUserId;
|
||||
const { data } = await api.get<AuditLogResponse>('/admin/audit-log', { params });
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function useAdminMutation<TVariables>(
|
||||
mutationFn: (vars: TVariables) => Promise<unknown>,
|
||||
onSuccess?: () => void
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<unknown, Error, TVariables>({
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
return useAdminMutation(async (payload: CreateUserPayload) => {
|
||||
const { data } = await api.post('/admin/users', payload);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => {
|
||||
const { data } = await api.patch(`/admin/users/${userId}/role`, { role });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => {
|
||||
const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisableMfa() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.delete(`/admin/users/${userId}/totp`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnforceMfa() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMfaEnforcement() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleUserActive() {
|
||||
return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => {
|
||||
const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeSessions() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.delete(`/admin/users/${userId}/sessions`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateConfig() {
|
||||
return useAdminMutation(async (config: Partial<SystemConfig>) => {
|
||||
const { data } = await api.patch('/admin/config', config);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export getErrorMessage for convenience in admin components
|
||||
export { getErrorMessage };
|
||||
Loading…
x
Reference in New Issue
Block a user