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:
parent
c3654dc069
commit
8582b41b03
29
backend/alembic/versions/037_add_user_profile_fields.py
Normal file
29
backend/alembic/versions/037_add_user_profile_fields.py
Normal 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")
|
||||
@ -9,6 +9,9 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, 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)
|
||||
|
||||
# MFA — populated in Track B
|
||||
|
||||
@ -17,12 +17,14 @@ from typing import Optional
|
||||
import sqlalchemy as sa
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.routers.auth import (
|
||||
@ -153,7 +155,7 @@ async def get_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_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))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
@ -168,9 +170,16 @@ async def get_user(
|
||||
)
|
||||
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(
|
||||
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
|
||||
active_sessions=active_sessions,
|
||||
preferred_name=preferred_name,
|
||||
)
|
||||
|
||||
|
||||
@ -190,10 +199,20 @@ async def create_user(
|
||||
if existing.scalar_one_or_none():
|
||||
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(
|
||||
username=data.username,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
email=email,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
last_password_change_at=datetime.now(),
|
||||
# Force password change so the user sets their own credential
|
||||
must_change_password=True,
|
||||
@ -201,7 +220,7 @@ async def create_user(
|
||||
db.add(new_user)
|
||||
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(
|
||||
db,
|
||||
@ -211,7 +230,12 @@ async def create_user(
|
||||
detail={"username": new_user.username, "role": new_user.role},
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Username or email already in use")
|
||||
|
||||
return UserDetailResponse(
|
||||
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
|
||||
|
||||
@ -227,9 +227,11 @@ async def _create_db_session(
|
||||
# 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."""
|
||||
db.add(Settings(user_id=user_id))
|
||||
db.add(Settings(user_id=user_id, preferred_name=preferred_name))
|
||||
db.add(Calendar(
|
||||
name="Personal", color="#3b82f6",
|
||||
is_default=True, is_system=False, is_visible=True,
|
||||
|
||||
@ -8,7 +8,7 @@ import re
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
@ -20,6 +20,9 @@ from app.schemas.auth import _validate_username, _validate_password_strength
|
||||
class UserListItem(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
last_login_at: Optional[datetime] = None
|
||||
@ -38,7 +41,9 @@ class UserListResponse(BaseModel):
|
||||
|
||||
|
||||
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
|
||||
password: str
|
||||
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")
|
||||
@classmethod
|
||||
@ -63,6 +72,32 @@ class CreateUserRequest(BaseModel):
|
||||
def validate_password(cls, v: str) -> str:
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@ -25,6 +25,10 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
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();
|
||||
|
||||
@ -32,12 +36,26 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
|
||||
e.preventDefault();
|
||||
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 {
|
||||
await createUser.mutateAsync({ username: username.trim(), password, role });
|
||||
await createUser.mutateAsync(payload);
|
||||
toast.success(`User "${username.trim()}" created successfully`);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('standard');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
setEmail('');
|
||||
setPreferredName('');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Failed to create user'));
|
||||
@ -96,6 +114,51 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
|
||||
</Select>
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Users,
|
||||
@ -6,13 +6,16 @@ import {
|
||||
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,
|
||||
@ -56,6 +59,8 @@ function RoleBadge({ role }: { role: UserRole }) {
|
||||
|
||||
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();
|
||||
@ -63,6 +68,19 @@ export default function IAMPage() {
|
||||
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 });
|
||||
@ -107,17 +125,28 @@ export default function IAMPage() {
|
||||
|
||||
{/* User table */}
|
||||
<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="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 ? (
|
||||
@ -126,8 +155,10 @@ export default function IAMPage() {
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !users?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No users found.</p>
|
||||
) : !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">
|
||||
@ -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">
|
||||
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>
|
||||
@ -160,15 +194,24 @@ export default function IAMPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user: AdminUserDetail, idx) => (
|
||||
{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 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'
|
||||
)
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
@ -206,7 +249,7 @@ export default function IAMPage() {
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{getRelativeTime(user.created_at)}
|
||||
</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} />
|
||||
</td>
|
||||
</tr>
|
||||
@ -218,6 +261,14 @@ export default function IAMPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User detail section */}
|
||||
{selectedUserId !== null && (
|
||||
<UserDetailSection
|
||||
userId={selectedUserId}
|
||||
onClose={() => setSelectedUserId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* System settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
197
frontend/src/components/admin/UserDetailSection.tsx
Normal file
197
frontend/src/components/admin/UserDetailSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,10 @@ interface CreateUserPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
preferred_name?: string;
|
||||
}
|
||||
|
||||
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() {
|
||||
return useQuery<AdminDashboardData>({
|
||||
queryKey: ['admin', 'dashboard'],
|
||||
|
||||
@ -222,6 +222,9 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
last_login_at: string | null;
|
||||
@ -233,6 +236,9 @@ export interface AdminUser {
|
||||
|
||||
export interface AdminUserDetail extends AdminUser {
|
||||
active_sessions: number;
|
||||
preferred_name?: string | null;
|
||||
must_change_password?: boolean;
|
||||
locked_until?: string | null;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user