From 0466b77328c7bdbca6d5f9472e9aa29a312563cc Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 27 Jul 2026 18:04:17 +0300 Subject: [PATCH] Add curator roles/permissions with Users admin, and fix lineage branch joins. Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless. Co-authored-by: Cursor --- Documentation/API.md | 81 ++++-- Documentation/DB_structure.md | 19 +- Documentation/FAC.md | 9 +- Documentation/Plans.md | 2 + Documentation/basics.md | 33 ++- Documentation/environments.md | 2 +- client/src/api/client.ts | 78 +++++- client/src/components/ArtistBio.tsx | 20 +- client/src/components/MovementBands.css | 3 +- client/src/components/MovementBands.tsx | 103 ++++--- client/src/components/PaintingDetail.tsx | 21 +- client/src/context/AuthContext.tsx | 48 +++- client/src/i18n/index.ts | 6 +- client/src/locales/en/home.json | 1 + client/src/locales/en/users.json | 37 +++ client/src/locales/ru/home.json | 1 + client/src/locales/ru/users.json | 37 +++ client/src/pages/HomePage.tsx | 204 +++++++++----- client/src/pages/UsersPage.css | 149 ++++++++++ client/src/pages/UsersPage.tsx | 331 +++++++++++++++++++++++ db/migrate-user-roles.sql | 35 +++ scripts/reset-curator-password.js | 38 ++- server/index.js | 36 +-- server/middleware/auth.js | 82 +++++- server/migrate.js | 18 +- server/permissions.js | 41 +++ server/routes/auth.js | 43 +-- server/routes/influences.js | 20 +- server/routes/tours.js | 19 +- server/routes/translations.js | 14 +- server/routes/users.js | 230 ++++++++++++++++ 31 files changed, 1492 insertions(+), 269 deletions(-) create mode 100644 client/src/locales/en/users.json create mode 100644 client/src/locales/ru/users.json create mode 100644 client/src/pages/UsersPage.css create mode 100644 client/src/pages/UsersPage.tsx create mode 100644 db/migrate-user-roles.sql create mode 100644 server/permissions.js create mode 100644 server/routes/users.js diff --git a/Documentation/API.md b/Documentation/API.md index 0029b13..eb60e9d 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -30,7 +30,7 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds ## Authentication -Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes. +Anonymous visitors have implicit role **`user`** (browse only). Staff accounts in `users` are **`admin`** or **`curator`** with fine-grained **permissions**. Admins have all tools; curators only the flags assigned to them. Mutations are logged in `curator_audit_log` with `user_id`. Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests. @@ -42,19 +42,25 @@ Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: { "role": "user" } ``` -**Response (curator session)** +**Response (staff session)** ```json -{ "role": "curator", "username": "curator" } +{ + "role": "admin", + "username": "curator", + "permissions": ["images", "checkup", "curator_notes", "translations", "influences", "tours", "users"] +} ``` +`permissions` is the effective set (admins always receive the full list). + ### `POST /api/auth/login` **Body:** `{ "username": "curator", "password": "…" }` -**Response:** `{ "role": "curator", "username": "curator" }` +**Response:** same shape as `/me` for staff (`role`, `username`, `permissions`). -**Errors:** `401` invalid credentials, `400` missing fields. +**Errors:** `401` invalid credentials or disabled account, `400` missing fields. ### `POST /api/auth/logout` @@ -62,30 +68,57 @@ Destroys the session cookie. **Response:** `{ "ok": true }` -### Curator-only routes +### Permission flags -These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session: +| Permission | Gates | +|------------|--------| +| `images` | Debug image/portrait fix/clear/upload/delete, debug search/proxy | +| `checkup` | Checkup page + checkup flag patches | +| `curator_notes` | `PATCH …/curator-notes` | +| `translations` | `/api/translations/*` | +| `influences` | `/api/influences/*` | +| `tours` | Tour admin CRUD | +| `users` | `/api/users/*` (Users page) | -| Route | Audit action (mutations only) | -|-------|-------------------------------| -| `GET /api/paintings/checkup` | — (read) | -| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — | -| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — | -| `GET /api/debug/image-proxy` | — | -| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` | -| `PATCH /api/paintings/:id/curator-notes` | `painting.update_curator_notes` | -| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` | -| `POST /api/paintings/:id/fix-image` | `painting.fix_image` | -| `POST /api/paintings/:id/clear-image` | `painting.clear_image` | -| `POST /api/paintings/:id/upload-image` | `painting.upload_image` | -| `DELETE /api/paintings/:id` | `painting.delete` | -| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` | -| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` | -| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` | +Missing session → **`401`** `{ "error": "Curator login required" }`. Missing permission → **`403`** `{ "error": "Permission denied" }`. + +### Users (admin / `users` permission) + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/api/users` | List users + known permission keys | +| `POST` | `/api/users` | Create `{ username, password, role, permissions }` | +| `PATCH` | `/api/users/:id` | Update `role`, `permissions`, `is_active` | +| `POST` | `/api/users/:id/password` | Set new password; clears that user’s sessions | + +Only **admins** can create or promote **admin** accounts. Cannot deactivate/demote the last active admin. Audit: `user.create`, `user.update`, `user.reset_password`. + +### Staff-gated routes + +| Route | Permission | Audit action (mutations only) | +|-------|------------|-------------------------------| +| `GET /api/paintings/checkup` | `checkup` | — (read) | +| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | `images` | — | +| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | `images` | — | +| `GET /api/debug/image-proxy` | `images` | — | +| `PATCH /api/paintings/:id/checkup-flags` | `checkup` | `painting.checkup_flags` | +| `PATCH /api/paintings/:id/curator-notes` | `curator_notes` | `painting.update_curator_notes` | +| `PATCH /api/artists/:id/checkup-flags` | `checkup` | `artist.checkup_flags` | +| `POST /api/paintings/:id/fix-image` | `images` | `painting.fix_image` | +| `POST /api/paintings/:id/clear-image` | `images` | `painting.clear_image` | +| `POST /api/paintings/:id/upload-image` | `images` | `painting.upload_image` | +| `DELETE /api/paintings/:id` | `images` | `painting.delete` | +| `POST /api/artists/:id/fix-portrait` | `images` | `artist.fix_portrait` | +| `POST /api/artists/:id/clear-portrait` | `images` | `artist.clear_portrait` | +| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` | +| `/api/translations/*` | `translations` | `translation.*` | +| `/api/influences/*` | `influences` | `influence.*` | +| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` | +| `/api/users/*` | `users` | `user.*` | **Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static. -Curator mutations are recorded in `curator_audit_log` (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)). --- diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index e1cc74c..42b18e3 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -215,34 +215,41 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id ### `users` -Curator accounts (named logins). Anonymous site visitors do not have rows here. +Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`. | Column | Type | Notes | |--------|------|-------| | `id` | SERIAL PK | | | `username` | VARCHAR(64) UNIQUE | Login name | | `password_hash` | VARCHAR(255) | bcrypt hash | +| `role` | VARCHAR(32) | `admin` or `curator` (`users_role_check`) | +| `permissions` | TEXT[] | Fine-grained flags for `curator` accounts; admins are treated as having all | +| `is_active` | BOOLEAN | Soft-disable; inactive users cannot log in | | `created_at` | TIMESTAMPTZ | | | `last_login_at` | TIMESTAMPTZ | Updated on successful login | -First curator is bootstrapped on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env. +**Permission keys:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. + +- **`admin`** — all curator tools + **Users** management (role bypasses permission checks). +- **`curator`** — only assigned permission flags. +- First account is bootstrapped as **admin** on `npm run dev:migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set. Manage additional accounts via the in-app **Users** page or `/api/users`. ### `curator_audit_log` -Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes). +Append-only log of staff mutations (fix/clear/upload/delete, checkup flags, translations, influences, tours, user management). | Column | Type | Notes | |--------|------|-------| | `id` | BIGSERIAL PK | | | `user_id` | FK → `users` | Who performed the action | -| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` | -| `resource_type` | VARCHAR(32) | `painting` or `artist` | +| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `user.create` | +| `resource_type` | VARCHAR(32) | `painting`, `artist`, `tour`, `user`, etc. | | `resource_id` | INTEGER | Target row id | | `details` | JSONB | Optional metadata (URL, mime type, flag values) | | `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) | | `created_at` | TIMESTAMPTZ | | -**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`. +**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`. Example query in pgAdmin: diff --git a/Documentation/FAC.md b/Documentation/FAC.md index 94fa6e1..08ccbeb 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -95,16 +95,19 @@ CURATOR_USERNAME=curator CURATOR_PASSWORD=your-secure-password ``` -Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences** / inline **curator notes** on painting detail. Mutations are logged in `curator_audit_log` (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**). Mutations are logged in `curator_audit_log` per user (view in pgAdmin). -If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty). +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**). **Roles:** | Role | Access | |------|--------| | Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | -| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), curator notes, image fix/upload/delete APIs | +| 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 | + +**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. **Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** diff --git a/Documentation/Plans.md b/Documentation/Plans.md index 8397c8c..21c4669 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -3,6 +3,7 @@ this file contains draft for future releases and features ## Standing requirements (do not regress) - **Painting thumbnails:** any create/replace/clear of a painting’s full picture must regenerate or remove its dedicated `paintings/thumbs/` file — never use the full image or a remote thumb URL as `thumbnail_path`. See [data-and-images.md — Painting thumbnail invariant](data-and-images.md#painting-thumbnail-invariant). +- **Staff auth:** mutating curator tools must check session + permission flags (`admin` bypasses flags); actions must log to `curator_audit_log` with `user_id`. See [basics.md — User roles](basics.md#user-roles-and-access) and [API.md — Authentication](API.md#authentication). ## Feature backlog @@ -13,4 +14,5 @@ this file contains draft for future releases and features 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.) 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 cafb454..3f61958 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -247,8 +247,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str |---------|----------------| | Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed | | Vertical lanes | Movements whose time spans do **not** overlap (in the current zoom) share a horizontal lane; only concurrent spans stack into extra rows (`assignTemporalLanes`). A follow-up pass (`refineLanesForLineageCorridors`) pulls linked parent/child movements onto nearby or shared lanes when years allow, then displaces unrelated streams out of thick lineage branch corridors | -| Branch connectors | Smooth curves from fan-out points along a parent stream to the centre of each child stream; lanes are packed so those transitions do not cross through unconnected movements | -| Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform | +| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands | | Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load | | Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space | @@ -441,28 +440,34 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens | Role | Who | Can do | |------|-----|--------| | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | -| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, **Tour editor**, debug API mutations | +| **`curator`** | Named staff account | Public browse + tools allowed by their **permission flags** | +| **`admin`** | Named staff account | All curator tools + **Users** management | -Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session). +**Permission flags:** `images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`. Admins always have every flag. -Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log). +Staff sign in via **Curator login** in the site header (individual username/password). Sessions use an HTTP-only cookie (`gallery.sid`). The UI shows only tools the account may use; the server enforces the same rules (`401` without a session, `403` without permission). + +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). ## Developer tools (image audit) -Curator-only workflow for reviewing and fixing local image files — not part of the public visitor experience. +Staff workflow for reviewing and fixing local image files (requires **`images`** permission) — not part of the public visitor experience. | Feature | Where | Purpose | |---------|--------|---------| | **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail | -| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools | -| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | -| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load | -| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | -| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` | -| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | -| **Tour editor** | Home header → **Tour editor** (curators only) | Create/publish guided tours and stop text — [tours.md](tours.md) | +| **Curator login** | Home header (guests) | Username + password modal; unlocks permitted tools | +| **Debug mode** | Home header toggle (`images`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | +| **Show more** | Home header checkbox (`images`, when debug on) | Auto-opens the **More** modal on each painting / bio page load | +| **Checkup page** | Home header → **Checkup** (`checkup`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | +| **Translations** | Home header → **Translations** (`translations`) | Review/publish Russian `entity_translations` | +| **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 | | **Tours** | Home header → **Tours** (everyone) | Open published tours in a 3D hall — [tours.md](tours.md) | -| **Logout** | Home header (curators) | Ends session; hides debug tools | +| **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) | ### Debug panel (painting detail and artist bio) diff --git a/Documentation/environments.md b/Documentation/environments.md index 9e5dae8..30a3a41 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 and bootstraps the first curator when `users` is empty. Reset password later with `npm run dev:reset-curator`. + 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. 2. Run: diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b3eec26..ef16a70 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -39,11 +39,41 @@ function localizedPath(path: string, params?: URLSearchParams): string { const fetchCredentials: RequestInit = { credentials: 'include' }; -export type AuthRole = 'user' | 'curator'; +export type AuthRole = 'user' | 'admin' | 'curator'; + +export type StaffPermission = + | 'images' + | 'checkup' + | 'curator_notes' + | 'translations' + | 'influences' + | 'tours' + | 'users'; + +export const ALL_STAFF_PERMISSIONS: StaffPermission[] = [ + 'images', + 'checkup', + 'curator_notes', + 'translations', + 'influences', + 'tours', + 'users', +]; export interface AuthState { role: AuthRole; username?: string; + permissions?: StaffPermission[]; +} + +export interface StaffUser { + id: number; + username: string; + role: 'admin' | 'curator'; + permissions: StaffPermission[]; + is_active: boolean; + created_at: string; + last_login_at: string | null; } async function fetchJson(url: string, init?: RequestInit): Promise { @@ -337,6 +367,52 @@ export interface PaintingCheckupData { } export const api = { + listUsers: () => fetchJson<{ users: StaffUser[]; permissions: StaffPermission[] }>(`${API}/users`), + + createUser: (body: { + username: string; + password: string; + role: 'admin' | 'curator'; + permissions: StaffPermission[]; + }) => + fetch(`${API}/users`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(async (res) => { + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`); + return data as { user: StaffUser }; + }), + + updateUser: ( + id: number, + body: Partial<{ role: 'admin' | 'curator'; permissions: StaffPermission[]; is_active: boolean }> + ) => + fetch(`${API}/users/${id}`, { + ...fetchCredentials, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(async (res) => { + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Update failed: ${res.status}`); + return data as { user: StaffUser }; + }), + + resetUserPassword: (id: number, password: string) => + fetch(`${API}/users/${id}/password`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }).then(async (res) => { + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Password reset failed: ${res.status}`); + return data as { ok: boolean }; + }), + getBounds: () => fetchJson(`${API}/bounds`), getCatalogBootstrap: (start?: number, end?: number) => { diff --git a/client/src/components/ArtistBio.tsx b/client/src/components/ArtistBio.tsx index 1d2acf6..390f908 100644 --- a/client/src/components/ArtistBio.tsx +++ b/client/src/components/ArtistBio.tsx @@ -19,6 +19,7 @@ interface Props { artist: Artist & { movement_name?: string }; debugMode?: boolean; debugShowMore?: boolean; + canCheckup?: boolean; portraitRevision?: number; onBack: () => void; onEnterGallery: () => void; @@ -36,6 +37,7 @@ export default function ArtistBio({ artist, debugMode = false, debugShowMore = false, + canCheckup = true, portraitRevision = 0, onBack, onEnterGallery, @@ -322,14 +324,16 @@ export default function ArtistBio({

No portrait image result found.

)}
- + {canCheckup && ( + + )} + {canCheckup && ( + + )} + +
+ + ) + )} + {view.type === 'translations' && ( - isCurator ? ( + canTranslations ? ( ) : (
@@ -1038,21 +1080,21 @@ export default function HomePage() { )} {view.type === 'checkup' && ( - isCurator ? ( + canCheckup ? ( ) : (
-

Curator access required

-

The painting checkup table is available to logged-in curators only.

+

{t('curatorRequiredTitle')}

+

{t('curatorRequiredBody')}

@@ -1083,57 +1125,79 @@ export default function HomePage() { {username} - - - - - - + {canImages && ( + <> + + + + )} + {canInfluences && ( + + )} + {canTours && ( + + )} + {canTranslations && ( + + )} + {canCheckup && ( + + )} + {canUsers && ( + + )} +

{t('title')}

+ + + {error &&

{error}

} + {message &&

{message}

} + +
+
+ {loading ? ( +

{t('loading')}

+ ) : ( +
+ + + + + + + + + + {users.map((user) => ( + setSelectedId(user.id)} + > + + + + + ))} + +
{t('username')}{t('role')}{t('active')}
+ {user.username} + {user.username === selfUsername ? ' *' : ''} + {user.role === 'admin' ? t('roleAdmin') : t('roleCurator')}{user.is_active ? t('active') : t('inactive')}
+
+ )} + +
+

{t('createTitle')}

+ + + + {createForm.role === 'curator' && ( +
+ {t('permissions')} + {ALL_STAFF_PERMISSIONS.map((perm) => ( + + ))} +
+ )} + {createForm.role === 'admin' &&

{t('adminAllPerms')}

} + +
+
+ +
+ {!selected ? ( +

{t('selectUser')}

+ ) : ( + <> +

{selected.username}

+

+ {t('lastLogin')}:{' '} + {selected.last_login_at + ? new Date(selected.last_login_at).toLocaleString() + : t('never')} +

+ + {editRole === 'curator' ? ( +
+ {t('permissions')} + {ALL_STAFF_PERMISSIONS.map((perm) => ( + + ))} +
+ ) : ( +

{t('adminAllPerms')}

+ )} + + + +
+

{t('resetPassword')}

+ + +
+ + )} +
+
+
+ ); +} diff --git a/db/migrate-user-roles.sql b/db/migrate-user-roles.sql new file mode 100644 index 0000000..a453906 --- /dev/null +++ b/db/migrate-user-roles.sql @@ -0,0 +1,35 @@ +-- Staff roles and fine-grained permissions on users + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'role' + ) THEN + ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'curator'; + ALTER TABLE users ADD COLUMN permissions TEXT[] NOT NULL DEFAULT '{}'; + ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true; + + -- Existing accounts were full curators; promote to admin so nothing breaks + UPDATE users + SET role = 'admin', + permissions = ARRAY[ + 'images', + 'checkup', + 'curator_notes', + 'translations', + 'influences', + 'tours', + 'users' + ]::text[]; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check' + ) THEN + ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'curator')); + END IF; +END $$; diff --git a/scripts/reset-curator-password.js b/scripts/reset-curator-password.js index edbf459..38f8118 100644 --- a/scripts/reset-curator-password.js +++ b/scripts/reset-curator-password.js @@ -1,5 +1,5 @@ /** - * Upsert the curator account password from .env CURATOR_USERNAME / CURATOR_PASSWORD. + * Upsert the bootstrap admin account password from .env CURATOR_USERNAME / CURATOR_PASSWORD. * Use when login fails after changing .env, or after a DB restore with a different hash. * * npm run dev:reset-curator @@ -8,6 +8,16 @@ require('dotenv').config(); const bcrypt = require('bcryptjs'); const pool = require('../server/db'); +const ADMIN_PERMISSIONS = [ + 'images', + 'checkup', + 'curator_notes', + 'translations', + 'influences', + 'tours', + 'users', +]; + async function main() { const username = (process.env.CURATOR_USERNAME || 'curator').trim(); const password = process.env.CURATOR_PASSWORD; @@ -21,17 +31,23 @@ async function main() { ]); if (rows.length === 0) { - await pool.query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [ - username, - passwordHash, - ]); - console.log(`Created curator account: ${username}`); + await pool.query( + `INSERT INTO users (username, password_hash, role, permissions, is_active) + VALUES ($1, $2, 'admin', $3::text[], true)`, + [username, passwordHash, ADMIN_PERMISSIONS] + ); + console.log(`Created admin account: ${username}`); } else { - await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [ - rows[0].id, - passwordHash, - ]); - console.log(`Updated password for curator account: ${username}`); + await pool.query( + `UPDATE users + SET password_hash = $2, + role = 'admin', + permissions = $3::text[], + is_active = true + WHERE id = $1`, + [rows[0].id, passwordHash, ADMIN_PERMISSIONS] + ); + console.log(`Updated password and admin role for account: ${username}`); } // Drop stale sessions so a fresh login is required. diff --git a/server/index.js b/server/index.js index ed0f1a3..f381798 100644 --- a/server/index.js +++ b/server/index.js @@ -8,9 +8,10 @@ require('dotenv').config(); const pool = require('./db'); const { createSessionMiddleware } = require('./middleware/session'); -const { requireCurator } = require('./middleware/auth'); +const { requirePermission } = require('./middleware/auth'); const { logCuratorAction } = require('./audit-log'); const authRoutes = require('./routes/auth'); +const usersRoutes = require('./routes/users'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service'); const { getVersionInfo } = require('./version-info'); const { searchCatalog } = require('./search-service'); @@ -44,6 +45,7 @@ app.use(compression()); app.use(express.json({ limit: '20mb' })); app.use(createSessionMiddleware()); app.use('/api/auth', authRoutes); +app.use('/api/users', usersRoutes); app.use('/api/translations', translationRoutes); app.use('/api/influences', influenceRoutes); app.use('/api/tours', tourRoutes); @@ -469,7 +471,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => { }); // Update artist portrait checkup flags (checked / fixed) -app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => { +app.patch('/api/artists/:id/checkup-flags', requirePermission('checkup'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; @@ -537,7 +539,7 @@ app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => }); // Developer debug: portrait image search for artist bio -app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (req, res) => { +app.get('/api/artists/:id/debug-portrait-search/more', requirePermission('images'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); @@ -555,7 +557,7 @@ app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (re } }); -app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, res) => { +app.get('/api/artists/:id/debug-portrait-search', requirePermission('images'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); @@ -573,7 +575,7 @@ app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, re }); // Developer debug: replace artist portrait with a search result URL -app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => { +app.post('/api/artists/:id/fix-portrait', requirePermission('images'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; @@ -607,7 +609,7 @@ app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => { } }); -app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => { +app.post('/api/artists/:id/clear-portrait', requirePermission('images'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const updated = await clearArtistPortrait(artistId); @@ -630,7 +632,7 @@ app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => } }); -app.post('/api/artists/:id/upload-portrait', requireCurator, async (req, res) => { +app.post('/api/artists/:id/upload-portrait', requirePermission('images'), async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; @@ -720,7 +722,7 @@ app.get('/api/artists/:id', async (req, res) => { }); // Painting image checkup (developer audit table) — must be before /api/paintings/:id -app.get('/api/paintings/checkup', requireCurator, async (_req, res) => { +app.get('/api/paintings/checkup', requirePermission('checkup'), async (_req, res) => { try { const { rows } = await pool.query( `SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path, @@ -768,7 +770,7 @@ app.get('/api/paintings/checkup', requireCurator, async (_req, res) => { }); // Update checkup workflow flags (checked / fixed) -app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) => { +app.patch('/api/paintings/:id/checkup-flags', requirePermission('checkup'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; @@ -837,7 +839,7 @@ app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) = }); // Update public curator notes on a painting -app.patch('/api/paintings/:id/curator-notes', requireCurator, async (req, res) => { +app.patch('/api/paintings/:id/curator-notes', requirePermission('curator_notes'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); if (!Number.isFinite(paintingId)) { @@ -941,7 +943,7 @@ app.post('/api/artists/:id/preload-images', async (req, res) => { }); // Developer debug: Google Images first result for image audit -app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req, res) => { +app.get('/api/paintings/:id/debug-image-search/more', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); @@ -965,7 +967,7 @@ app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req } }); -app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res) => { +app.get('/api/paintings/:id/debug-image-search', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const result = await pool.query( @@ -989,7 +991,7 @@ app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res }); // Developer debug: replace painting image with a search result URL -app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => { +app.post('/api/paintings/:id/fix-image', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; @@ -1023,7 +1025,7 @@ app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => { } }); -app.delete('/api/paintings/:id', requireCurator, async (req, res) => { +app.delete('/api/paintings/:id', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); if (!Number.isFinite(paintingId)) { @@ -1047,7 +1049,7 @@ app.delete('/api/paintings/:id', requireCurator, async (req, res) => { } }); -app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => { +app.post('/api/paintings/:id/clear-image', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const updated = await clearPaintingImage(paintingId); @@ -1070,7 +1072,7 @@ app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => { } }); -app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => { +app.post('/api/paintings/:id/upload-image', requirePermission('images'), async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; @@ -1111,7 +1113,7 @@ app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => }); // Proxy remote image for debug preview (avoids hotlink / CORS blocks) -app.get('/api/debug/image-proxy', requireCurator, async (req, res) => { +app.get('/api/debug/image-proxy', requirePermission('images'), async (req, res) => { try { const imageUrl = req.query.url; const searchUrl = req.query.searchUrl; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 7253387..317a640 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -1,5 +1,34 @@ const pool = require('../db'); +const { hasPermission, effectivePermissions } = require('../permissions'); +async function loadStaffUser(userId) { + const { rows } = await pool.query( + `SELECT id, username, role, permissions, is_active + FROM users WHERE id = $1`, + [userId] + ); + if (rows.length === 0) return null; + const row = rows[0]; + return { + id: row.id, + username: row.username, + role: row.role, + permissions: row.permissions || [], + is_active: row.is_active !== false, + }; +} + +function attachStaff(req, user) { + req.curatorUser = { + id: user.id, + username: user.username, + role: user.role, + permissions: user.permissions, + is_active: user.is_active, + }; +} + +/** Any active staff account (admin or curator). */ async function requireCurator(req, res, next) { const userId = req.session?.userId; if (!userId) { @@ -7,16 +36,13 @@ async function requireCurator(req, res, next) { } try { - const { rows } = await pool.query( - `SELECT id, username FROM users WHERE id = $1`, - [userId] - ); - if (rows.length === 0) { + const user = await loadStaffUser(userId); + if (!user || !user.is_active) { req.session.destroy(() => {}); return res.status(401).json({ error: 'Curator login required' }); } - req.curatorUser = rows[0]; + attachStaff(req, user); next(); } catch (err) { console.error('Auth middleware error:', err.message); @@ -24,4 +50,46 @@ async function requireCurator(req, res, next) { } } -module.exports = { requireCurator }; +/** Active staff with a specific permission (admins always pass). */ +function requirePermission(permission) { + return async (req, res, next) => { + const userId = req.session?.userId; + if (!userId) { + return res.status(401).json({ error: 'Curator login required' }); + } + + try { + const user = await loadStaffUser(userId); + if (!user || !user.is_active) { + req.session.destroy(() => {}); + return res.status(401).json({ error: 'Curator login required' }); + } + + attachStaff(req, user); + + if (!hasPermission(user, permission)) { + return res.status(403).json({ error: 'Permission denied' }); + } + + next(); + } catch (err) { + console.error('Auth middleware error:', err.message); + res.status(500).json({ error: 'Authentication failed' }); + } + }; +} + +function staffAuthPayload(user) { + return { + role: user.role, + username: user.username, + permissions: effectivePermissions(user), + }; +} + +module.exports = { + requireCurator, + requirePermission, + loadStaffUser, + staffAuthPayload, +}; diff --git a/server/migrate.js b/server/migrate.js index 8467d25..54067b7 100644 --- a/server/migrate.js +++ b/server/migrate.js @@ -17,6 +17,17 @@ const INCREMENTAL_MIGRATIONS = [ 'migrate-i18n.sql', 'migrate-tours.sql', 'migrate-curator-notes.sql', + 'migrate-user-roles.sql', +]; + +const BOOTSTRAP_ADMIN_PERMISSIONS = [ + 'images', + 'checkup', + 'curator_notes', + 'translations', + 'influences', + 'tours', + 'users', ]; async function bootstrapCurator() { @@ -36,10 +47,11 @@ async function bootstrapCurator() { const bcrypt = require('bcryptjs'); const passwordHash = await bcrypt.hash(password, 10); await pool.query( - `INSERT INTO users (username, password_hash) VALUES ($1, $2)`, - [username, passwordHash] + `INSERT INTO users (username, password_hash, role, permissions, is_active) + VALUES ($1, $2, 'admin', $3::text[], true)`, + [username, passwordHash, BOOTSTRAP_ADMIN_PERMISSIONS] ); - console.log(` bootstrap curator account: ${username}`); + console.log(` bootstrap admin account: ${username}`); } async function applySqlFile(label, filePath) { diff --git a/server/permissions.js b/server/permissions.js new file mode 100644 index 0000000..5c0480b --- /dev/null +++ b/server/permissions.js @@ -0,0 +1,41 @@ +/** Fine-grained curator tool permissions. Admins are treated as having all. */ +const ALL_PERMISSIONS = [ + 'images', + 'checkup', + 'curator_notes', + 'translations', + 'influences', + 'tours', + 'users', +]; + +function normalizePermissions(raw) { + if (!Array.isArray(raw)) return []; + const allowed = new Set(ALL_PERMISSIONS); + const out = []; + for (const key of raw) { + if (typeof key === 'string' && allowed.has(key) && !out.includes(key)) { + out.push(key); + } + } + return out; +} + +function effectivePermissions(user) { + if (!user) return []; + if (user.role === 'admin') return [...ALL_PERMISSIONS]; + return normalizePermissions(user.permissions); +} + +function hasPermission(user, permission) { + if (!user || !permission) return false; + if (user.role === 'admin') return true; + return normalizePermissions(user.permissions).includes(permission); +} + +module.exports = { + ALL_PERMISSIONS, + normalizePermissions, + effectivePermissions, + hasPermission, +}; diff --git a/server/routes/auth.js b/server/routes/auth.js index e4dc17e..aec2d9f 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,6 +1,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const pool = require('../db'); +const { loadStaffUser, staffAuthPayload } = require('../middleware/auth'); const router = express.Router(); @@ -11,19 +12,13 @@ router.get('/me', async (req, res) => { } try { - const { rows } = await pool.query( - `SELECT id, username FROM users WHERE id = $1`, - [userId] - ); - if (rows.length === 0) { + const user = await loadStaffUser(userId); + if (!user || !user.is_active) { req.session.destroy(() => {}); return res.json({ role: 'user' }); } - res.json({ - role: 'curator', - username: rows[0].username, - }); + res.json(staffAuthPayload(user)); } catch (err) { console.error('Auth me error:', err.message); res.status(500).json({ error: 'Failed to read session' }); @@ -38,23 +33,36 @@ router.post('/login', async (req, res) => { try { const { rows } = await pool.query( - `SELECT id, username, password_hash FROM users WHERE LOWER(username) = LOWER($1)`, + `SELECT id, username, password_hash, role, permissions, is_active + FROM users WHERE LOWER(username) = LOWER($1)`, [username.trim()] ); if (rows.length === 0) { return res.status(401).json({ error: 'Invalid username or password' }); } - const user = rows[0]; - const valid = await bcrypt.compare(password, user.password_hash); + const row = rows[0]; + if (row.is_active === false) { + return res.status(401).json({ error: 'Account is disabled' }); + } + + const valid = await bcrypt.compare(password, row.password_hash); if (!valid) { return res.status(401).json({ error: 'Invalid username or password' }); } - await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]); + await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [row.id]); - req.session.userId = user.id; - req.session.username = user.username; + req.session.userId = row.id; + req.session.username = row.username; + + const user = { + id: row.id, + username: row.username, + role: row.role, + permissions: row.permissions || [], + is_active: true, + }; // Ensure the store writes before the response finishes (proxy / HTTPS). req.session.save((err) => { @@ -62,10 +70,7 @@ router.post('/login', async (req, res) => { console.error('Auth session save error:', err.message); return res.status(500).json({ error: 'Login failed' }); } - res.json({ - role: 'curator', - username: user.username, - }); + res.json(staffAuthPayload(user)); }); } catch (err) { console.error('Auth login error:', err.message); diff --git a/server/routes/influences.js b/server/routes/influences.js index 56ea84a..df181c5 100644 --- a/server/routes/influences.js +++ b/server/routes/influences.js @@ -1,6 +1,6 @@ const express = require('express'); const pool = require('../db'); -const { requireCurator } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/auth'); const { logCuratorAction } = require('../audit-log'); const { COLUMN_ROLES, @@ -24,7 +24,7 @@ function parseId(value) { return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; } -router.get('/presets', requireCurator, (_req, res) => { +router.get('/presets', requirePermission('influences'), (_req, res) => { res.json({ roles: COLUMN_ROLES, presets: Object.values(PRESETS).map((p) => ({ @@ -35,7 +35,7 @@ router.get('/presets', requireCurator, (_req, res) => { }); }); -router.get('/', requireCurator, async (req, res) => { +router.get('/', requirePermission('influences'), async (req, res) => { try { const artistId = parseId(req.query.artistId); const paintingId = parseId(req.query.paintingId); @@ -138,7 +138,7 @@ router.get('/', requireCurator, async (req, res) => { } }); -router.get('/graph', requireCurator, async (req, res) => { +router.get('/graph', requirePermission('influences'), async (req, res) => { try { const artistId = parseId(req.query.artistId); const paintingId = parseId(req.query.paintingId); @@ -261,7 +261,7 @@ router.get('/graph', requireCurator, async (req, res) => { } }); -router.post('/', requireCurator, async (req, res) => { +router.post('/', requirePermission('influences'), async (req, res) => { try { const { paintingId, @@ -361,7 +361,7 @@ router.post('/', requireCurator, async (req, res) => { } }); -router.patch('/:id', requireCurator, async (req, res) => { +router.patch('/:id', requirePermission('influences'), async (req, res) => { try { const id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); @@ -450,7 +450,7 @@ router.patch('/:id', requireCurator, async (req, res) => { } }); -router.delete('/:id', requireCurator, async (req, res) => { +router.delete('/:id', requirePermission('influences'), async (req, res) => { try { const id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); @@ -491,7 +491,7 @@ router.delete('/:id', requireCurator, async (req, res) => { } }); -router.post('/import/parse', requireCurator, async (req, res) => { +router.post('/import/parse', requirePermission('influences'), async (req, res) => { try { const { filename, sheet, contentBase64, content } = req.body || {}; let buffer; @@ -552,7 +552,7 @@ router.post('/import/parse', requireCurator, async (req, res) => { } }); -router.post('/import/preview', requireCurator, async (req, res) => { +router.post('/import/preview', requirePermission('influences'), async (req, res) => { try { const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {}; if (!Array.isArray(rows) || !rows.length) { @@ -585,7 +585,7 @@ router.post('/import/preview', requireCurator, async (req, res) => { } }); -router.post('/import/commit', requireCurator, async (req, res) => { +router.post('/import/commit', requirePermission('influences'), async (req, res) => { try { const { proposals, fileName, contentHash, payloadHash, force } = req.body || {}; if (!Array.isArray(proposals)) { diff --git a/server/routes/tours.js b/server/routes/tours.js index 58f5b57..436d1c4 100644 --- a/server/routes/tours.js +++ b/server/routes/tours.js @@ -1,6 +1,6 @@ const express = require('express'); const pool = require('../db'); -const { requireCurator } = require('../middleware/auth'); +const { requirePermission, loadStaffUser } = require('../middleware/auth'); const { logCuratorAction } = require('../audit-log'); const { enrichPaintingRow } = require('../image-service'); const { localizePaintings, resolveLocale, translationStatuses } = require('../translation-service'); @@ -87,7 +87,7 @@ router.get('/', async (req, res) => { } }); -router.get('/admin', requireCurator, async (_req, res) => { +router.get('/admin', requirePermission('tours'), async (_req, res) => { try { const { rows } = await pool.query( `SELECT t.*, @@ -125,11 +125,8 @@ router.get('/:id', async (req, res) => { const tour = rows[0]; let isCurator = false; if (req.session?.userId) { - const { rows: users } = await pool.query( - 'SELECT id FROM users WHERE id = $1', - [req.session.userId], - ); - isCurator = users.length > 0; + const user = await loadStaffUser(req.session.userId); + isCurator = Boolean(user && user.is_active); } if (tour.status !== 'published' && !isCurator) { return res.status(404).json({ error: 'Tour not found' }); @@ -150,7 +147,7 @@ router.get('/:id', async (req, res) => { } }); -router.post('/', requireCurator, async (req, res) => { +router.post('/', requirePermission('tours'), async (req, res) => { try { const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; if (!title) return res.status(400).json({ error: 'title required' }); @@ -180,7 +177,7 @@ router.post('/', requireCurator, async (req, res) => { } }); -router.patch('/:id', requireCurator, async (req, res) => { +router.patch('/:id', requirePermission('tours'), async (req, res) => { try { const id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); @@ -236,7 +233,7 @@ router.patch('/:id', requireCurator, async (req, res) => { } }); -router.delete('/:id', requireCurator, async (req, res) => { +router.delete('/:id', requirePermission('tours'), async (req, res) => { try { const id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); @@ -260,7 +257,7 @@ router.delete('/:id', requireCurator, async (req, res) => { } }); -router.put('/:id/stops', requireCurator, async (req, res) => { +router.put('/:id/stops', requirePermission('tours'), async (req, res) => { const client = await pool.connect(); try { const id = parseId(req.params.id); diff --git a/server/routes/translations.js b/server/routes/translations.js index 0a91366..69cfb52 100644 --- a/server/routes/translations.js +++ b/server/routes/translations.js @@ -1,6 +1,6 @@ const express = require('express'); const pool = require('../db'); -const { requireCurator } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/auth'); const { logCuratorAction } = require('../audit-log'); const { TRANSLATABLE_FIELDS, @@ -14,7 +14,7 @@ const router = express.Router(); const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS)); -router.get('/worklist/:entityType', requireCurator, async (req, res) => { +router.get('/worklist/:entityType', requirePermission('translations'), async (req, res) => { try { const entityType = req.params.entityType; const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru'; @@ -75,7 +75,7 @@ router.get('/worklist/:entityType', requireCurator, async (req, res) => { } }); -router.get('/coverage', requireCurator, async (req, res) => { +router.get('/coverage', requirePermission('translations'), async (req, res) => { try { const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru'; const coverage = await getTranslationCoverage(locale); @@ -86,7 +86,7 @@ router.get('/coverage', requireCurator, async (req, res) => { } }); -router.get('/', requireCurator, async (req, res) => { +router.get('/', requirePermission('translations'), async (req, res) => { try { const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined; const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru'; @@ -99,7 +99,7 @@ router.get('/', requireCurator, async (req, res) => { } }); -router.get('/:entityType/:id', requireCurator, async (req, res) => { +router.get('/:entityType/:id', requirePermission('translations'), async (req, res) => { try { const entityType = req.params.entityType; const entityId = parseInt(req.params.id, 10); @@ -133,7 +133,7 @@ router.get('/:entityType/:id', requireCurator, async (req, res) => { } }); -router.put('/:entityType/:id', requireCurator, async (req, res) => { +router.put('/:entityType/:id', requirePermission('translations'), async (req, res) => { try { const entityType = req.params.entityType; const entityId = parseInt(req.params.id, 10); @@ -185,7 +185,7 @@ router.put('/:entityType/:id', requireCurator, async (req, res) => { } }); -router.post('/:entityType/:id/publish', requireCurator, async (req, res) => { +router.post('/:entityType/:id/publish', requirePermission('translations'), async (req, res) => { try { const entityType = req.params.entityType; const entityId = parseInt(req.params.id, 10); diff --git a/server/routes/users.js b/server/routes/users.js new file mode 100644 index 0000000..c6a897a --- /dev/null +++ b/server/routes/users.js @@ -0,0 +1,230 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const pool = require('../db'); +const { requirePermission } = require('../middleware/auth'); +const { logCuratorAction } = require('../audit-log'); +const { ALL_PERMISSIONS, normalizePermissions } = require('../permissions'); + +const router = express.Router(); + +const USER_SELECT = `id, username, role, permissions, is_active, created_at, last_login_at`; + +function mapUser(row) { + return { + id: row.id, + username: row.username, + role: row.role, + permissions: row.role === 'admin' ? [...ALL_PERMISSIONS] : normalizePermissions(row.permissions), + is_active: row.is_active !== false, + created_at: row.created_at, + last_login_at: row.last_login_at, + }; +} + +function parseRole(raw) { + if (raw === 'admin' || raw === 'curator') return raw; + return null; +} + +async function countActiveAdmins(client = pool) { + const { rows } = await client.query( + `SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin' AND is_active = true` + ); + return rows[0].n; +} + +async function clearUserSessions(userId) { + // connect-pg-simple stores session JSON with userId + await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]); +} + +router.use(requirePermission('users')); + +router.get('/', async (_req, res) => { + try { + const { rows } = await pool.query( + `SELECT ${USER_SELECT} FROM users ORDER BY username ASC` + ); + res.json({ users: rows.map(mapUser), permissions: ALL_PERMISSIONS }); + } catch (err) { + console.error('Users list error:', err.message); + res.status(500).json({ error: 'Failed to list users' }); + } +}); + +router.post('/', async (req, res) => { + try { + const username = + typeof req.body?.username === 'string' ? req.body.username.trim() : ''; + const password = typeof req.body?.password === 'string' ? req.body.password : ''; + const role = parseRole(req.body?.role) || 'curator'; + const permissions = normalizePermissions(req.body?.permissions); + + if (!username || username.length < 2 || username.length > 64) { + return res.status(400).json({ error: 'Username must be 2–64 characters' }); + } + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + return res.status(400).json({ error: 'Username may only contain letters, numbers, . _ -' }); + } + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + if (role === 'admin' && req.curatorUser.role !== 'admin') { + return res.status(403).json({ error: 'Only admins can create admin accounts' }); + } + + const passwordHash = await bcrypt.hash(password, 10); + const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions; + + const { rows } = await pool.query( + `INSERT INTO users (username, password_hash, role, permissions, is_active) + VALUES ($1, $2, $3, $4::text[], true) + RETURNING ${USER_SELECT}`, + [username, passwordHash, role, storedPermissions] + ); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'user.create', + resourceType: 'user', + resourceId: rows[0].id, + details: { username, role, permissions: storedPermissions }, + req, + }); + + res.status(201).json({ user: mapUser(rows[0]) }); + } catch (err) { + if (err.code === '23505') { + return res.status(409).json({ error: 'Username already exists' }); + } + console.error('Users create error:', err.message); + res.status(500).json({ error: 'Failed to create user' }); + } +}); + +router.patch('/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); + + const { rows: existingRows } = await pool.query( + `SELECT ${USER_SELECT} FROM users WHERE id = $1`, + [id] + ); + if (!existingRows[0]) return res.status(404).json({ error: 'User not found' }); + const existing = existingRows[0]; + + let role = existing.role; + if (req.body?.role !== undefined) { + const parsed = parseRole(req.body.role); + if (!parsed) return res.status(400).json({ error: 'Invalid role' }); + if (parsed === 'admin' && req.curatorUser.role !== 'admin') { + return res.status(403).json({ error: 'Only admins can promote to admin' }); + } + if (existing.role === 'admin' && parsed !== 'admin' && req.curatorUser.role !== 'admin') { + return res.status(403).json({ error: 'Only admins can demote admins' }); + } + role = parsed; + } + + let permissions = normalizePermissions(existing.permissions); + if (req.body?.permissions !== undefined) { + permissions = normalizePermissions(req.body.permissions); + } + if (role === 'admin') { + permissions = [...ALL_PERMISSIONS]; + } + + let isActive = existing.is_active !== false; + if (req.body?.is_active !== undefined) { + if (typeof req.body.is_active !== 'boolean') { + return res.status(400).json({ error: 'is_active must be boolean' }); + } + isActive = req.body.is_active; + } + + if ( + existing.role === 'admin' && + existing.is_active !== false && + (role !== 'admin' || !isActive) + ) { + const admins = await countActiveAdmins(); + if (admins <= 1) { + return res.status(400).json({ error: 'Cannot deactivate or demote the last active admin' }); + } + } + + const { rows } = await pool.query( + `UPDATE users + SET role = $2, permissions = $3::text[], is_active = $4 + WHERE id = $1 + RETURNING ${USER_SELECT}`, + [id, role, permissions, isActive] + ); + + if (!isActive) { + await clearUserSessions(id); + } + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'user.update', + resourceType: 'user', + resourceId: id, + details: { + username: rows[0].username, + role, + permissions, + is_active: isActive, + }, + req, + }); + + res.json({ user: mapUser(rows[0]) }); + } catch (err) { + console.error('Users update error:', err.message); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +router.post('/:id/password', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); + + const password = typeof req.body?.password === 'string' ? req.body.password : ''; + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + const { rows: existingRows } = await pool.query( + `SELECT id, username, role FROM users WHERE id = $1`, + [id] + ); + if (!existingRows[0]) return res.status(404).json({ error: 'User not found' }); + + if (existingRows[0].role === 'admin' && req.curatorUser.role !== 'admin' && req.curatorUser.id !== id) { + return res.status(403).json({ error: 'Only admins can reset another admin password' }); + } + + const passwordHash = await bcrypt.hash(password, 10); + await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [id, passwordHash]); + await clearUserSessions(id); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'user.reset_password', + resourceType: 'user', + resourceId: id, + details: { username: existingRows[0].username }, + req, + }); + + res.json({ ok: true }); + } catch (err) { + console.error('Users password error:', err.message); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + +module.exports = router;