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

195 lines
5.5 KiB
Python

"""
Admin API schemas — Pydantic v2.
All admin-facing request/response shapes live here to keep the admin router
clean and testable in isolation.
"""
import re
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength
# ---------------------------------------------------------------------------
# User list / detail
# ---------------------------------------------------------------------------
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
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
created_at: datetime
active_sessions: int = 0
model_config = ConfigDict(from_attributes=True)
class UserListResponse(BaseModel):
users: list[UserListItem]
total: int
class UserDetailResponse(UserListItem):
preferred_name: Optional[str] = None
must_change_password: bool = False
locked_until: Optional[datetime] = None
# ---------------------------------------------------------------------------
# Mutating user requests
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
"""Admin-created user — allows role selection (unlike public RegisterRequest)."""
model_config = ConfigDict(extra="forbid")
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
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
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")
role: Literal["admin", "standard", "public_event_manager"]
class ToggleActiveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
is_active: bool
class ToggleMfaEnforceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enforce: bool
# ---------------------------------------------------------------------------
# System config
# ---------------------------------------------------------------------------
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
model_config = ConfigDict(from_attributes=True)
class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------
class RecentLoginItem(BaseModel):
username: str
last_login_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class RecentAuditItem(BaseModel):
action: str
actor_username: Optional[str] = None
target_username: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AdminDashboardResponse(BaseModel):
total_users: int
active_users: int
admin_count: int
active_sessions: int
mfa_adoption_rate: float
recent_logins: list[RecentLoginItem]
recent_audit_entries: list[RecentAuditItem]
# ---------------------------------------------------------------------------
# Password reset
# ---------------------------------------------------------------------------
class ResetPasswordResponse(BaseModel):
message: str
temporary_password: str
class DeleteUserResponse(BaseModel):
message: str
deleted_username: str
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
class AuditLogEntry(BaseModel):
id: int
actor_username: Optional[str] = None
target_username: Optional[str] = None
action: str
detail: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class AuditLogResponse(BaseModel):
entries: list[AuditLogEntry]
total: int