Kyle Pope 8582b41b03 Add user profile fields + IAM search, email column, detail panel
Backend:
- Migration 037: add email, first_name, last_name to users table
- User model: add 3 profile columns
- Admin schemas: extend UserListItem/UserDetailResponse/CreateUserRequest
  with profile fields, email validator, name field sanitization
- _create_user_defaults: accept optional preferred_name kwarg
- POST /users: set profile fields, email uniqueness check, IntegrityError guard
- GET /users/{id}: join Settings for preferred_name, include must_change_password/locked_until

Frontend:
- AdminUser/AdminUserDetail types: add profile + detail fields
- useAdmin: add CreateUserPayload profile fields + useAdminUserDetail query
- CreateUserDialog: optional profile section (first/last name, email, preferred name)
- IAMPage: search bar filtering on username/email/name, email column in table,
  row click to select user with accent highlight
- UserDetailSection: two-column detail panel (User Info + Security & Permissions)
  with inline role editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:40:20 +08:00

326 lines
13 KiB
TypeScript

import { useState, useMemo } from 'react';
import { toast } from 'sonner';
import {
Users,
ShieldCheck,
Smartphone,
Plus,
Activity,
Search,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { StatCard } from './shared';
import UserDetailSection from './UserDetailSection';
import {
useAdminUsers,
useAdminDashboard,
useAdminConfig,
useUpdateConfig,
getErrorMessage,
} from '@/hooks/useAdmin';
import { useAuth } from '@/hooks/useAuth';
import { getRelativeTime } from '@/lib/date-utils';
import type { AdminUserDetail, UserRole } from '@/types';
import { cn } from '@/lib/utils';
import UserActionsMenu from './UserActionsMenu';
import CreateUserDialog from './CreateUserDialog';
// ── Role badge ────────────────────────────────────────────────────────────────
function RoleBadge({ role }: { role: UserRole }) {
const styles: Record<UserRole, string> = {
admin: 'bg-red-500/15 text-red-400',
standard: 'bg-blue-500/15 text-blue-400',
public_event_manager: 'bg-purple-500/15 text-purple-400',
};
const labels: Record<UserRole, string> = {
admin: 'Admin',
standard: 'Standard',
public_event_manager: 'Pub. Events',
};
return (
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
styles[role]
)}
>
{labels[role]}
</span>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function IAMPage() {
const [createOpen, setCreateOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const { authStatus } = useAuth();
const { data: users, isLoading: usersLoading } = useAdminUsers();
const { data: dashboard } = useAdminDashboard();
const { data: config, isLoading: configLoading } = useAdminConfig();
const updateConfig = useUpdateConfig();
const filteredUsers = useMemo(() => {
if (!users) return [];
if (!searchQuery.trim()) return users;
const q = searchQuery.toLowerCase();
return users.filter(
(u) =>
u.username.toLowerCase().includes(q) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.first_name && u.first_name.toLowerCase().includes(q)) ||
(u.last_name && u.last_name.toLowerCase().includes(q))
);
}, [users, searchQuery]);
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
try {
await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to update settings'));
}
};
const mfaPct = dashboard
? Math.round(dashboard.mfa_adoption_rate * 100)
: null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-5 w-5 text-accent" />}
label="Total Users"
value={dashboard?.total_users ?? '—'}
/>
<StatCard
icon={<Activity className="h-5 w-5 text-green-400" />}
label="Active Sessions"
value={dashboard?.active_sessions ?? '—'}
iconBg="bg-green-500/10"
/>
<StatCard
icon={<ShieldCheck className="h-5 w-5 text-red-400" />}
label="Admins"
value={dashboard?.admin_count ?? '—'}
iconBg="bg-red-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>
{/* User table */}
<Card>
<CardHeader className="flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" />
</div>
<CardTitle>Users</CardTitle>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
className="pl-8 h-8 w-48 text-xs"
/>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Create User
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{usersLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !filteredUsers.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">
{searchQuery ? 'No users match your search.' : 'No users found.'}
</p>
) : (
<div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Email
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Role
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Status
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Last Login
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
MFA
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Sessions
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Created
</th>
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user: AdminUserDetail, idx) => (
<tr
key={user.id}
onClick={() => setSelectedUserId(selectedUserId === user.id ? null : user.id)}
className={cn(
'border-b border-border transition-colors cursor-pointer',
selectedUserId === user.id
? 'bg-accent/5 border-l-2 border-l-accent'
: cn(
'hover:bg-card-elevated/50',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.email || '—'}
</td>
<td className="px-5 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
user.is_active
? 'bg-green-500/15 text-green-400'
: 'bg-red-500/15 text-red-400'
)}
>
{user.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
</td>
<td className="px-5 py-3">
{user.totp_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
On
</span>
) : user.mfa_enforce_pending ? (
<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>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
{user.active_sessions}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{getRelativeTime(user.created_at)}
</td>
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* User detail section */}
{selectedUserId !== null && (
<UserDetailSection
userId={selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
)}
{/* System settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<CardTitle>System Settings</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5">
{configLoading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Allow New Account Registration</Label>
<p className="text-xs text-muted-foreground">
When enabled, the /register page accepts new sign-ups.
</p>
</div>
<Switch
checked={config?.allow_registration ?? false}
onCheckedChange={(v) => handleConfigToggle('allow_registration', v)}
disabled={updateConfig.isPending}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Enforce MFA on New Users</Label>
<p className="text-xs text-muted-foreground">
Newly registered users will be required to set up TOTP before accessing the app.
</p>
</div>
<Switch
checked={config?.enforce_mfa_new_users ?? false}
onCheckedChange={(v) => handleConfigToggle('enforce_mfa_new_users', v)}
disabled={updateConfig.isPending}
/>
</div>
</>
)}
</CardContent>
</Card>
<CreateUserDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}