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)
|
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
|
||||||
|
|||||||
@ -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"}),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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;
|
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'],
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user