diff --git a/backend/alembic/versions/008_add_user_change_log.py b/backend/alembic/versions/008_add_user_change_log.py new file mode 100644 index 0000000..b371a60 --- /dev/null +++ b/backend/alembic/versions/008_add_user_change_log.py @@ -0,0 +1,39 @@ +"""Add user_change_logs table for audit trail + +Revision ID: 008_add_user_change_log +Revises: 007_add_role_check_constraint +Create Date: 2026-02-22 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "008_add_user_change_log" +down_revision: Union[str, None] = "007_add_role_check_constraint" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_change_logs", + sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", sa.dialects.postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False), + sa.Column("changed_by_id", sa.dialects.postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), + sa.Column("change_type", sa.String(50), nullable=False), + sa.Column("field_changed", sa.String(50), nullable=True), + sa.Column("old_value", sa.String(255), nullable=True), + sa.Column("new_value", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_user_change_logs_user_id", "user_change_logs", ["user_id"]) + op.create_index("ix_user_change_logs_created_at", "user_change_logs", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_user_change_logs_created_at", table_name="user_change_logs") + op.drop_index("ix_user_change_logs_user_id", table_name="user_change_logs") + op.drop_table("user_change_logs") diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index f137053..2d9d982 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -26,6 +26,7 @@ from app.api.schemas import ( CurrentUserResponse, UserResponse, UserUpdate, + UserChangeLogResponse, SupportEmailRequest, ) from app.dependencies.auth import ( @@ -619,6 +620,15 @@ async def update_user( """Update a user's role and/or agency (super_admin only).""" user_repo = UserRepository(db) + # Fetch current state BEFORE applying changes (for change logging) + existing_user = await user_repo.get_by_id(user_id) + if not existing_user: + raise HTTPException(status_code=404, detail="User not found") + + old_role = existing_user.role + old_agency_id = existing_user.agency_id + old_agency_name = existing_user.agency.name if existing_user.agency else None + # Build kwargs, using sentinel to distinguish "not provided" from None kwargs: dict = {} if data.role is not None: @@ -627,7 +637,8 @@ async def update_user( raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}") kwargs["role"] = data.role # agency_id can be explicitly set to None (unassign) or a UUID - if "agency_id" in (data.model_fields_set or set()): + agency_id_provided = "agency_id" in (data.model_fields_set or set()) + if agency_id_provided: kwargs["agency_id"] = data.agency_id else: kwargs["agency_id"] = ... # sentinel: "not provided" @@ -636,6 +647,29 @@ async def update_user( if not user: raise HTTPException(status_code=404, detail="User not found") + # Log role change + if data.role is not None and data.role != old_role: + await user_repo.create_change_log( + user_id=user_id, + changed_by_id=current_user.id, + change_type="role_changed", + field_changed="role", + old_value=old_role, + new_value=data.role, + ) + + # Log agency change + if agency_id_provided and data.agency_id != old_agency_id: + new_agency_name = user.agency.name if user.agency else None + await user_repo.create_change_log( + user_id=user_id, + changed_by_id=current_user.id, + change_type="agency_changed", + field_changed="agency", + old_value=old_agency_name, + new_value=new_agency_name, + ) + return UserResponse( id=user.id, email=user.email, @@ -647,6 +681,29 @@ async def update_user( ) +@router.get("/users/{user_id}/change-history", response_model=list[UserChangeLogResponse]) +async def get_user_change_history( + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("super_admin")), +): + """Get change history for a user (super_admin only).""" + user_repo = UserRepository(db) + logs = await user_repo.get_change_logs(user_id) + return [ + UserChangeLogResponse( + id=log.id, + change_type=log.change_type, + field_changed=log.field_changed, + old_value=log.old_value, + new_value=log.new_value, + changed_by_name=log.changed_by.name if log.changed_by else None, + created_at=log.created_at, + ) + for log in logs + ] + + # --------------------------------------------------------------------------- # Agency endpoints # --------------------------------------------------------------------------- diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 43495cf..379b0cd 100755 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -201,6 +201,20 @@ class AgencyCreate(BaseModel): name: str +# User change log schemas +class UserChangeLogResponse(BaseModel): + id: uuid.UUID + change_type: str + field_changed: Optional[str] + old_value: Optional[str] + new_value: Optional[str] + changed_by_name: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + # Support email schemas class SupportEmailRequest(BaseModel): message: str diff --git a/backend/app/models/models.py b/backend/app/models/models.py index ded4770..4511eb5 100755 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -40,6 +40,29 @@ class User(Base): proofs: Mapped[list["Proof"]] = relationship("Proof", back_populates="created_by_user") flagged_items: Mapped[list["FlaggedItem"]] = relationship("FlaggedItem", back_populates="submitter") resolved_items: Mapped[list["ResolvedItem"]] = relationship("ResolvedItem", back_populates="submitter") + change_logs: Mapped[list["UserChangeLog"]] = relationship( + "UserChangeLog", + back_populates="user", + foreign_keys="[UserChangeLog.user_id]", + ) + + +class UserChangeLog(Base): + """Audit log entry for changes to a user's role or agency assignment.""" + __tablename__ = "user_change_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) + changed_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + change_type: Mapped[str] = mapped_column(String(50), nullable=False) + field_changed: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + old_value: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + new_value: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="change_logs", foreign_keys=[user_id]) + changed_by: Mapped[Optional["User"]] = relationship("User", foreign_keys=[changed_by_id]) class Campaign(Base): diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 1425154..2e79756 100755 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.models import User, Agency +from app.models.models import User, Agency, UserChangeLog class UserRepository: @@ -58,6 +58,17 @@ class UserRepository: ) self.session.add(user) await self.session.flush() + + # Log user creation + await self.create_change_log( + user_id=user.id, + changed_by_id=None, + change_type="user_created", + field_changed=None, + old_value=None, + new_value="basic_user", + ) + return user async def get_or_create_agency(self, name: str) -> Agency: @@ -119,3 +130,36 @@ class UserRepository: self.session.add(agency) await self.session.flush() return agency + + async def create_change_log( + self, + user_id: uuid.UUID, + changed_by_id: Optional[uuid.UUID], + change_type: str, + field_changed: Optional[str], + old_value: Optional[str], + new_value: Optional[str], + ) -> UserChangeLog: + """Insert a user change log entry.""" + entry = UserChangeLog( + user_id=user_id, + changed_by_id=changed_by_id, + change_type=change_type, + field_changed=field_changed, + old_value=old_value, + new_value=new_value, + ) + self.session.add(entry) + await self.session.flush() + return entry + + async def get_change_logs(self, user_id: uuid.UUID, limit: int = 100) -> list[UserChangeLog]: + """Fetch change history for a user, most recent first.""" + result = await self.session.execute( + select(UserChangeLog) + .options(selectinload(UserChangeLog.changed_by)) + .where(UserChangeLog.user_id == user_id) + .order_by(UserChangeLog.created_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) diff --git a/frontend/components/UserManagement.tsx b/frontend/components/UserManagement.tsx index 10843f0..a0784a2 100644 --- a/frontend/components/UserManagement.tsx +++ b/frontend/components/UserManagement.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; import apiService from '../services/apiService'; -import type { UserManagementResponse, AgencyResponse } from '../services/apiService'; +import type { UserManagementResponse, AgencyResponse, UserChangeLogEntry } from '../services/apiService'; import { UserIcon } from './icons/UserIcon'; import { PlusIcon } from './icons/PlusIcon'; import { ChevronDownIcon } from './icons/ChevronDownIcon'; +import { ClockIcon } from './icons/ClockIcon'; import type { UserRole } from '../types'; const ROLE_OPTIONS: { value: UserRole; label: string }[] = [ @@ -30,6 +31,11 @@ export const UserManagement: React.FC = () => { const [confirmationText, setConfirmationText] = useState(''); const [confirmationError, setConfirmationError] = useState(false); + // Change history modal + const [historyUser, setHistoryUser] = useState<{ id: string; name: string } | null>(null); + const [historyEntries, setHistoryEntries] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + // New agency form const [newAgencyName, setNewAgencyName] = useState(''); const [isCreatingAgency, setIsCreatingAgency] = useState(false); @@ -116,6 +122,50 @@ export const UserManagement: React.FC = () => { } }; + const formatRoleLabel = (role: string): string => { + const found = ROLE_OPTIONS.find(r => r.value === role); + return found ? found.label : role; + }; + + const formatChangeDescription = (entry: UserChangeLogEntry): string => { + if (entry.change_type === 'user_created') { + return `User account created (${formatRoleLabel(entry.new_value || 'basic_user')})`; + } + if (entry.change_type === 'role_changed') { + return `Role changed from ${formatRoleLabel(entry.old_value || '')} to ${formatRoleLabel(entry.new_value || '')}`; + } + if (entry.change_type === 'agency_changed') { + return `Agency changed from ${entry.old_value || 'None'} to ${entry.new_value || 'None'}`; + } + return entry.change_type; + }; + + const formatHistoryDate = (iso: string): string => { + const d = new Date(iso); + return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + + ', ' + + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + }; + + const handleOpenHistory = async (user: UserManagementResponse) => { + setHistoryUser({ id: user.id, name: user.name }); + setHistoryLoading(true); + setHistoryEntries([]); + try { + const entries = await apiService.getUserChangeHistory(user.id); + setHistoryEntries(entries); + } catch (err) { + console.error('Failed to load change history:', err); + } finally { + setHistoryLoading(false); + } + }; + + const handleCloseHistory = () => { + setHistoryUser(null); + setHistoryEntries([]); + }; + const handleCreateAgency = async (e: React.FormEvent) => { e.preventDefault(); if (!newAgencyName.trim()) return; @@ -238,14 +288,23 @@ export const UserManagement: React.FC = () => { {formatDate(user.created_at)} - {savedUserId === user.id && ( - - - - - Saved - - )} +
+ + {savedUserId === user.id && ( + + + + + Saved + + )} +
))} @@ -326,6 +385,70 @@ export const UserManagement: React.FC = () => { )} + {/* Change History Modal */} + {historyUser && ( +
+
e.stopPropagation()} + > +
+

+ Change History — {historyUser.name} +

+ +
+
+ {historyLoading ? ( +
+ + + + +
+ ) : historyEntries.length === 0 ? ( +

No change history recorded.

+ ) : ( + + + + + + + + + + {historyEntries.map((entry) => ( + + + + + + ))} + +
DateChangeChanged by
+ {formatHistoryDate(entry.created_at)} + + {formatChangeDescription(entry)} + + {entry.changed_by_name || 'System'} +
+ )} +
+
+
+ )} + {/* Agency Management */}

Agencies ({agencies.length})

diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index fde78e4..a12af00 100755 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -407,6 +407,10 @@ class ApiService { }); } + async getUserChangeHistory(userId: string): Promise { + return this.fetch(`/users/${userId}/change-history`); + } + async createAgency(name: string): Promise { return this.fetch('/agencies', { method: 'POST', @@ -528,5 +532,15 @@ export interface UserManagementResponse { created_at: string; } +export interface UserChangeLogEntry { + id: string; + change_type: string; + field_changed: string | null; + old_value: string | null; + new_value: string | null; + changed_by_name: string | null; + created_at: string; +} + export const apiService = new ApiService(); export default apiService;