From dc0ac81081b0ca145cf357bcee93ffc8aff33885 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 3 Aug 2026 22:20:09 +0300 Subject: [PATCH] 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 --- Documentation/API.md | 17 +- Documentation/DB_structure.md | 2 + Documentation/FAC.md | 8 +- Documentation/Plans.md | 4 +- Documentation/basics.md | 7 +- Documentation/environments.md | 2 +- client/src/api/client.ts | 79 ++++++ client/src/i18n/index.ts | 6 +- client/src/locales/en/audit.json | 40 +++ client/src/locales/en/home.json | 1 + client/src/locales/ru/audit.json | 40 +++ client/src/locales/ru/home.json | 1 + client/src/pages/AuditPage.css | 310 +++++++++++++++++++++++ client/src/pages/AuditPage.tsx | 407 +++++++++++++++++++++++++++++++ client/src/pages/HomePage.tsx | 49 +++- server/index.js | 2 + server/middleware/auth.js | 28 +++ server/routes/audit.js | 266 ++++++++++++++++++++ 18 files changed, 1255 insertions(+), 14 deletions(-) create mode 100644 client/src/locales/en/audit.json create mode 100644 client/src/locales/ru/audit.json create mode 100644 client/src/pages/AuditPage.css create mode 100644 client/src/pages/AuditPage.tsx create mode 100644 server/routes/audit.js diff --git a/Documentation/API.md b/Documentation/API.md index 29b4367..af78d55 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -80,7 +80,7 @@ Destroys the session cookie. | `tours` | Tour admin CRUD | | `users` | `/api/users/*` (Users page) | -Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. +Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. Admin-only routes (no matching permission flag) → **`403`** `{ "error": "Admin access required" }`. ### Users (admin / `users` permission) @@ -93,6 +93,18 @@ Missing session → **`401`** `{ "error": "Curator login required" }`. Missing p Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`. +### Audit log (admin) + +Admin-only reports over `curator_audit_log`. Responses include `database` (`process.env.DB_NAME`) so the UI shows whether you are reading **dev** or **prod**. + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/api/audit` | Paginated entries + resource labels. Query: `user_id`, `username`, `action`, `resource_type`, `resource_id`, `from`, `to`, `q`, `limit`, `offset` | +| `GET` | `/api/audit/summary` | Totals (all / 24h / 7d), breakdowns by user / action / resource_type. Same filters as list | +| `GET` | `/api/audit/meta` | Distinct users, actions, resource types for filter dropdowns | + +Each list entry includes `created_at`, `username`, `user_role`, `action`, `resource_type`, `resource_id`, `resource_label`, `details`, `ip_address`. + ### Staff-gated routes | Route | Permission | Audit action (mutations only) | @@ -115,10 +127,11 @@ Only **admins** can create or promote **admin** accounts. Cannot deactivate/demo | `/api/influences/*` | `influences` | `influence.*` | | Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` | | `/api/users/*` | `users` | `user.*` | +| `/api/audit/*` | admin role | — (read) | **Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static. -Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). +Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). Browse via admin **Activity** UI or `/api/audit`. --- diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 42b18e3..a241f5f 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -251,6 +251,8 @@ Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, tran **Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `painting.update_curator_notes`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`, `tour.create`, `tour.update`, `tour.delete`, `tour.stops`, `user.create`, `user.update`, `user.reset_password`. +Admins browse this table in the app (**Activity** / `GET /api/audit*`). Each environment’s API uses its own DB (`gallery_dev` vs `gallery_prod`); audit history is not synced by harmonize/devtoprod. + Example query in pgAdmin: ```sql diff --git a/Documentation/FAC.md b/Documentation/FAC.md index 08ccbeb..22420a0 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -95,7 +95,7 @@ CURATOR_USERNAME=curator CURATOR_PASSWORD=your-secure-password ``` -Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**). Mutations are logged in `curator_audit_log` per user (view in pgAdmin). +Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user. If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**). @@ -105,11 +105,13 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res |------|--------| | Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | | Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) | -| Admin | All curator tools + **Users** page to create accounts with individual passwords and permissions | +| Admin | All curator tools + **Users** + **Activity** audit reports | **Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. -**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** +**Activity page:** after admin login, header → **Activity** — filterable curator action log (date/time, curator, action, resource, details, IP) plus summary charts. Reads the DB for that environment (`gallery_dev` on devgallery / `npm run dev:web`, `gallery_prod` on prod). + +**Audit log (SQL / pgAdmin on `gallery_dev` or `gallery_prod`):** ```sql SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id diff --git a/Documentation/Plans.md b/Documentation/Plans.md index 21c4669..ae1b893 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -9,9 +9,9 @@ this file contains draft for future releases and features 1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md) 2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md) -3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ? +3. ~~tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?~~ — done for monitoring: admin **Activity** page + `/api/audit` over `curator_audit_log` (filters, summary, per-env DB). Checkup flags remain the painting review markers. 4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md) -5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.) +5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); browse via admin **Activity** page — [DB_structure.md](DB_structure.md#curator_audit_log) / [API.md](API.md#audit-log-admin). 6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search` 7. ~~create guided tours (with text/extra infor, set of entities)~~ — done: `tours` / `tour_stops`, public Tours popup + 3D tour hall, curator Tour editor — [tours.md](tours.md) 8. ~~curator role + multi-user accounts with permissions~~ — done: `admin`/`curator` roles, permission flags, Users page + `/api/users`, per-user audit — [API.md](API.md#authentication) / [basics.md](basics.md#user-roles-and-access) diff --git a/Documentation/basics.md b/Documentation/basics.md index 4e7541d..0cdc4b3 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -53,6 +53,8 @@ Gallery/ │ │ ├── pages/TranslationsPage.tsx # Russian translation review │ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard │ │ ├── pages/ToursPage.tsx # Guided tour editor +│ │ ├── pages/UsersPage.tsx # Staff accounts +│ │ ├── pages/AuditPage.tsx # Admin curator activity reports │ │ ├── components/ToursPopup.tsx # Public published-tours modal │ │ ├── i18n/ # react-i18next bootstrap │ │ └── locales/{en,ru}/ # UI chrome strings @@ -454,7 +456,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens |------|-----|--------| | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | | **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** | -| **`admin`** | Named staff account | All curator tools + **Users** management | +| **`admin`** | Named staff account | All curator tools + **Users** management + **Activity** audit reports | **Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag. @@ -462,7 +464,7 @@ Staff sign in via **Curator login** in the site header (individual username/pass Admins create and manage accounts on the **Users** page (`UsersPage.tsx` / `/api/users`). Bootstrap the first admin with `CURATOR_*` env vars + `npm run dev:migrate` (or `npm run dev:reset-curator`). -Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log). +Mutating actions are appended to **`curator_audit_log`** with `user_id`, action, target id, optional JSON details, and client IP. Admins browse reports on the **Activity** page (`AuditPage.tsx` / `/api/audit`); the API reads whatever DB the server is connected to (`DB_NAME`: `gallery_dev` on dev, `gallery_prod` on prod). See [DB_structure.md](DB_structure.md#curator_audit_log). ## Developer tools (image audit) @@ -479,6 +481,7 @@ Staff workflow for reviewing and fixing local image files (requires **`images`** | **Influences** | Home header → **Influences** (`influences`) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | | **Tour editor** | Home header → **Tour editor** (`tours`) | Create/publish guided tours and stop text — [tours.md](tours.md) | | **Users** | Home header → **Users** (`users` / admin) | Create staff accounts, roles, permissions, reset passwords, disable accounts | +| **Activity** | Home header → **Activity** (admin only) | Curator audit reports: filters, summary, dated action log from `curator_audit_log` (env DB) | | **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) | | **Logout** | Home header (staff) | Ends session; hides staff tools | | **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | diff --git a/Documentation/environments.md b/Documentation/environments.md index 30a3a41..964dc7b 100644 --- a/Documentation/environments.md +++ b/Documentation/environments.md @@ -117,7 +117,7 @@ npm run infra:db:split-dev-prod CURATOR_PASSWORD=your-secure-password ``` - Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page. + Omit `SESSION_COOKIE_SECURE` so cookies follow the request scheme (`TRUST_PROXY` + HTTPS → Secure). Set `true`/`false` to force. `npm run dev:migrate` creates auth tables/roles and bootstraps the first **admin** when `users` is empty. Reset that account later with `npm run dev:reset-curator`. Create additional staff via the in-app **Users** page. Admins browse curator actions on **Activity** (`/api/audit`), which always reads the DB named by `DB_NAME` for that environment (`gallery_dev` here; `gallery_prod` on prod). Audit history is not synced by harmonize/devtoprod. 2. Run: diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 379c586..2df78db 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 | 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(url: string, init?: RequestInit): Promise { 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(`${API}/audit${auditQueryString(params)}`), + + getAuditSummary: (params?: AuditQuery) => + fetchJson(`${API}/audit/summary${auditQueryString(params)}`), + + getAuditMeta: () => fetchJson(`${API}/audit/meta`), + createUser: (body: { username: string; password: string; diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 4807d52..3c5dc30 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -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 }, diff --git a/client/src/locales/en/audit.json b/client/src/locales/en/audit.json new file mode 100644 index 0000000..65d9feb --- /dev/null +++ b/client/src/locales/en/audit.json @@ -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" +} diff --git a/client/src/locales/en/home.json b/client/src/locales/en/home.json index 1253857..60ffaaa 100644 --- a/client/src/locales/en/home.json +++ b/client/src/locales/en/home.json @@ -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.", diff --git a/client/src/locales/ru/audit.json b/client/src/locales/ru/audit.json new file mode 100644 index 0000000..bf8f697 --- /dev/null +++ b/client/src/locales/ru/audit.json @@ -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": "Далее" +} diff --git a/client/src/locales/ru/home.json b/client/src/locales/ru/home.json index 98efac6..e8caa65 100644 --- a/client/src/locales/ru/home.json +++ b/client/src/locales/ru/home.json @@ -18,6 +18,7 @@ "tours": "Экскурсии", "toursEditor": "Редактор экскурсий", "users": "Пользователи", + "audit": "Активность", "openingTourGallery": "Открытие экскурсии…", "tourEmpty": "В этой экскурсии пока нет картин.", "tourLoadFailed": "Не удалось загрузить экскурсию.", diff --git a/client/src/pages/AuditPage.css b/client/src/pages/AuditPage.css new file mode 100644 index 0000000..c236cdf --- /dev/null +++ b/client/src/pages/AuditPage.css @@ -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; + } +} diff --git a/client/src/pages/AuditPage.tsx b/client/src/pages/AuditPage.tsx new file mode 100644 index 0000000..32fa0ac --- /dev/null +++ b/client/src/pages/AuditPage.tsx @@ -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 | 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(null); + const [summary, setSummary] = useState(null); + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [database, setDatabase] = useState(null); + const [offset, setOffset] = useState(0); + const [filters, setFilters] = useState(emptyFilters); + const [applied, setApplied] = useState(emptyFilters); + const [expandedId, setExpandedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const queryFromFilters = useCallback((f: ReturnType, 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, 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 ( +
+
+ +
+

