Feature - add logs for admin user

This commit is contained in:
2026-06-15 09:41:11 +03:00
parent bf1839607f
commit a1f523628e
23 changed files with 784 additions and 39 deletions

View File

@@ -14,6 +14,7 @@ import ResetPasswordPage from './pages/ResetPasswordPage'
import TemplateDetailPage from './pages/TemplateDetailPage'
import TemplatesPage from './pages/TemplatesPage'
import UsersPage from './pages/UsersPage'
import LogsPage from './pages/LogsPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
@@ -43,6 +44,7 @@ function AppRoutes() {
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
<Route path="/documents/:id/edit" element={<ProtectedRoute><EditDocumentPage /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UsersPage /></ProtectedRoute>} />
<Route path="/logs" element={<ProtectedRoute><LogsPage /></ProtectedRoute>} />
</Routes>
</>
)

View File

@@ -1,4 +1,5 @@
import type {
AuditLog,
DocumentTemplate,
FilledDocument,
PreviewResponse,
@@ -164,6 +165,15 @@ export const api = {
return request<void>(`/users/${id}`, { method: 'DELETE' })
},
async listLogs(params?: { limit?: number; offset?: number; action?: string }): Promise<AuditLog[]> {
const search = new URLSearchParams()
if (params?.limit != null) search.set('limit', String(params.limit))
if (params?.offset != null) search.set('offset', String(params.offset))
if (params?.action) search.set('action', params.action)
const query = search.toString()
return request<AuditLog[]>(`/logs${query ? `?${query}` : ''}`)
},
async listTemplates(): Promise<TemplateListItem[]> {
return request<TemplateListItem[]>('/templates')
},

View File

@@ -0,0 +1,52 @@
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
danger = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!open) return null
return (
<div className="dialog-overlay" onClick={onCancel}>
<div
className="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="confirm-dialog-title" style={{ marginTop: 0 }}>
{title}
</h2>
<p style={{ color: 'var(--muted)', marginBottom: 24 }}>{message}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel}
</button>
<button
type="button"
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}

View File

