UMBRA/frontend/src/components/admin/UserDetailSection.tsx
Kyle Pope 03fd8dba97 Fix notification UX, admin panel error handling, and data bugs
Notification fixes:
- Add NotificationToaster component with real-time toast notifications
  for new incoming notifications (30s polling, 15s stale time)
- Connection request toasts show inline Accept/Reject buttons
- Add inline Accept/Reject buttons to connection_request notifications
  in NotificationsPage (prevents bricked requests after navigation)
- Don't mark connection_request as read or navigate away when pending
- Auto-refetch notification list when unread count increases

Admin panel fixes:
- Add error state UI to UserDetailSection and ConfigPage (previously
  silently returned null/empty on API errors)
- Fix get_user response missing must_change_password and locked_until
- Fix create_user response missing preferred_name and date_of_birth
- Add defensive limit(1) on settings query to prevent MultipleResultsFound
- Guard _target_username_col JSONB cast with CASE to prevent crash on
  non-JSON audit detail values
- Add connection audit action types to ConfigPage filter dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:55:14 +08:00

224 lines
7.6 KiB
TypeScript

import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import type { UserRole } from '@/types';
interface UserDetailSectionProps {
userId: number;
onClose: () => void;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right">{value || '—'}</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
)}
>
{active ? 'Active' : 'Disabled'}
</span>
);
}
function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) {
if (enabled) {
return (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
Enabled
</span>
);
}
if (pending) {
return (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-orange-500/15 text-orange-400">
Pending
</span>
);
}
return <span className="text-xs text-muted-foreground">Off</span>;
}
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const handleRoleChange = async (newRole: UserRole) => {
if (!user || newRole === user.role) return;
try {
await updateRole.mutateAsync({ userId: user.id, role: newRole });
toast.success(`Role updated to "${newRole}"`);
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to update role'));
}
};
if (isLoading) {
return (
<div className="grid grid-cols-4 gap-4">
<Card className="col-span-1">
<CardContent className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-1">
<CardContent className="p-5 space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</CardContent>
</Card>
</div>
);
}
if (error) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-sm text-destructive">Failed to load user details</p>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</CardContent>
</Card>
);
}
if (!user) return null;
return (
<div className="grid grid-cols-4 gap-4">
{/* User Information (read-only) */}
<Card className="col-span-1">
<CardHeader className="flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<User className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">User Information</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={onClose}
>
<X className="h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<DetailRow label="Username" value={user.username} />
<DetailRow label="First Name" value={user.first_name} />
<DetailRow label="Last Name" value={user.last_name} />
<DetailRow label="Email" value={user.email} />
<DetailRow label="Preferred Name" value={user.preferred_name} />
<DetailRow
label="Date of Birth"
value={user.date_of_birth ? (() => {
const dob = new Date(user.date_of_birth + 'T00:00:00');
const now = new Date();
let age = now.getFullYear() - dob.getFullYear();
if (now.getMonth() < dob.getMonth() || (now.getMonth() === dob.getMonth() && now.getDate() < dob.getDate())) age--;
return `${dob.toLocaleDateString()} (${age})`;
})() : null}
/>
<DetailRow
label="Created"
value={getRelativeTime(user.created_at)}
/>
</CardContent>
</Card>
{/* Security & Permissions */}
<Card className="col-span-1">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ShieldCheck className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">Security & Permissions</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<div className="flex items-center justify-between gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0">Role</span>
<div className="flex items-center gap-1.5">
<Select
value={user.role}
onChange={(e) => handleRoleChange(e.target.value as UserRole)}
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[120px]"
disabled={updateRole.isPending}
>
<option value="admin">Admin</option>
<option value="standard">Standard</option>
<option value="public_event_manager">Pub. Events</option>
</Select>
{updateRole.isPending && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
</div>
<DetailRow
label="Account Status"
value={<StatusBadge active={user.is_active} />}
/>
<DetailRow
label="MFA Status"
value={
<MfaBadge
enabled={user.totp_enabled}
pending={user.mfa_enforce_pending}
/>
}
/>
<DetailRow
label="Must Change Pwd"
value={user.must_change_password ? 'Yes' : 'No'}
/>
<DetailRow
label="Active Sessions"
value={String(user.active_sessions)}
/>
<DetailRow
label="Last Login"
value={user.last_login_at ? getRelativeTime(user.last_login_at) : null}
/>
<DetailRow
label="Last Pwd Change"
value={
user.last_password_change_at
? getRelativeTime(user.last_password_change_at)
: null
}
/>
<DetailRow
label="Locked Until"
value={user.locked_until ?? null}
/>
</CardContent>
</Card>
</div>
);
}