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>
242 lines
9.4 KiB
TypeScript
242 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|