@@ -21,9 +21,14 @@ export default function Navbar() {
Documents
</Link>
{user?.role === 'admin' && (
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
Users
</Link>
<>
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
Users
</Link>
<Link to="/logs" className={isActive('/logs') ? 'active' : ''}>
Logs
</Link>
</>
)}
{user && (
<>

View File

@@ -378,3 +378,30 @@ textarea {
background: #dcfce7;
color: var(--success);
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 1000;
}
.dialog {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
width: 100%;
max-width: 440px;
}
.log-action {
font-size: 0.85rem;
background: #e8eef7;
padding: 2px 8px;
border-radius: 4px;
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import type { DocumentTemplate, FilledDocument } from '../types'
@@ -13,6 +14,7 @@ export default function DocumentDetailPage() {
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => {
const load = async () => {
@@ -61,7 +63,7 @@ export default function DocumentDetailPage() {
}
const handleDelete = async () => {
if (!document || !confirm(`Delete document "${document.name}"?`)) return
if (!document) return
try {
await api.deleteDocument(document.id)
navigate('/documents')
@@ -78,6 +80,16 @@ export default function DocumentDetailPage() {
return (
<div className="container">
<ConfirmDialog
open={showDeleteConfirm}
title="Delete document"
message={`Delete document "${document.name}"? This action cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={() => void handleDelete()}
onCancel={() => setShowDeleteConfirm(false)}
/>
<div className="page-header">
<div>
<h1>{document.name}</h1>
@@ -105,7 +117,7 @@ export default function DocumentDetailPage() {
</Link>
)}
{canManage && (
<button className="btn btn-danger" onClick={handleDelete}>
<button className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
Delete
</button>
)}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import type { FilledDocument } from '../types'
@@ -9,6 +10,7 @@ export default function DocumentsPage() {
const [documents, setDocuments] = useState<FilledDocument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<FilledDocument | null>(null)
const load = () => {
api.listDocuments()
@@ -21,11 +23,12 @@ export default function DocumentsPage() {
load()
}, [])
const handleDelete = async (doc: FilledDocument) => {
if (!confirm(`Delete document "${doc.name}"?`)) return
const handleDelete = async () => {
if (!deleteTarget) return
try {
await api.deleteDocument(doc.id)
setDocuments((list) => list.filter((d) => d.id !== doc.id))
await api.deleteDocument(deleteTarget.id)
setDocuments((list) => list.filter((d) => d.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
@@ -35,6 +38,20 @@ export default function DocumentsPage() {
return (
<div className="container">
<ConfirmDialog
open={deleteTarget !== null}
title="Delete document"
message={
deleteTarget
? `Delete document "${deleteTarget.name}"? This action cannot be undone.`
: ''
}
confirmLabel="Delete"
danger
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<div className="page-header">
<h1>Documents</h1>
<Link to="/templates" className="btn btn-primary">
@@ -79,7 +96,7 @@ export default function DocumentsPage() {
{(isAdmin || d.owner_id === user?.id) && (
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(d)}
onClick={() => setDeleteTarget(d)}
>
Delete
</button>

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../context/AuthContext'
import type { AuditLog } from '../types'
function formatAction(action: string): string {
return action.replace(/\./g, ' · ').replace(/_/g, ' ')
}
function formatDetails(details: Record<string, unknown> | null): string {
if (!details) return '—'
const text = JSON.stringify(details, null, 0)
return text.length > 120 ? `${text.slice(0, 120)}` : text
}
export default function LogsPage() {
const { user: currentUser } = useAuth()
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionFilter, setActionFilter] = useState('')
const load = async (action?: string) => {
setLoading(true)
setError('')
try {
setLogs(await api.listLogs({ action: action || undefined }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load logs')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleFilter = (e: React.FormEvent) => {
e.preventDefault()
load(actionFilter.trim() || undefined)
}
if (currentUser?.role !== 'admin') {
return <Navigate to="/templates" replace />
}
return (
<div className="container">
<div className="page-header">
<h1>Activity Logs</h1>
</div>
<form onSubmit={handleFilter} className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 240px' }}>
<label>Filter by action</label>
<input
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
placeholder="e.g. user.delete, template.create"
/>
</div>
<button type="submit" className="btn btn-primary">
Apply
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setActionFilter('')
load()
}}
>
Clear
</button>
</div>
</form>
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
) : (
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Resource</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--muted)' }}>
No log entries found
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.created_at).toLocaleString()}
</td>
<td>{log.username || '—'}</td>
<td>
<code className="log-action">{formatAction(log.action)}</code>
</td>
<td>
{log.resource_type
? `${log.resource_type}${log.resource_id != null ? ` #${log.resource_id}` : ''}`
: '—'}
</td>
<td>
<span className="style-hint" title={JSON.stringify(log.details, null, 2)}>
{formatDetails(log.details)}
</span>
</td>
<td>{log.ip_address || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import type { TemplateListItem } from '../types'
@@ -9,6 +10,7 @@ export default function TemplatesPage() {
const [templates, setTemplates] = useState<TemplateListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<TemplateListItem | null>(null)
const load = async () => {
try {
@@ -24,11 +26,12 @@ export default function TemplatesPage() {
load()
}, [])
const handleDelete = async (t: TemplateListItem) => {
if (!confirm(`Delete template "${t.name}"?`)) return
const handleDelete = async () => {
if (!deleteTarget) return
try {
await api.deleteTemplate(t.id)
setTemplates((list) => list.filter((x) => x.id !== t.id))
await api.deleteTemplate(deleteTarget.id)
setTemplates((list) => list.filter((x) => x.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
@@ -65,6 +68,20 @@ export default function TemplatesPage() {
return (
<div className="container">
<ConfirmDialog
open={deleteTarget !== null}
title="Delete template"
message={
deleteTarget
? `Delete template "${deleteTarget.name}"? This action cannot be undone.`
: ''
}
confirmLabel="Delete"
danger
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<div className="page-header">
<h1>Templates</h1>
<Link to="/templates/new" className="btn btn-primary">
@@ -142,7 +159,7 @@ export default function TemplatesPage() {
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(t)}
onClick={() => setDeleteTarget(t)}
>
Delete
</button>

View File

@@ -1,9 +1,18 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { api } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import type { User } from '../types'
type PendingConfirm = {
title: string
message: string
confirmLabel: string
danger?: boolean
onConfirm: () => Promise<void>
}
export default function UsersPage() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
@@ -11,6 +20,7 @@ export default function UsersPage() {
const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null)
const [form, setForm] = useState({
email: '',
username: '',
@@ -63,8 +73,7 @@ export default function UsersPage() {
})
}
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
const submitEdit = async () => {
if (!editingUser) return
setError('')
try {
@@ -82,22 +91,64 @@ export default function UsersPage() {
}
}
const handleToggleActive = async (u: User) => {
try {
await api.updateUser(u.id, { is_active: !u.is_active })
await load()
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
const handleEdit = (e: React.FormEvent) => {
e.preventDefault()
if (!editingUser) return
if (editForm.is_active !== editingUser.is_active) {
const activating = editForm.is_active
setPendingConfirm({
title: activating ? 'Activate user' : 'Deactivate user',
message: activating
? `Activate user "${editingUser.username}"? They will be able to sign in again.`
: `Deactivate user "${editingUser.username}"? They will no longer be able to sign in.`,
confirmLabel: activating ? 'Activate' : 'Deactivate',
danger: !activating,
onConfirm: submitEdit,
})
return
}
void submitEdit()
}
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.username}"?`)) return
const handleToggleActive = (u: User) => {
const activating = !u.is_active
setPendingConfirm({
title: activating ? 'Activate user' : 'Deactivate user',
message: activating
? `Activate user "${u.username}"? They will be able to sign in again.`
: `Deactivate user "${u.username}"? They will no longer be able to sign in.`,
confirmLabel: activating ? 'Activate' : 'Deactivate',
danger: !activating,
onConfirm: async () => {
await api.updateUser(u.id, { is_active: !u.is_active })
await load()
},
})
}
const handleDelete = (u: User) => {
setPendingConfirm({
title: 'Delete user',
message: `Delete user "${u.username}"? This action cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
await api.deleteUser(u.id)
await load()
},
})
}
const handleConfirm = async () => {
if (!pendingConfirm) return
const action = pendingConfirm.onConfirm
setPendingConfirm(null)
try {
await api.deleteUser(u.id)
await load()
await action()
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
alert(err instanceof Error ? err.message : 'Action failed')
}
}
@@ -107,6 +158,16 @@ export default function UsersPage() {
return (
<div className="container">
<ConfirmDialog
open={pendingConfirm !== null}
title={pendingConfirm?.title ?? ''}
message={pendingConfirm?.message ?? ''}
confirmLabel={pendingConfirm?.confirmLabel}
danger={pendingConfirm?.danger}
onConfirm={() => void handleConfirm()}
onCancel={() => setPendingConfirm(null)}
/>
<div className="page-header">
<h1>User Management</h1>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>

View File

@@ -78,3 +78,15 @@ export interface PreviewResponse {
html: string
field_data: Record<string, unknown>
}
export interface AuditLog {
id: number
user_id: number | null
username: string | null
action: string
resource_type: string | null
resource_id: number | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/ConfirmDialog.tsx","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/LogsPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}