- Tab nav: scroll isolation, icon-only on mobile, accessible titles - IAM table: hide 6 columns on mobile, responsive padding - User detail: responsive grid (1→2→3 cols), role select sizing - Dashboard: responsive stats grid, hide Actor/Target cols on mobile - Audit log: responsive column hiding and padding - Actions menu: role submenu repositions below trigger on mobile - Config: narrower filter select on mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
201 lines
8.1 KiB
TypeScript
201 lines
8.1 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';
|
|
import { StatCard, actionColor } from './shared';
|
|
|
|
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-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
|
{/* Stats grid */}
|
|
<div className="grid gap-2.5 grid-cols-2 md:grid-cols-3 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-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
|
Username
|
|
</th>
|
|
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
|
When
|
|
</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-3 lg:px-5 py-2.5 font-medium">{entry.username}</td>
|
|
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground">
|
|
{getRelativeTime(entry.last_login_at)}
|
|
</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-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
|
Action
|
|
</th>
|
|
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
|
Actor
|
|
</th>
|
|
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
|
Target
|
|
</th>
|
|
<th className="px-3 lg: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-3 lg: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-3 lg:px-5 py-2.5 text-xs font-medium hidden sm:table-cell">
|
|
{entry.actor_username ?? (
|
|
<span className="text-muted-foreground italic">system</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground hidden sm:table-cell">
|
|
{entry.target_username ?? '—'}
|
|
</td>
|
|
<td className="px-3 lg: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>
|
|
);
|
|
}
|