diff --git a/backend/alembic/versions/037_add_user_profile_fields.py b/backend/alembic/versions/037_add_user_profile_fields.py new file mode 100644 index 0000000..1710a96 --- /dev/null +++ b/backend/alembic/versions/037_add_user_profile_fields.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index efe582f..92623dd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index c4acdb6..aac4d97 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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, ) - await db.commit() + + 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"}), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index d9cd550..0bf3fe0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index ea03df2..c24160d 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -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") diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx index e4e8023..299f54d 100644 --- a/frontend/src/components/admin/CreateUserDialog.tsx +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -25,6 +25,10 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [role, setRole] = useState('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[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 +
+

+ Optional Profile +

+
+
+ + setFirstName(e.target.value)} + placeholder="First name" + /> +
+
+ + setLastName(e.target.value)} + placeholder="Last name" + /> +
+
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setPreferredName(e.target.value)} + placeholder="Display name" + /> +
+
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search users..." + className="pl-8 h-8 w-48 text-xs" + /> +
+ +
{usersLoading ? ( @@ -126,8 +155,10 @@ export default function IAMPage() { ))} - ) : !users?.length ? ( -

No users found.

+ ) : !filteredUsers.length ? ( +

+ {searchQuery ? 'No users match your search.' : 'No users found.'} +

) : (
@@ -136,6 +167,9 @@ export default function IAMPage() { + @@ -160,15 +194,24 @@ export default function IAMPage() { - {users.map((user: AdminUserDetail, idx) => ( + {filteredUsers.map((user: AdminUserDetail, idx) => ( setSelectedUserId(selectedUserId === user.id ? null : user.id)} className={cn( - 'border-b border-border transition-colors hover:bg-card-elevated/50', - idx % 2 === 0 ? '' : 'bg-card-elevated/25' + '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' + ) )} > + @@ -206,7 +249,7 @@ export default function IAMPage() { - @@ -218,6 +261,14 @@ export default function IAMPage() { + {/* User detail section */} + {selectedUserId !== null && ( + setSelectedUserId(null)} + /> + )} + {/* System settings */} diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx new file mode 100644 index 0000000..0f09563 --- /dev/null +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -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 ( +
+ {label} + {value || '—'} +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? 'Active' : 'Disabled'} + + ); +} + +function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) { + if (enabled) { + return ( + + Enabled + + ); + } + if (pending) { + return ( + + Pending + + ); + } + return Off; +} + +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 ( +
+ + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + + + {Array.from({ length: 7 }).map((_, i) => ( + + ))} + + +
+ ); + } + + if (!user) return null; + + return ( +
+ {/* User Information (read-only) */} + + +
+
+ +
+ User Information +
+ +
+ + + + + + + + +
+ + {/* Security & Permissions */} + + +
+
+ +
+ Security & Permissions +
+
+ +
+ Role +
+ + {updateRole.isPending && ( + + )} +
+
+ } + /> + + } + /> + + + + + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 58d2521..ac0cb52 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -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({ + queryKey: ['admin', 'users', userId], + queryFn: async () => { + const { data } = await api.get(`/admin/users/${userId}`); + return data; + }, + enabled: userId !== null, + }); +} + export function useAdminDashboard() { return useQuery({ queryKey: ['admin', 'dashboard'], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ba85daf..fed8531 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {
Username + Email + Role
{user.username} + {user.email || '—'} + {getRelativeTime(user.created_at)} + e.stopPropagation()}>