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:
parent
a5d5d51d2a
commit
407f11c003
7 changed files with 325 additions and 11 deletions
39
backend/alembic/versions/008_add_user_change_log.py
Normal file
39
backend/alembic/versions/008_add_user_change_log.py
Normal 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")
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 — {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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue