UMBRA/frontend/src/components/admin/AdminDashboardPage.tsx
Kyle Pope 2ec70d9344 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>
2026-02-26 18:40:16 +08:00

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