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>
This commit is contained in:
Kyle 2026-02-27 22:40:20 +08:00
parent c3654dc069
commit 8582b41b03
10 changed files with 445 additions and 20 deletions

View File

@ -0,0 +1,29 @@
"""Add user profile fields (email, first_name, last_name).
Revision ID: 037
Revises: 036
"""
from alembic import op
import sqlalchemy as sa
revision = "037"
down_revision = "036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("email", sa.String(255), nullable=True))
op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True))
op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_index("ix_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_constraint("uq_users_email", "users", type_="unique")
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")
op.drop_column("users", "email")

View File

@ -9,6 +9,9 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# MFA — populated in Track B # MFA — populated in Track B

View File

@ -17,12 +17,14 @@ from typing import Optional
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.backup_code import BackupCode from app.models.backup_code import BackupCode
from app.models.session import UserSession from app.models.session import UserSession
from app.models.settings import Settings
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.routers.auth import ( from app.routers.auth import (
@ -153,7 +155,7 @@ async def get_user(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user), _actor: User = Depends(get_current_user),
): ):
"""Return a single user with their active session count.""" """Return a single user with their active session count and preferred_name."""
result = await db.execute(sa.select(User).where(User.id == user_id)) result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@ -168,9 +170,16 @@ async def get_user(
) )
active_sessions = session_result.scalar_one() active_sessions = session_result.scalar_one()
# Fetch preferred_name from Settings
settings_result = await db.execute(
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
)
preferred_name = settings_result.scalar_one_or_none()
return UserDetailResponse( return UserDetailResponse(
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
active_sessions=active_sessions, active_sessions=active_sessions,
preferred_name=preferred_name,
) )
@ -190,10 +199,20 @@ async def create_user(
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already taken") raise HTTPException(status_code=409, detail="Username already taken")
# Check email uniqueness if provided
email = data.email.strip().lower() if data.email else None
if email:
email_exists = await db.execute(sa.select(User).where(User.email == email))
if email_exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already in use")
new_user = User( new_user = User(
username=data.username, username=data.username,
password_hash=hash_password(data.password), password_hash=hash_password(data.password),
role=data.role, role=data.role,
email=email,
first_name=data.first_name,
last_name=data.last_name,
last_password_change_at=datetime.now(), last_password_change_at=datetime.now(),
# Force password change so the user sets their own credential # Force password change so the user sets their own credential
must_change_password=True, must_change_password=True,
@ -201,7 +220,7 @@ async def create_user(
db.add(new_user) db.add(new_user)
await db.flush() # populate new_user.id await db.flush() # populate new_user.id
await _create_user_defaults(db, new_user.id) await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name)
await log_audit_event( await log_audit_event(
db, db,
@ -211,7 +230,12 @@ async def create_user(
detail={"username": new_user.username, "role": new_user.role}, detail={"username": new_user.username, "role": new_user.role},
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
) )
try:
await db.commit() await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Username or email already in use")
return UserDetailResponse( return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),

View File

@ -227,9 +227,11 @@ async def _create_db_session(
# User bootstrapping helper (Settings + default calendars) # User bootstrapping helper (Settings + default calendars)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _create_user_defaults(db: AsyncSession, user_id: int) -> None: async def _create_user_defaults(
db: AsyncSession, user_id: int, *, preferred_name: str | None = None,
) -> None:
"""Create Settings row and default calendars for a new user.""" """Create Settings row and default calendars for a new user."""
db.add(Settings(user_id=user_id)) db.add(Settings(user_id=user_id, preferred_name=preferred_name))
db.add(Calendar( db.add(Calendar(
name="Personal", color="#3b82f6", name="Personal", color="#3b82f6",
is_default=True, is_system=False, is_visible=True, is_default=True, is_system=False, is_visible=True,

View File

@ -8,7 +8,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength from app.schemas.auth import _validate_username, _validate_password_strength
@ -20,6 +20,9 @@ from app.schemas.auth import _validate_username, _validate_password_strength
class UserListItem(BaseModel): class UserListItem(BaseModel):
id: int id: int
username: str username: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: str role: str
is_active: bool is_active: bool
last_login_at: Optional[datetime] = None last_login_at: Optional[datetime] = None
@ -38,7 +41,9 @@ class UserListResponse(BaseModel):
class UserDetailResponse(UserListItem): class UserDetailResponse(UserListItem):
pass preferred_name: Optional[str] = None
must_change_password: bool = False
locked_until: Optional[datetime] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -52,6 +57,10 @@ class CreateUserRequest(BaseModel):
username: str username: str
password: str password: str
role: Literal["admin", "standard", "public_event_manager"] = "standard" role: Literal["admin", "standard", "public_event_manager"] = "standard"
email: Optional[str] = Field(None, max_length=254)
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
preferred_name: Optional[str] = Field(None, max_length=100)
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@ -63,6 +72,32 @@ class CreateUserRequest(BaseModel):
def validate_password(cls, v: str) -> str: def validate_password(cls, v: str) -> str:
return _validate_password_strength(v) return _validate_password_strength(v)
@field_validator("email")
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip().lower()
if not v:
return None
# Basic format check: must have exactly one @, with non-empty local and domain parts
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v):
raise ValueError("Invalid email format")
return v
@field_validator("first_name", "last_name", "preferred_name")
@classmethod
def validate_name_fields(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
if not v:
return None
# Reject ASCII control characters
if re.search(r"[\x00-\x1f]", v):
raise ValueError("Name must not contain control characters")
return v
class UpdateUserRoleRequest(BaseModel): class UpdateUserRoleRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@ -25,6 +25,10 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('standard'); const [role, setRole] = useState<UserRole>('standard');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [preferredName, setPreferredName] = useState('');
const createUser = useCreateUser(); const createUser = useCreateUser();
@ -32,12 +36,26 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
e.preventDefault(); e.preventDefault();
if (!username.trim() || !password.trim()) return; if (!username.trim() || !password.trim()) return;
const payload: Parameters<typeof createUser.mutateAsync>[0] = {
username: username.trim(),
password,
role,
};
if (email.trim()) payload.email = email.trim();
if (firstName.trim()) payload.first_name = firstName.trim();
if (lastName.trim()) payload.last_name = lastName.trim();
if (preferredName.trim()) payload.preferred_name = preferredName.trim();
try { try {
await createUser.mutateAsync({ username: username.trim(), password, role }); await createUser.mutateAsync(payload);
toast.success(`User "${username.trim()}" created successfully`); toast.success(`User "${username.trim()}" created successfully`);
setUsername(''); setUsername('');
setPassword(''); setPassword('');
setRole('standard'); setRole('standard');
setFirstName('');
setLastName('');
setEmail('');
setPreferredName('');
onOpenChange(false); onOpenChange(false);
} catch (err) { } catch (err) {
toast.error(getErrorMessage(err, 'Failed to create user')); toast.error(getErrorMessage(err, 'Failed to create user'));
@ -96,6 +114,51 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
</Select> </Select>
</div> </div>
<div className="pt-2 space-y-3">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider font-medium">
Optional Profile
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="new-first-name">First Name</Label>
<Input
id="new-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="First name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-last-name">Last Name</Label>
<Input
id="new-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Last name"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-email">Email</Label>
<Input
id="new-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-preferred-name">Preferred Name</Label>
<Input
id="new-preferred-name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
placeholder="Display name"
/>
</div>
</div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Users, Users,
@ -6,13 +6,16 @@ import {
Smartphone, Smartphone,
Plus, Plus,
Activity, Activity,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { StatCard } from './shared'; import { StatCard } from './shared';
import UserDetailSection from './UserDetailSection';
import { import {
useAdminUsers, useAdminUsers,
useAdminDashboard, useAdminDashboard,
@ -56,6 +59,8 @@ function RoleBadge({ role }: { role: UserRole }) {
export default function IAMPage() { export default function IAMPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const { authStatus } = useAuth(); const { authStatus } = useAuth();
const { data: users, isLoading: usersLoading } = useAdminUsers(); const { data: users, isLoading: usersLoading } = useAdminUsers();
@ -63,6 +68,19 @@ export default function IAMPage() {
const { data: config, isLoading: configLoading } = useAdminConfig(); const { data: config, isLoading: configLoading } = useAdminConfig();
const updateConfig = useUpdateConfig(); 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) => { const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
try { try {
await updateConfig.mutateAsync({ [key]: value }); await updateConfig.mutateAsync({ [key]: value });
@ -107,17 +125,28 @@ export default function IAMPage() {
{/* User table */} {/* User table */}
<Card> <Card>
<CardHeader className="flex-row items-center justify-between"> <CardHeader className="flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" /> <Users className="h-4 w-4 text-accent" />
</div> </div>
<CardTitle>Users</CardTitle> <CardTitle>Users</CardTitle>
</div> </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)}> <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create User Create User
</Button> </Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{usersLoading ? ( {usersLoading ? (
@ -126,8 +155,10 @@ export default function IAMPage() {
<Skeleton key={i} className="h-10 w-full" /> <Skeleton key={i} className="h-10 w-full" />
))} ))}
</div> </div>
) : !users?.length ? ( ) : !filteredUsers.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No users found.</p> <p className="px-5 pb-5 text-sm text-muted-foreground">
{searchQuery ? 'No users match your search.' : 'No users found.'}
</p>
) : ( ) : (
<div> <div>
<table className="w-full text-sm"> <table className="w-full text-sm">
@ -136,6 +167,9 @@ export default function IAMPage() {
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username Username
</th> </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"> <th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Role Role
</th> </th>
@ -160,15 +194,24 @@ export default function IAMPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user: AdminUserDetail, idx) => ( {filteredUsers.map((user: AdminUserDetail, idx) => (
<tr <tr
key={user.id} key={user.id}
onClick={() => setSelectedUserId(selectedUserId === user.id ? null : user.id)}
className={cn( className={cn(
'border-b border-border transition-colors hover:bg-card-elevated/50', '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' idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)
)} )}
> >
<td className="px-5 py-3 font-medium">{user.username}</td> <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"> <td className="px-5 py-3">
<RoleBadge role={user.role} /> <RoleBadge role={user.role} />
</td> </td>
@ -206,7 +249,7 @@ export default function IAMPage() {
<td className="px-5 py-3 text-muted-foreground text-xs"> <td className="px-5 py-3 text-muted-foreground text-xs">
{getRelativeTime(user.created_at)} {getRelativeTime(user.created_at)}
</td> </td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} /> <UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
</td> </td>
</tr> </tr>
@ -218,6 +261,14 @@ export default function IAMPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* User detail section */}
{selectedUserId !== null && (
<UserDetailSection
userId={selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
)}
{/* System settings */} {/* System settings */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -0,0 +1,197 @@
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 } = 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 (!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="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>
);
}

View File

@ -22,6 +22,10 @@ interface CreateUserPayload {
username: string; username: string;
password: string; password: string;
role: UserRole; role: UserRole;
email?: string;
first_name?: string;
last_name?: string;
preferred_name?: string;
} }
interface UpdateRolePayload { interface UpdateRolePayload {
@ -46,6 +50,17 @@ export function useAdminUsers() {
}); });
} }
export function useAdminUserDetail(userId: number | null) {
return useQuery<AdminUserDetail>({
queryKey: ['admin', 'users', userId],
queryFn: async () => {
const { data } = await api.get<AdminUserDetail>(`/admin/users/${userId}`);
return data;
},
enabled: userId !== null,
});
}
export function useAdminDashboard() { export function useAdminDashboard() {
return useQuery<AdminDashboardData>({ return useQuery<AdminDashboardData>({
queryKey: ['admin', 'dashboard'], queryKey: ['admin', 'dashboard'],

View File

@ -222,6 +222,9 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
export interface AdminUser { export interface AdminUser {
id: number; id: number;
username: string; username: string;
email: string | null;
first_name: string | null;
last_name: string | null;
role: UserRole; role: UserRole;
is_active: boolean; is_active: boolean;
last_login_at: string | null; last_login_at: string | null;
@ -233,6 +236,9 @@ export interface AdminUser {
export interface AdminUserDetail extends AdminUser { export interface AdminUserDetail extends AdminUser {
active_sessions: number; active_sessions: number;
preferred_name?: string | null;
must_change_password?: boolean;
locked_until?: string | null;
} }
export interface SystemConfig { export interface SystemConfig {