Add curator authentication with audit logging and fix empty 3D gallery sessions.

Introduce session-based curator login, gate debug/checkup routes, log mutations to curator_audit_log, and keep guest hall preload public. Fix gallery view mounting so WebGL halls render reliably after navigation.
This commit is contained in:
Danila Khodjaef
2026-07-06 00:17:01 +03:00
parent aa31a2aa6e
commit 9da065acbe
27 changed files with 1252 additions and 112 deletions
+49 -6
View File
@@ -10,12 +10,47 @@ import type {
const API = '/api';
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
export interface AuthState {
role: AuthRole;
username?: string;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...fetchCredentials, ...init });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function getAuthMe(): Promise<AuthState> {
return fetchJson<AuthState>(`${API}/auth/me`);
}
export async function loginCurator(username: string, password: string): Promise<AuthState> {
const res = await fetch(`${API}/auth/login`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed: ${res.status}`);
}
return res.json();
}
export async function logoutCurator(): Promise<void> {
const res = await fetch(`${API}/auth/logout`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
}
export function imageUrl(path: string | null | undefined): string {
if (!path) return '/placeholder-art.svg';
return `/images/${path}`;
@@ -84,6 +119,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -96,7 +132,10 @@ async function postJsonImageAction<T>(url: string, payload: { imageData: string;
}
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
const res = await fetch(`${API}/artists/${artistId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
@@ -198,6 +237,7 @@ export const api = {
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/paintings/${id}/fix-image`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
@@ -210,7 +250,7 @@ export const api = {
}),
clearPaintingImage: (id: number) =>
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
fetch(`${API}/paintings/${id}/clear-image`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
@@ -219,7 +259,7 @@ export const api = {
}),
deletePainting: (id: number) =>
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
fetch(`${API}/paintings/${id}`, { ...fetchCredentials, method: 'DELETE' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Remove failed: ${res.status}`);
@@ -239,6 +279,7 @@ export const api = {
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/paintings/${id}/checkup-flags`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
@@ -262,6 +303,7 @@ export const api = {
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/artists/${id}/fix-portrait`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
@@ -274,7 +316,7 @@ export const api = {
}),
clearArtistPortrait: (id: number) =>
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
fetch(`${API}/artists/${id}/clear-portrait`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
@@ -292,6 +334,7 @@ export const api = {
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/artists/${id}/checkup-flags`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),