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>
224 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|