{t('title')}

+

{envLabel}

+
+
+ + {error &&

{error}

} + + {summary && ( +
+
+ {t('statTotal')} + {summary.total} +
+
+ {t('stat24h')} + {summary.last_24h} +
+
+ {t('stat7d')} + {summary.last_7d} +
+
+ {t('statNewest')} + + {summary.newest ? formatWhen(summary.newest) : '—'} + +
+
+ )} + +
+ + + + + + + +
+ + +
+
+ + {summary && (summary.by_user.length > 0 || summary.by_action.length > 0) && ( +
+
+

{t('byCurator')}

+
    + {summary.by_user.map((row) => ( +
  • + +
  • + ))} +
+
+
+

{t('byAction')}

+
    + {summary.by_action.slice(0, 12).map((row) => ( +
  • + +
  • + ))} +
+
+
+ )} + +
+
+ {t('showing', { count: entries.length, total })} + + {t('page', { page, pages: pageCount })} + +
+ {loading ? ( +

{t('loading')}

+ ) : entries.length === 0 ? ( +

{t('empty')}

+ ) : ( + + + + + + + + + + + + + {entries.map((entry) => { + const open = expandedId === entry.id; + return ( + + setExpandedId(open ? null : entry.id)} + > + + + + + + + + {open && ( + + + + )} + + ); + })} + +
{t('colWhen')}{t('colCurator')}{t('colAction')}{t('colResource')}{t('colDetails')}{t('colIp')}
{formatWhen(entry.created_at)} +
+ {entry.username} + {entry.user_role} +
+
+ {entry.action} + +
+ + {entry.resource_type} + {entry.resource_id != null ? ` #${entry.resource_id}` : ''} + + {entry.resource_label && ( + {entry.resource_label} + )} +
+
{detailsPreview(entry.details)}{entry.ip_address || '—'}
+
{JSON.stringify(entry.details ?? {}, null, 2)}
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index e6c7263..756b70b 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -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 = { @@ -1104,6 +1118,25 @@ export default function HomePage() { ) )} + {view.type === 'audit' && ( + isAdmin ? ( + + ) : ( +
+

{t('curatorRequiredTitle')}

+

{t('curatorRequiredBody')}

+
+ + +
+
+ ) + )} + {view.type === 'translations' && ( canTranslations ? ( @@ -1253,6 +1286,16 @@ export default function HomePage() { {t('users')} )} + {isAdmin && ( + + )}