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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
bfa21989c9
commit
0466b77328
+57
-24
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`):**
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+19
-14
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
@@ -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<YearBounds>(`${API}/bounds`),
|
||||
|
||||
getCatalogBootstrap: (start?: number, end?: number) => {
|
||||
|
||||
@@ -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,6 +324,7 @@ export default function ArtistBio({
|
||||
<p className="debug-image-status">No portrait image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
{canCheckup && (
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
@@ -330,6 +333,7 @@ export default function ArtistBio({
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
|
||||
@@ -96,8 +96,9 @@
|
||||
}
|
||||
|
||||
.movement-branch-fast {
|
||||
stroke-opacity: 0.32;
|
||||
stroke-opacity: 0.36;
|
||||
stroke-width: calc(var(--stream-stroke) * 0.45);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.movement-stream-fast {
|
||||
|
||||
@@ -377,13 +377,12 @@ function branchCorridorXRange(
|
||||
parentXStart: number,
|
||||
parentXEnd: number,
|
||||
childXStart: number,
|
||||
childXEnd: number,
|
||||
childIndex: number,
|
||||
childCount: number
|
||||
): { x0: number; x1: number } {
|
||||
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
|
||||
const originX = parentXStart + t * (parentXEnd - parentXStart);
|
||||
const targetX = (childXStart + childXEnd) / 2;
|
||||
const targetX = childXStart;
|
||||
const x0 = Math.min(originX, targetX);
|
||||
const x1 = Math.max(originX, targetX);
|
||||
// Near-vertical transitions still have a wide SVG stroke — pad so overlapping streams register.
|
||||
@@ -574,7 +573,6 @@ function refineLanesForLineageCorridors(
|
||||
parentSpan.xStart,
|
||||
parentSpan.xEnd,
|
||||
childSpan.xStart,
|
||||
childSpan.xEnd,
|
||||
childIndex,
|
||||
children.length
|
||||
);
|
||||
@@ -928,7 +926,7 @@ function buildLabelPlacements(
|
||||
}
|
||||
|
||||
function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||
const x = (layout.xStart + layout.xEnd) / 2;
|
||||
const x = layout.xStart;
|
||||
return { x, y: yOnStream(layout, x) };
|
||||
}
|
||||
|
||||
@@ -1491,9 +1489,35 @@ export default function MovementBands({
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
>
|
||||
{!fastGraphics && (
|
||||
<defs>
|
||||
{branches.map((branch) => (
|
||||
{/* Hide branch ink under streams so translucent overlaps do not brighten movements */}
|
||||
<mask
|
||||
id="movement-branch-cutout"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x={0}
|
||||
y={0}
|
||||
width={100}
|
||||
height={layoutHeight}
|
||||
>
|
||||
<rect x={0} y={0} width={100} height={layoutHeight} fill="white" />
|
||||
{layouts.map((layout) => (
|
||||
<path
|
||||
key={`branch-cutout-${layout.movement.id}`}
|
||||
d={streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset)}
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
strokeWidth: streamStrokePx * 1.12,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</mask>
|
||||
|
||||
{!fastGraphics &&
|
||||
branches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`branch-grad-${branch.key}`}
|
||||
id={`branch-grad-${branch.key}`}
|
||||
@@ -1503,15 +1527,13 @@ export default function MovementBands({
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
|
||||
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
|
||||
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0.36} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.36} />
|
||||
</linearGradient>
|
||||
))}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
{!fastGraphics &&
|
||||
layouts.map((layout) => {
|
||||
const primaryParent =
|
||||
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||
@@ -1558,10 +1580,10 @@ export default function MovementBands({
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
{fastGraphics ? (
|
||||
<>
|
||||
<g mask="url(#movement-branch-cutout)">
|
||||
{branches.map((branch) => (
|
||||
<path
|
||||
key={branch.key}
|
||||
@@ -1571,6 +1593,7 @@ export default function MovementBands({
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
|
||||
return (
|
||||
@@ -1586,6 +1609,7 @@ export default function MovementBands({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<g mask="url(#movement-branch-cutout)">
|
||||
{branches.map((branch) => {
|
||||
const [fromId, toId] = branch.key.split('-').map(Number);
|
||||
const branchHighlighted =
|
||||
@@ -1601,6 +1625,7 @@ export default function MovementBands({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
|
||||
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
onArtistBio: () => void;
|
||||
onInfluenceArtistClick?: (artistId: number) => void;
|
||||
isCurator?: boolean;
|
||||
/** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */
|
||||
canCheckup?: boolean;
|
||||
debugMode?: boolean;
|
||||
debugShowMore?: boolean;
|
||||
onPaintingImageFixed?: (
|
||||
@@ -206,6 +208,7 @@ export default function PaintingDetailView({
|
||||
onArtistBio,
|
||||
onInfluenceArtistClick,
|
||||
isCurator = false,
|
||||
canCheckup = true,
|
||||
debugMode = false,
|
||||
debugShowMore = false,
|
||||
onPaintingImageFixed,
|
||||
@@ -734,6 +737,7 @@ export default function PaintingDetailView({
|
||||
<p className="debug-image-status">No Google image result found.</p>
|
||||
)}
|
||||
<div className="debug-action-buttons">
|
||||
{canCheckup && (
|
||||
<button
|
||||
type="button"
|
||||
className="debug-checked-btn"
|
||||
@@ -742,6 +746,7 @@ export default function PaintingDetailView({
|
||||
>
|
||||
{markingChecked ? '…' : 'Checked'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-fix-btn"
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState, type ReactNode, createContext } from 'react';
|
||||
import {
|
||||
getAuthMe,
|
||||
loginCurator,
|
||||
logoutCurator,
|
||||
type AuthRole,
|
||||
type StaffPermission,
|
||||
} from '../api/client';
|
||||
|
||||
interface AuthContextValue {
|
||||
role: AuthRole;
|
||||
username?: string;
|
||||
permissions: StaffPermission[];
|
||||
isCurator: boolean;
|
||||
isAdmin: boolean;
|
||||
loading: boolean;
|
||||
can: (permission: StaffPermission) => boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -13,15 +22,26 @@ interface AuthContextValue {
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function applyAuthState(
|
||||
me: { role: AuthRole; username?: string; permissions?: StaffPermission[] },
|
||||
setRole: (r: AuthRole) => void,
|
||||
setUsername: (u: string | undefined) => void,
|
||||
setPermissions: (p: StaffPermission[]) => void
|
||||
) {
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
setPermissions(me.role === 'user' ? [] : me.permissions ?? []);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<AuthRole>('user');
|
||||
const [username, setUsername] = useState<string | undefined>();
|
||||
const [permissions, setPermissions] = useState<StaffPermission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const me = await getAuthMe();
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
applyAuthState(me, setRole, setUsername, setPermissions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,27 +50,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const login = useCallback(async (user: string, password: string) => {
|
||||
const me = await loginCurator(user, password);
|
||||
setRole(me.role);
|
||||
setUsername(me.username);
|
||||
applyAuthState(me, setRole, setUsername, setPermissions);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutCurator();
|
||||
setRole('user');
|
||||
setUsername(undefined);
|
||||
setPermissions([]);
|
||||
}, []);
|
||||
|
||||
const can = useCallback(
|
||||
(permission: StaffPermission) => {
|
||||
if (role === 'admin') return true;
|
||||
if (role !== 'curator') return false;
|
||||
return permissions.includes(permission);
|
||||
},
|
||||
[role, permissions]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
role,
|
||||
username,
|
||||
isCurator: role === 'curator',
|
||||
permissions,
|
||||
isCurator: role === 'admin' || role === 'curator',
|
||||
isAdmin: role === 'admin',
|
||||
loading,
|
||||
can,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[role, username, loading, login, logout, refresh]
|
||||
[role, username, permissions, loading, can, login, logout, refresh]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -13,6 +13,7 @@ import enDebug from '../locales/en/debug.json';
|
||||
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 ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
@@ -25,6 +26,7 @@ import ruDebug from '../locales/ru/debug.json';
|
||||
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';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
@@ -33,7 +35,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'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
@@ -48,6 +50,7 @@ void i18n.use(initReactI18next).init({
|
||||
translations: enTranslations,
|
||||
influences: enInfluences,
|
||||
tours: enTours,
|
||||
users: enUsers,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
@@ -61,6 +64,7 @@ void i18n.use(initReactI18next).init({
|
||||
translations: ruTranslations,
|
||||
influences: ruInfluences,
|
||||
tours: ruTours,
|
||||
users: ruUsers,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"influences": "Influences",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
"openingTourGallery": "Opening guided tour…",
|
||||
"tourEmpty": "This tour has no paintings yet.",
|
||||
"tourLoadFailed": "Failed to load the tour.",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Users",
|
||||
"back": "← Back",
|
||||
"loadFailed": "Failed to load users",
|
||||
"createTitle": "Create user",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"role": "Role",
|
||||
"roleAdmin": "Admin",
|
||||
"roleCurator": "Curator",
|
||||
"permissions": "Permissions",
|
||||
"active": "Active",
|
||||
"inactive": "Disabled",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"resetPassword": "Reset password",
|
||||
"newPassword": "New password",
|
||||
"lastLogin": "Last login",
|
||||
"never": "Never",
|
||||
"selectUser": "Select a user to edit",
|
||||
"loading": "Loading…",
|
||||
"perm_images": "Images (fix/upload/debug)",
|
||||
"perm_checkup": "Checkup",
|
||||
"perm_curator_notes": "Curator notes",
|
||||
"perm_translations": "Translations",
|
||||
"perm_influences": "Influences",
|
||||
"perm_tours": "Tours",
|
||||
"perm_users": "Users",
|
||||
"adminAllPerms": "Admins have all permissions automatically.",
|
||||
"deactivate": "Disable account",
|
||||
"activate": "Enable account",
|
||||
"created": "User created",
|
||||
"saved": "Saved",
|
||||
"passwordReset": "Password updated"
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"influences": "Влияния",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
"openingTourGallery": "Открытие экскурсии…",
|
||||
"tourEmpty": "В этой экскурсии пока нет картин.",
|
||||
"tourLoadFailed": "Не удалось загрузить экскурсию.",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Пользователи",
|
||||
"back": "← Назад",
|
||||
"loadFailed": "Не удалось загрузить пользователей",
|
||||
"createTitle": "Создать пользователя",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"role": "Роль",
|
||||
"roleAdmin": "Администратор",
|
||||
"roleCurator": "Куратор",
|
||||
"permissions": "Права",
|
||||
"active": "Активен",
|
||||
"inactive": "Отключён",
|
||||
"create": "Создать",
|
||||
"creating": "Создание…",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"resetPassword": "Сбросить пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"lastLogin": "Последний вход",
|
||||
"never": "Никогда",
|
||||
"selectUser": "Выберите пользователя для редактирования",
|
||||
"loading": "Загрузка…",
|
||||
"perm_images": "Изображения (правка/загрузка)",
|
||||
"perm_checkup": "Проверка",
|
||||
"perm_curator_notes": "Заметки куратора",
|
||||
"perm_translations": "Переводы",
|
||||
"perm_influences": "Влияния",
|
||||
"perm_tours": "Экскурсии",
|
||||
"perm_users": "Пользователи",
|
||||
"adminAllPerms": "У администраторов все права автоматически.",
|
||||
"deactivate": "Отключить учётную запись",
|
||||
"activate": "Включить учётную запись",
|
||||
"created": "Пользователь создан",
|
||||
"saved": "Сохранено",
|
||||
"passwordReset": "Пароль обновлён"
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import CheckupPage from '../pages/CheckupPage';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import InfluencesPage from '../pages/InfluencesPage';
|
||||
import ToursPage from '../pages/ToursPage';
|
||||
import UsersPage from '../pages/UsersPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import ToursPopup from '../components/ToursPopup';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
@@ -22,6 +23,7 @@ import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import '../pages/InfluencesPage.css';
|
||||
import '../pages/ToursPage.css';
|
||||
import '../pages/UsersPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type {
|
||||
@@ -44,6 +46,7 @@ type View =
|
||||
| { type: 'translations' }
|
||||
| { type: 'influences' }
|
||||
| { type: 'tours' }
|
||||
| { type: 'users' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
|
||||
@@ -55,7 +58,7 @@ type GallerySession =
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
|
||||
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
|
||||
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
|
||||
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | null;
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
@@ -147,7 +150,14 @@ function catalogNavigateTarget(
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout } = useAuth();
|
||||
const { isCurator, username, login, logout, can } = useAuth();
|
||||
const canImages = can('images');
|
||||
const canCheckup = can('checkup');
|
||||
const canNotes = can('curator_notes');
|
||||
const canTranslations = can('translations');
|
||||
const canInfluences = can('influences');
|
||||
const canTours = can('tours');
|
||||
const canUsers = can('users');
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
@@ -168,7 +178,7 @@ export default function HomePage() {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
|
||||
const [toursPopupOpen, setToursPopupOpen] = useState(false);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const effectiveDebugMode = debugMode && canImages;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
@@ -279,6 +289,8 @@ export default function HomePage() {
|
||||
setView({ type: 'influences' });
|
||||
} else if (loginRedirect === 'tours') {
|
||||
setView({ type: 'tours' });
|
||||
} else if (loginRedirect === 'users') {
|
||||
setView({ type: 'users' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
@@ -291,14 +303,15 @@ export default function HomePage() {
|
||||
view.type === 'checkup' ||
|
||||
view.type === 'translations' ||
|
||||
view.type === 'influences' ||
|
||||
view.type === 'tours'
|
||||
view.type === 'tours' ||
|
||||
view.type === 'users'
|
||||
) {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
|
||||
const openCheckup = () => {
|
||||
if (!isCurator) {
|
||||
if (!canCheckup) {
|
||||
openCuratorLogin('checkup');
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +319,7 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const openTranslations = () => {
|
||||
if (!isCurator) {
|
||||
if (!canTranslations) {
|
||||
openCuratorLogin('translations');
|
||||
return;
|
||||
}
|
||||
@@ -314,7 +327,7 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const openInfluences = () => {
|
||||
if (!isCurator) {
|
||||
if (!canInfluences) {
|
||||
openCuratorLogin('influences');
|
||||
return;
|
||||
}
|
||||
@@ -322,13 +335,21 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const openToursEditor = () => {
|
||||
if (!isCurator) {
|
||||
if (!canTours) {
|
||||
openCuratorLogin('tours');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'tours' });
|
||||
};
|
||||
|
||||
const openUsers = () => {
|
||||
if (!canUsers) {
|
||||
openCuratorLogin('users');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'users' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
@@ -952,9 +973,10 @@ export default function HomePage() {
|
||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
isCurator={isCurator}
|
||||
isCurator={canNotes}
|
||||
canCheckup={canCheckup}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
debugShowMore={debugShowMore && canImages}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
@@ -968,7 +990,8 @@ export default function HomePage() {
|
||||
<ArtistBio
|
||||
artist={view.data.artist}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
debugShowMore={debugShowMore && canImages}
|
||||
canCheckup={canCheckup}
|
||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||
onBack={() => setView(view.returnTo)}
|
||||
onEnterGallery={() =>
|
||||
@@ -981,7 +1004,7 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{view.type === 'influences' && (
|
||||
isCurator ? (
|
||||
canInfluences ? (
|
||||
<InfluencesPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
@@ -1000,7 +1023,7 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{view.type === 'tours' && (
|
||||
isCurator ? (
|
||||
canTours ? (
|
||||
<ToursPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
@@ -1018,8 +1041,27 @@ export default function HomePage() {
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'users' && (
|
||||
canUsers ? (
|
||||
<UsersPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('users')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
canTranslations ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
@@ -1038,21 +1080,21 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
canCheckup ? (
|
||||
<CheckupPage
|
||||
onBack={goToTimelineHome}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>Curator access required</h2>
|
||||
<p>The painting checkup table is available to logged-in curators only.</p>
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||
Curator login
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
Back to gallery
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1083,6 +1125,8 @@ export default function HomePage() {
|
||||
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||
{username}
|
||||
</span>
|
||||
{canImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
@@ -1102,6 +1146,9 @@ export default function HomePage() {
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{canInfluences && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
@@ -1110,6 +1157,8 @@ export default function HomePage() {
|
||||
>
|
||||
{t('influences')}
|
||||
</button>
|
||||
)}
|
||||
{canTours && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
@@ -1118,6 +1167,8 @@ export default function HomePage() {
|
||||
>
|
||||
{t('toursEditor')}
|
||||
</button>
|
||||
)}
|
||||
{canTranslations && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
@@ -1126,6 +1177,8 @@ export default function HomePage() {
|
||||
>
|
||||
{t('translations')}
|
||||
</button>
|
||||
)}
|
||||
{canCheckup && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
@@ -1134,6 +1187,17 @@ export default function HomePage() {
|
||||
>
|
||||
{t('checkup')}
|
||||
</button>
|
||||
)}
|
||||
{canUsers && (
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openUsers}
|
||||
title="Manage curator accounts"
|
||||
>
|
||||
{t('users')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="curator-logout-btn"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
.users-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.users-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.users-error {
|
||||
color: #f5a5a5;
|
||||
}
|
||||
|
||||
.users-message {
|
||||
color: #a8d5a2;
|
||||
}
|
||||
|
||||
.users-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users-list-panel,
|
||||
.users-editor {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.users-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.users-list th,
|
||||
.users-list td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.users-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.users-row-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.users-create,
|
||||
.users-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.users-create label,
|
||||
.users-editor label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-create input,
|
||||
.users-create select,
|
||||
.users-editor input,
|
||||
.users-editor select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.users-perms {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.users-perm-row {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem !important;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.users-hint {
|
||||
opacity: 0.85;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-meta {
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.users-create button,
|
||||
.users-editor button {
|
||||
align-self: flex-start;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.users-create button:disabled,
|
||||
.users-editor button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.users-password-block {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.users-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ALL_STAFF_PERMISSIONS,
|
||||
api,
|
||||
type StaffPermission,
|
||||
type StaffUser,
|
||||
} from '../api/client';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import './UsersPage.css';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function emptyCreateForm() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'curator' as 'admin' | 'curator',
|
||||
permissions: ['images', 'checkup', 'curator_notes'] as StaffPermission[],
|
||||
};
|
||||
}
|
||||
|
||||
export default function UsersPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('users');
|
||||
const { isAdmin, username: selfUsername } = useAuth();
|
||||
const [users, setUsers] = useState<StaffUser[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [editRole, setEditRole] = useState<'admin' | 'curator'>('curator');
|
||||
const [editPermissions, setEditPermissions] = useState<StaffPermission[]>([]);
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const selected = users.find((u) => u.id === selectedId) ?? null;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.listUsers();
|
||||
setUsers(data.users);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setEditRole(selected.role);
|
||||
setEditPermissions(selected.permissions);
|
||||
setEditActive(selected.is_active);
|
||||
setNewPassword('');
|
||||
setMessage(null);
|
||||
}, [selected]);
|
||||
|
||||
const toggleCreatePerm = (perm: StaffPermission) => {
|
||||
setCreateForm((prev) => {
|
||||
const has = prev.permissions.includes(perm);
|
||||
return {
|
||||
...prev,
|
||||
permissions: has
|
||||
? prev.permissions.filter((p) => p !== perm)
|
||||
: [...prev.permissions, perm],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEditPerm = (perm: StaffPermission) => {
|
||||
setEditPermissions((prev) =>
|
||||
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const { user } = await api.createUser({
|
||||
username: createForm.username.trim(),
|
||||
password: createForm.password,
|
||||
role: createForm.role,
|
||||
permissions: createForm.role === 'admin' ? ALL_STAFF_PERMISSIONS : createForm.permissions,
|
||||
});
|
||||
setCreateForm(emptyCreateForm());
|
||||
setMessage(t('created'));
|
||||
await load();
|
||||
setSelectedId(user.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.updateUser(selected.id, {
|
||||
role: editRole,
|
||||
permissions: editRole === 'admin' ? ALL_STAFF_PERMISSIONS : editPermissions,
|
||||
is_active: editActive,
|
||||
});
|
||||
setMessage(t('saved'));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!selected || !newPassword) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.resetUserPassword(selected.id, newPassword);
|
||||
setNewPassword('');
|
||||
setMessage(t('passwordReset'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="users-page">
|
||||
<header className="users-header">
|
||||
<button type="button" className="users-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{error && <p className="users-error">{error}</p>}
|
||||
{message && <p className="users-message">{message}</p>}
|
||||
|
||||
<div className="users-layout">
|
||||
<section className="users-list-panel">
|
||||
{loading ? (
|
||||
<p>{t('loading')}</p>
|
||||
) : (
|
||||
<div className="users-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('username')}</th>
|
||||
<th>{t('role')}</th>
|
||||
<th>{t('active')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={user.id === selectedId ? 'users-row-selected' : undefined}
|
||||
onClick={() => setSelectedId(user.id)}
|
||||
>
|
||||
<td>
|
||||
{user.username}
|
||||
{user.username === selfUsername ? ' *' : ''}
|
||||
</td>
|
||||
<td>{user.role === 'admin' ? t('roleAdmin') : t('roleCurator')}</td>
|
||||
<td>{user.is_active ? t('active') : t('inactive')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="users-create" onSubmit={handleCreate}>
|
||||
<h2>{t('createTitle')}</h2>
|
||||
<label>
|
||||
{t('username')}
|
||||
<input
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
|
||||
autoComplete="off"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={64}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('password')}
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('role')}
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={(e) =>
|
||||
setCreateForm((p) => ({
|
||||
...p,
|
||||
role: e.target.value as 'admin' | 'curator',
|
||||
}))
|
||||
}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
<option value="curator">{t('roleCurator')}</option>
|
||||
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
|
||||
</select>
|
||||
</label>
|
||||
{createForm.role === 'curator' && (
|
||||
<fieldset className="users-perms">
|
||||
<legend>{t('permissions')}</legend>
|
||||
{ALL_STAFF_PERMISSIONS.map((perm) => (
|
||||
<label key={perm} className="users-perm-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.permissions.includes(perm)}
|
||||
onChange={() => toggleCreatePerm(perm)}
|
||||
/>
|
||||
{t(`perm_${perm}`)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{createForm.role === 'admin' && <p className="users-hint">{t('adminAllPerms')}</p>}
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? t('creating') : t('create')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="users-editor">
|
||||
{!selected ? (
|
||||
<p className="users-hint">{t('selectUser')}</p>
|
||||
) : (
|
||||
<>
|
||||
<h2>{selected.username}</h2>
|
||||
<p className="users-meta">
|
||||
{t('lastLogin')}:{' '}
|
||||
{selected.last_login_at
|
||||
? new Date(selected.last_login_at).toLocaleString()
|
||||
: t('never')}
|
||||
</p>
|
||||
<label>
|
||||
{t('role')}
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value as 'admin' | 'curator')}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
<option value="curator">{t('roleCurator')}</option>
|
||||
{isAdmin && <option value="admin">{t('roleAdmin')}</option>}
|
||||
</select>
|
||||
</label>
|
||||
{editRole === 'curator' ? (
|
||||
<fieldset className="users-perms">
|
||||
<legend>{t('permissions')}</legend>
|
||||
{ALL_STAFF_PERMISSIONS.map((perm) => (
|
||||
<label key={perm} className="users-perm-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.includes(perm)}
|
||||
onChange={() => toggleEditPerm(perm)}
|
||||
/>
|
||||
{t(`perm_${perm}`)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : (
|
||||
<p className="users-hint">{t('adminAllPerms')}</p>
|
||||
)}
|
||||
<label className="users-perm-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editActive}
|
||||
onChange={(e) => setEditActive(e.target.checked)}
|
||||
/>
|
||||
{editActive ? t('active') : t('inactive')}
|
||||
</label>
|
||||
<button type="button" onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
|
||||
<div className="users-password-block">
|
||||
<h3>{t('resetPassword')}</h3>
|
||||
<label>
|
||||
{t('newPassword')}
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResetPassword()}
|
||||
disabled={saving || newPassword.length < 8}
|
||||
>
|
||||
{t('resetPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 $$;
|
||||
@@ -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.
|
||||
|
||||
+19
-17
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+15
-3
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+24
-19
@@ -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);
|
||||
|
||||
+10
-10
@@ -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)) {
|
||||
|
||||
+8
-11
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user