Add user change history audit trail for compliance

Adds a user_change_logs table to track all role and agency changes made
to users by super admins. Includes a change history modal in the User
Management screen (clock icon per row) showing timestamped, human-readable
change descriptions with the actor who made each change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael 2026-02-22 08:41:16 -06:00
parent a5d5d51d2a
commit 407f11c003
7 changed files with 325 additions and 11 deletions

View file

@ -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")

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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):

View file

@ -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())

View file

@ -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<UserChangeLogEntry[]>([]);
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)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{savedUserId === user.id && (
<span className="inline-flex items-center gap-1 text-green-700 font-medium">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenHistory(user)}
title="View change history"
className="p-1 text-grey-700 hover:text-active-blue transition-colors rounded-full hover:bg-info-light"
>
<ClockIcon className="h-4 w-4" />
</button>
{savedUserId === user.id && (
<span className="inline-flex items-center gap-1 text-green-700 font-medium">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
</div>
</td>
</tr>
))}
@ -326,6 +385,70 @@ export const UserManagement: React.FC = () => {
</div>
)}
{/* Change History Modal */}
{historyUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleCloseHistory}
>
<div
className="bg-white rounded-[10px] shadow-xl max-w-2xl w-full mx-4 p-6 max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-black-title">
Change History &mdash; {historyUser.name}
</h3>
<button
onClick={handleCloseHistory}
className="p-1 text-grey-700 hover:text-black-title transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1">
{historyLoading ? (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin h-6 w-6 text-active-blue" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : historyEntries.length === 0 ? (
<p className="text-center py-8 text-grey-700">No change history recorded.</p>
) : (
<table className="min-w-full">
<thead className="bg-grey-100 sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Date</th>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Change</th>
<th className="px-4 py-2 text-left text-xs font-bold text-black-title uppercase tracking-wider">Changed by</th>
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{historyEntries.map((entry) => (
<tr key={entry.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-grey-700">
{formatHistoryDate(entry.created_at)}
</td>
<td className="px-4 py-3 text-sm text-black-title">
{formatChangeDescription(entry)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-grey-700">
{entry.changed_by_name || 'System'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Agency Management */}
<section>
<h2 className="text-xl font-semibold text-primary-blue mb-4">Agencies ({agencies.length})</h2>

View file

@ -407,6 +407,10 @@ class ApiService {
});
}
async getUserChangeHistory(userId: string): Promise<UserChangeLogEntry[]> {
return this.fetch<UserChangeLogEntry[]>(`/users/${userId}/change-history`);
}
async createAgency(name: string): Promise<AgencyResponse> {
return this.fetch<AgencyResponse>('/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;