UMBRA/frontend/src/components/admin/ConfigPage.tsx
Kyle Pope 84b3083987 Admin portal mobile responsiveness: tables, grids, and nav
- 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>
2026-03-11 02:54:23 +08:00

229 lines
8.8 KiB
TypeScript

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';
import { actionColor } from './shared';
const ACTION_TYPES = [
'admin.user_created',
'admin.role_changed',
'admin.password_reset',
'admin.mfa_disabled',
'admin.mfa_enforce_toggled',
'admin.user_deactivated',
'admin.user_activated',
'admin.sessions_revoked',
'admin.config_updated',
'auth.login_success',
'auth.login_failed',
'auth.setup_complete',
'auth.registration',
'auth.mfa_enforce_prompted',
'connection.request_sent',
'connection.request_cancelled',
'connection.accepted',
'connection.rejected',
'connection.removed',
];
function actionLabel(action: string): string {
return action
.split('.')
.map((p) => p.replace(/_/g, ' '))
.join(' — ');
}
export default function ConfigPage() {
const [page, setPage] = useState(1);
const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25;
const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return (
<div className="px-4 md: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-36 sm: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>
) : error ? (
<div className="px-5 pb-5">
<p className="text-sm text-destructive">Failed to load audit log</p>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</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-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Time
</th>
<th className="px-3 lg:px-5 py-3 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-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action
</th>
<th className="px-3 lg:px-5 py-3 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-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
IP
</th>
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
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-3 lg:px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
<td className="px-3 lg:px-5 py-3 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-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-3 lg:px-5 py-3 text-xs text-muted-foreground hidden sm:table-cell">
{entry.target_username ?? '—'}
</td>
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground font-mono hidden lg:table-cell">
{entry.ip_address ?? '—'}
</td>
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground max-w-xs truncate hidden lg:table-cell">
{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>
);
}