Add admin Activity page for curator audit reports.
Admins can filter and review curator_audit_log (date/time, actor, action, resource, details) via /api/audit against the environment DB. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f9b0fb0496
commit
dc0ac81081
@@ -77,6 +77,77 @@ export interface StaffUser {
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
user_role: 'admin' | 'curator';
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: number | null;
|
||||
resource_label: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogList {
|
||||
database: string | null;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
entries: AuditLogEntry[];
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
database: string | null;
|
||||
total: number;
|
||||
last_24h: number;
|
||||
last_7d: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
by_user: Array<{ user_id: number; username: string; role: string; count: number }>;
|
||||
by_action: Array<{ action: string; count: number }>;
|
||||
by_resource_type: Array<{ resource_type: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface AuditMeta {
|
||||
database: string | null;
|
||||
users: Array<{ id: number; username: string; role: string }>;
|
||||
actions: string[];
|
||||
resource_types: string[];
|
||||
}
|
||||
|
||||
export type AuditQuery = {
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
function auditQueryString(params?: AuditQuery): string {
|
||||
if (!params) return '';
|
||||
const qs = new URLSearchParams();
|
||||
if (params.user_id != null) qs.set('user_id', String(params.user_id));
|
||||
if (params.username) qs.set('username', params.username);
|
||||
if (params.action) qs.set('action', params.action);
|
||||
if (params.resource_type) qs.set('resource_type', params.resource_type);
|
||||
if (params.resource_id != null) qs.set('resource_id', String(params.resource_id));
|
||||
if (params.from) qs.set('from', params.from);
|
||||
if (params.to) qs.set('to', params.to);
|
||||
if (params.q) qs.set('q', params.q);
|
||||
if (params.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params.offset != null) qs.set('offset', String(params.offset));
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { ...fetchCredentials, ...init });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
@@ -407,6 +478,14 @@ export interface PaintingCheckupData {
|
||||
export const api = {
|
||||
listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`),
|
||||
|
||||
listAuditLog: (params?: AuditQuery) =>
|
||||
fetchJson<AuditLogList>(`${API}/audit${auditQueryString(params)}`),
|
||||
|
||||
getAuditSummary: (params?: AuditQuery) =>
|
||||
fetchJson<AuditSummary>(`${API}/audit/summary${auditQueryString(params)}`),
|
||||
|
||||
getAuditMeta: () => fetchJson<AuditMeta>(`${API}/audit/meta`),
|
||||
|
||||
createUser: (body: {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ import enTranslations from '../locales/en/translations.json';
|
||||
import enInfluences from '../locales/en/influences.json';
|
||||
import enTours from '../locales/en/tours.json';
|
||||
import enUsers from '../locales/en/users.json';
|
||||
import enAudit from '../locales/en/audit.json';
|
||||
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
@@ -27,6 +28,7 @@ import ruTranslations from '../locales/ru/translations.json';
|
||||
import ruInfluences from '../locales/ru/influences.json';
|
||||
import ruTours from '../locales/ru/tours.json';
|
||||
import ruUsers from '../locales/ru/users.json';
|
||||
import ruAudit from '../locales/ru/audit.json';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
@@ -35,7 +37,7 @@ void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
|
||||
influences: enInfluences,
|
||||
tours: enTours,
|
||||
users: enUsers,
|
||||
audit: enAudit,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
@@ -65,6 +68,7 @@ void i18n.use(initReactI18next).init({
|
||||
influences: ruInfluences,
|
||||
tours: ruTours,
|
||||
users: ruUsers,
|
||||
audit: ruAudit,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Curator activity",
|
||||
"back": "← Back",
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"loading": "Loading…",
|
||||
"empty": "No curator actions match these filters.",
|
||||
"databaseUnknown": "Reading the audit log for this environment’s database.",
|
||||
"databaseDev": "Dev database: {{name}}",
|
||||
"databaseProd": "Production database: {{name}}",
|
||||
"databaseNamed": "Database: {{name}}",
|
||||
"statTotal": "Total (filtered)",
|
||||
"stat24h": "Last 24 hours",
|
||||
"stat7d": "Last 7 days",
|
||||
"statNewest": "Most recent",
|
||||
"filterCurator": "Curator",
|
||||
"filterAction": "Action",
|
||||
"filterResource": "Resource type",
|
||||
"filterResourceId": "Resource id",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterSearch": "Search",
|
||||
"filterSearchPlaceholder": "Action, user, IP, details…",
|
||||
"allCurators": "All curators",
|
||||
"allActions": "All actions",
|
||||
"allResources": "All resources",
|
||||
"apply": "Apply filters",
|
||||
"clear": "Clear",
|
||||
"byCurator": "By curator",
|
||||
"byAction": "By action",
|
||||
"showing": "Showing {{count}} of {{total}}",
|
||||
"page": "Page {{page}} / {{pages}}",
|
||||
"colWhen": "Date & time",
|
||||
"colCurator": "Curator",
|
||||
"colAction": "Action",
|
||||
"colResource": "Resource",
|
||||
"colDetails": "Details",
|
||||
"colIp": "IP",
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"audit": "Activity",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Действия кураторов",
|
||||
"back": "← Назад",
|
||||
"loadFailed": "Не удалось загрузить журнал действий",
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Нет действий кураторов по этим фильтрам.",
|
||||
"databaseUnknown": "Журнал читается из базы данных текущего окружения.",
|
||||
"databaseDev": "База разработки: {{name}}",
|
||||
"databaseProd": "Продакшен-база: {{name}}",
|
||||
"databaseNamed": "База данных: {{name}}",
|
||||
"statTotal": "Всего (фильтр)",
|
||||
"stat24h": "За 24 часа",
|
||||
"stat7d": "За 7 дней",
|
||||
"statNewest": "Последнее",
|
||||
"filterCurator": "Куратор",
|
||||
"filterAction": "Действие",
|
||||
"filterResource": "Тип ресурса",
|
||||
"filterResourceId": "ID ресурса",
|
||||
"filterFrom": "С",
|
||||
"filterTo": "По",
|
||||
"filterSearch": "Поиск",
|
||||
"filterSearchPlaceholder": "Действие, пользователь, IP, детали…",
|
||||
"allCurators": "Все кураторы",
|
||||
"allActions": "Все действия",
|
||||
"allResources": "Все ресурсы",
|
||||
"apply": "Применить",
|
||||
"clear": "Сбросить",
|
||||
"byCurator": "По кураторам",
|
||||
"byAction": "По действиям",
|
||||
"showing": "Показано {{count}} из {{total}}",
|
||||
"page": "Стр. {{page}} / {{pages}}",
|
||||
"colWhen": "Дата и время",
|
||||
"colCurator": "Куратор",
|
||||
"colAction": "Действие",
|
||||
"colResource": "Ресурс",
|
||||
"colDetails": "Детали",
|
||||
"colIp": "IP",
|
||||
"prev": "Назад",
|
||||
"next": "Далее"
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"audit": "Активность",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
.audit-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.audit-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.audit-env {
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(245, 240, 232, 0.72);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.audit-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-error {
|
||||
color: #f5a5a5;
|
||||
}
|
||||
|
||||
.audit-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-stat {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-stat-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.audit-stat-value {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audit-stat-value-sm {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.audit-filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select,
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button,
|
||||
.audit-chip {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.audit-filters input,
|
||||
.audit-filters select {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.audit-filter-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audit-filter-actions button,
|
||||
.audit-pager button {
|
||||
background: rgba(201, 169, 110, 0.2);
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-filter-actions button:disabled,
|
||||
.audit-pager button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audit-secondary {
|
||||
background: transparent !important;
|
||||
border-color: rgba(255, 255, 255, 0.28) !important;
|
||||
}
|
||||
|
||||
.audit-breakdowns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-breakdown {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.audit-breakdown h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.audit-breakdown ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.audit-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-chip span {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
}
|
||||
|
||||
.audit-table-wrap {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audit-table-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
padding: 0.5rem 0.55rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.audit-row-open {
|
||||
background: rgba(201, 169, 110, 0.08);
|
||||
}
|
||||
|
||||
.audit-when {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.audit-role {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(245, 240, 232, 0.55);
|
||||
}
|
||||
|
||||
.audit-resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.audit-resource-label {
|
||||
color: rgba(201, 169, 110, 0.95);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.audit-details-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgba(245, 240, 232, 0.7);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.audit-details-row td {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.audit-details-row pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(245, 240, 232, 0.88);
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
color: rgba(245, 240, 232, 0.65);
|
||||
}
|
||||
|
||||
.audit-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.audit-stats,
|
||||
.audit-filters,
|
||||
.audit-breakdowns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.audit-filter-search {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
api,
|
||||
type AuditLogEntry,
|
||||
type AuditMeta,
|
||||
type AuditQuery,
|
||||
type AuditSummary,
|
||||
} from '../api/client';
|
||||
import './AuditPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function detailsPreview(details: Record<string, unknown> | null): string {
|
||||
if (!details || Object.keys(details).length === 0) return '—';
|
||||
try {
|
||||
const raw = JSON.stringify(details);
|
||||
return raw.length > 120 ? `${raw.slice(0, 117)}…` : raw;
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function emptyFilters() {
|
||||
return {
|
||||
user_id: '' as string,
|
||||
action: '',
|
||||
resource_type: '',
|
||||
resource_id: '',
|
||||
from: '',
|
||||
to: '',
|
||||
q: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuditPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('audit');
|
||||
const [meta, setMeta] = useState<AuditMeta | null>(null);
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [database, setDatabase] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [filters, setFilters] = useState(emptyFilters);
|
||||
const [applied, setApplied] = useState(emptyFilters);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const queryFromFilters = useCallback((f: ReturnType<typeof emptyFilters>, pageOffset: number): AuditQuery => {
|
||||
const q: AuditQuery = { limit: PAGE_SIZE, offset: pageOffset };
|
||||
if (f.user_id) q.user_id = Number(f.user_id);
|
||||
if (f.action) q.action = f.action;
|
||||
if (f.resource_type) q.resource_type = f.resource_type;
|
||||
if (f.resource_id.trim()) {
|
||||
const id = Number(f.resource_id);
|
||||
if (Number.isFinite(id)) q.resource_id = id;
|
||||
}
|
||||
if (f.from) q.from = new Date(f.from).toISOString();
|
||||
if (f.to) {
|
||||
// Inclusive end-of-day when only a date is provided
|
||||
const end = new Date(f.to);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(f.to)) {
|
||||
end.setHours(23, 59, 59, 999);
|
||||
}
|
||||
q.to = end.toISOString();
|
||||
}
|
||||
if (f.q.trim()) q.q = f.q.trim();
|
||||
return q;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (f: ReturnType<typeof emptyFilters>, pageOffset: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = queryFromFilters(f, pageOffset);
|
||||
const [list, sum] = await Promise.all([api.listAuditLog(q), api.getAuditSummary(q)]);
|
||||
setEntries(list.entries);
|
||||
setTotal(list.total);
|
||||
setDatabase(list.database ?? sum.database);
|
||||
setSummary(sum);
|
||||
setOffset(pageOffset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[queryFromFilters, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await api.getAuditMeta();
|
||||
setMeta(m);
|
||||
setDatabase(m.database);
|
||||
} catch {
|
||||
// Meta is optional for first paint; list will surface auth errors.
|
||||
}
|
||||
await load(emptyFilters(), 0);
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const applyFilters = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setApplied(filters);
|
||||
setExpandedId(null);
|
||||
void load(filters, 0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
const cleared = emptyFilters();
|
||||
setFilters(cleared);
|
||||
setApplied(cleared);
|
||||
setExpandedId(null);
|
||||
void load(cleared, 0);
|
||||
};
|
||||
|
||||
const page = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const envLabel = useMemo(() => {
|
||||
if (!database) return t('databaseUnknown');
|
||||
if (database.includes('prod')) return t('databaseProd', { name: database });
|
||||
if (database.includes('dev')) return t('databaseDev', { name: database });
|
||||
return t('databaseNamed', { name: database });
|
||||
}, [database, t]);
|
||||
|
||||
return (
|
||||
<div className="audit-page">
|
||||
<header className="audit-header">
|
||||
<button type="button" className="audit-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<div className="audit-header-text">
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="audit-env">{envLabel}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="audit-error">{error}</p>}
|
||||
|
||||
{summary && (
|
||||
<section className="audit-stats">
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statTotal')}</span>
|
||||
<span className="audit-stat-value">{summary.total}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat24h')}</span>
|
||||
<span className="audit-stat-value">{summary.last_24h}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('stat7d')}</span>
|
||||
<span className="audit-stat-value">{summary.last_7d}</span>
|
||||
</div>
|
||||
<div className="audit-stat">
|
||||
<span className="audit-stat-label">{t('statNewest')}</span>
|
||||
<span className="audit-stat-value audit-stat-value-sm">
|
||||
{summary.newest ? formatWhen(summary.newest) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<form className="audit-filters" onSubmit={applyFilters}>
|
||||
<label>
|
||||
{t('filterCurator')}
|
||||
<select
|
||||
value={filters.user_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, user_id: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allCurators')}</option>
|
||||
{(meta?.users ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterAction')}
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, action: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allActions')}</option>
|
||||
{(meta?.actions ?? []).map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResource')}
|
||||
<select
|
||||
value={filters.resource_type}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_type: e.target.value }))}
|
||||
>
|
||||
<option value="">{t('allResources')}</option>
|
||||
{(meta?.resource_types ?? []).map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterResourceId')}
|
||||
<input
|
||||
value={filters.resource_id}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, resource_id: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 42"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterFrom')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, from: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('filterTo')}
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, to: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="audit-filter-search">
|
||||
{t('filterSearch')}
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters((p) => ({ ...p, q: e.target.value }))}
|
||||
placeholder={t('filterSearchPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<div className="audit-filter-actions">
|
||||
<button type="submit">{t('apply')}</button>
|
||||
<button type="button" className="audit-secondary" onClick={clearFilters}>
|
||||
{t('clear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{summary && (summary.by_user.length > 0 || summary.by_action.length > 0) && (
|
||||
<section className="audit-breakdowns">
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byCurator')}</h2>
|
||||
<ul>
|
||||
{summary.by_user.map((row) => (
|
||||
<li key={row.user_id}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, user_id: String(row.user_id) };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.username}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="audit-breakdown">
|
||||
<h2>{t('byAction')}</h2>
|
||||
<ul>
|
||||
{summary.by_action.slice(0, 12).map((row) => (
|
||||
<li key={row.action}>
|
||||
<button
|
||||
type="button"
|
||||
className="audit-chip"
|
||||
onClick={() => {
|
||||
const next = { ...applied, action: row.action };
|
||||
setFilters(next);
|
||||
setApplied(next);
|
||||
void load(next, 0);
|
||||
}}
|
||||
>
|
||||
<strong>{row.action}</strong>
|
||||
<span>{row.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="audit-table-wrap">
|
||||
<div className="audit-table-meta">
|
||||
<span>{t('showing', { count: entries.length, total })}</span>
|
||||
<span>
|
||||
{t('page', { page, pages: pageCount })}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p>{t('loading')}</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="audit-empty">{t('empty')}</p>
|
||||
) : (
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('colWhen')}</th>
|
||||
<th>{t('colCurator')}</th>
|
||||
<th>{t('colAction')}</th>
|
||||
<th>{t('colResource')}</th>
|
||||
<th>{t('colDetails')}</th>
|
||||
<th>{t('colIp')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const open = expandedId === entry.id;
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<tr
|
||||
className={open ? 'audit-row-open' : undefined}
|
||||
onClick={() => setExpandedId(open ? null : entry.id)}
|
||||
>
|
||||
<td className="audit-when">{formatWhen(entry.created_at)}</td>
|
||||
<td>
|
||||
<div className="audit-user">
|
||||
<strong>{entry.username}</strong>
|
||||
<span className="audit-role">{entry.user_role}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{entry.action}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="audit-resource">
|
||||
<span>
|
||||
{entry.resource_type}
|
||||
{entry.resource_id != null ? ` #${entry.resource_id}` : ''}
|
||||
</span>
|
||||
{entry.resource_label && (
|
||||
<span className="audit-resource-label">{entry.resource_label}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="audit-details-cell">{detailsPreview(entry.details)}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="audit-details-row">
|
||||
<td colSpan={6}>
|
||||
<pre>{JSON.stringify(entry.details ?? {}, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="audit-pager">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset <= 0}
|
||||
onClick={() => void load(applied, Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('prev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || offset + PAGE_SIZE >= total}
|
||||
onClick={() => void load(applied, offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('next')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import ToursPage from '../pages/ToursPage';
|
||||
import UsersPage from '../pages/UsersPage';
|
||||
import AuditPage from '../pages/AuditPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import ArtistFilterModal from '../components/ArtistFilterModal';
|
||||
import ToursPopup from '../components/ToursPopup';
|
||||
@@ -26,6 +27,7 @@ import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import '../pages/ToursPage.css';
|
||||
import '../pages/UsersPage.css';
|
||||
import '../pages/AuditPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type {
|
||||
@@ -50,6 +52,7 @@ type View =
|
||||
| { type: 'influences' }
|
||||
| { type: 'tours' }
|
||||
| { type: 'users' }
|
||||
| { type: 'audit' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||
@@ -61,7 +64,7 @@ type GallerySession =
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
@@ -153,7 +156,7 @@ function catalogNavigateTarget(
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout, can } = useAuth();
|
||||
const { isCurator, isAdmin, username, login, logout, can } = useAuth();
|
||||
const canImages = can('images');
|
||||
const canCheckup = can('checkup');
|
||||
const canNotes = can('curator_notes');
|
||||
@@ -299,6 +302,8 @@ export default function HomePage() {
|
||||
setView({ type: 'tours' });
|
||||
} else if (loginRedirect === 'users') {
|
||||
setView({ type: 'users' });
|
||||
} else if (loginRedirect === 'audit') {
|
||||
setView({ type: 'audit' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
@@ -312,7 +317,8 @@ export default function HomePage() {
|
||||
view.type === 'translations' ||
|
||||
view.type === 'influences' ||
|
||||
view.type === 'tours' ||
|
||||
view.type === 'users'
|
||||
view.type === 'users' ||
|
||||
view.type === 'audit'
|
||||
) {
|
||||
goToTimelineHome();
|
||||
}
|
||||
@@ -358,6 +364,14 @@ export default function HomePage() {
|
||||
setView({ type: 'users' });
|
||||
};
|
||||
|
||||
const openAudit = () => {
|
||||
if (!isAdmin) {
|
||||
openCuratorLogin('audit');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'audit' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
@@ -1104,6 +1118,25 @@ export default function HomePage() {
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'audit' && (
|
||||
isAdmin ? (
|
||||
<AuditPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('audit')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
canTranslations ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
@@ -1253,6 +1286,16 @@ export default function HomePage() {
|
||||
{t('users')}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openAudit}
|
||||
title="Curator activity audit reports"
|
||||
>
|
||||
{t('audit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="curator-logout-btn"
|
||||
|
||||
Reference in New Issue
Block a user