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