From 2ec70d93443377a6df01b87cb49ef103ff41e8c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 18:40:16 +0800 Subject: [PATCH] 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 --- .../components/admin/AdminDashboardPage.tsx | 241 ++++++++++++++ frontend/src/components/admin/AdminPortal.tsx | 64 ++++ frontend/src/components/admin/ConfigPage.tsx | 231 ++++++++++++++ .../src/components/admin/CreateUserDialog.tsx | 121 +++++++ frontend/src/components/admin/IAMPage.tsx | 298 ++++++++++++++++++ .../src/components/admin/UserActionsMenu.tsx | 296 +++++++++++++++++ frontend/src/hooks/useAdmin.ts | 165 ++++++++++ 7 files changed, 1416 insertions(+) create mode 100644 frontend/src/components/admin/AdminDashboardPage.tsx create mode 100644 frontend/src/components/admin/AdminPortal.tsx create mode 100644 frontend/src/components/admin/ConfigPage.tsx create mode 100644 frontend/src/components/admin/CreateUserDialog.tsx create mode 100644 frontend/src/components/admin/IAMPage.tsx create mode 100644 frontend/src/components/admin/UserActionsMenu.tsx create mode 100644 frontend/src/hooks/useAdmin.ts diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx new file mode 100644 index 0000000..33d95c4 --- /dev/null +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -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 ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +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 ( +
+ {/* Stats grid */} +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : ( + <> + } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Users" + value={dashboard?.active_users ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Disabled Users" + value={disabledUsers ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-blue-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> + + )} +
+ +
+ {/* Recent logins */} + + +
+
+ +
+ Recent Logins +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : !dashboard?.recent_logins?.length ? ( +

No recent logins.

+ ) : ( +
+ + + + + + + + + + {dashboard.recent_logins.map((entry, idx) => ( + + + + + + ))} + +
+ Username + + When + + IP +
{entry.username} + {getRelativeTime(entry.last_login_at)} + + {entry.ip_address ?? '—'} +
+
+ )} +
+
+ + {/* Recent admin actions */} + + +
+
+ +
+ Recent Admin Actions +
+
+ + {!auditData?.entries?.length ? ( +

No recent actions.

+ ) : ( +
+ + + + + + + + + + + {auditData.entries.slice(0, 10).map((entry, idx) => ( + + + + + + + ))} + +
+ Action + + Actor + + Target + + When +
+ + {entry.action} + + + {entry.actor_username ?? ( + system + )} + + {entry.target_username ?? '—'} + + {getRelativeTime(entry.created_at)} +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/AdminPortal.tsx b/frontend/src/components/admin/AdminPortal.tsx new file mode 100644 index 0000000..b519f6f --- /dev/null +++ b/frontend/src/components/admin/AdminPortal.tsx @@ -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 ( +
+ {/* Portal header with tab navigation */} +
+
+
+
+ +
+

Admin Portal

+
+ + {/* Horizontal tab navigation */} + +
+
+ + {/* Page content */} +
+ + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx new file mode 100644 index 0000000..8a9ce15 --- /dev/null +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -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(''); + const PER_PAGE = 25; + + const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined); + + const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; + + return ( +
+ + +
+
+ +
+ Audit Log + {data && ( + + {data.total} entries + + )} +
+ + {/* Filter controls */} +
+
+ + Filter: +
+
+ +
+ {filterAction && ( + + )} +
+
+ + + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : !data?.entries?.length ? ( +

No audit entries found.

+ ) : ( + <> +
+ + + + + + + + + + + + + {data.entries.map((entry, idx) => ( + + + + + + + + + ))} + +
+ Time + + Actor + + Action + + Target + + IP + + Detail +
+ {getRelativeTime(entry.created_at)} + + {entry.actor_username ?? ( + system + )} + + + {entry.action} + + + {entry.target_username ?? '—'} + + {entry.ip_address ?? '—'} + + {entry.detail ?? '—'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx new file mode 100644 index 0000000..a44164e --- /dev/null +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -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('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 ( + + + + + + Create User + + + onOpenChange(false)} /> + +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + autoFocus + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Min. 8 characters" + required + /> +

+ Must be at least 8 characters. The user will be prompted to change it on first login. +

+
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx new file mode 100644 index 0000000..64329b9 --- /dev/null +++ b/frontend/src/components/admin/IAMPage.tsx @@ -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 = { + 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 = { + admin: 'Admin', + standard: 'Standard', + public_event_manager: 'Pub. Events', + }; + return ( + + {labels[role]} + + ); +} + +// ── 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 ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +// ── 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 ( +
+ {/* Stats row */} +
+ } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Admins" + value={dashboard?.admin_count ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> +
+ + {/* User table */} + + +
+
+ +
+ Users +
+ +
+ + {usersLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : !users?.length ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + + + {users.map((user: AdminUserDetail, idx) => ( + + + + + + + + + + + ))} + +
+ Username + + Role + + Status + + Last Login + + MFA + + Sessions + + Created + + Actions +
{user.username} + + + + {user.is_active ? 'Active' : 'Disabled'} + + + {user.last_login_at ? getRelativeTime(user.last_login_at) : '—'} + + {user.totp_enabled ? ( + + On + + ) : user.mfa_enforce_pending ? ( + + Pending + + ) : ( + + )} + + {user.active_sessions} + + {getRelativeTime(user.created_at)} + + +
+
+ )} +
+
+ + {/* System settings */} + + +
+
+ +
+ System Settings +
+
+ + {configLoading ? ( +
+ + +
+ ) : ( + <> +
+
+ +

+ When enabled, the /register page accepts new sign-ups. +

+
+ handleConfigToggle('allow_registration', v)} + disabled={updateConfig.isPending} + /> +
+ +
+
+ +

+ Newly registered users will be required to set up TOTP before accessing the app. +

+
+ handleConfigToggle('enforce_mfa_new_users', v)} + disabled={updateConfig.isPending} + /> +
+ + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx new file mode 100644 index 0000000..3163b2f --- /dev/null +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -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(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, 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 ( +
+ + + {open && ( +
+ {/* Edit Role */} +
+ + + {roleSubmenuOpen && ( +
setRoleSubmenuOpen(true)} + onMouseLeave={() => setRoleSubmenuOpen(false)} + > + {ROLES.map(({ value, label }) => ( + + ))} +
+ )} +
+ + {/* Reset Password */} + {!showResetPassword ? ( + + ) : ( +
+ setNewPassword(e.target.value)} + autoFocus + /> +
+ + +
+
+ )} + +
+ + {/* MFA actions */} + {user.mfa_enforce_pending ? ( + + ) : ( + + )} + + {user.totp_enabled && ( + + )} + +
+ + {/* Disable / Enable Account */} + + + {/* Revoke Sessions */} + +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts new file mode 100644 index 0000000..3d9d484 --- /dev/null +++ b/frontend/src/hooks/useAdmin.ts @@ -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({ + queryKey: ['admin', 'users'], + queryFn: async () => { + const { data } = await api.get('/admin/users'); + return data; + }, + }); +} + +export function useAdminDashboard() { + return useQuery({ + queryKey: ['admin', 'dashboard'], + queryFn: async () => { + const { data } = await api.get('/admin/dashboard'); + return data; + }, + }); +} + +export function useAdminConfig() { + return useQuery({ + queryKey: ['admin', 'config'], + queryFn: async () => { + const { data } = await api.get('/admin/config'); + return data; + }, + }); +} + +export function useAuditLog( + page: number, + perPage: number, + action?: string, + targetUserId?: number +) { + return useQuery({ + queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId], + queryFn: async () => { + const params: Record = { page, per_page: perPage }; + if (action) params.action = action; + if (targetUserId) params.target_user_id = targetUserId; + const { data } = await api.get('/admin/audit-log', { params }); + return data; + }, + }); +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +function useAdminMutation( + mutationFn: (vars: TVariables) => Promise, + onSuccess?: () => void +) { + const queryClient = useQueryClient(); + return useMutation({ + 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) => { + const { data } = await api.patch('/admin/config', config); + return data; + }); +} + +// Re-export getErrorMessage for convenience in admin components +export { getErrorMessage };