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:
Kyle 2026-02-26 18:40:16 +08:00
parent 464b8b911f
commit 2ec70d9344
7 changed files with 1416 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };