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:
Danila Khodjaef
2026-07-27 18:04:17 +03:00
co-authored by Cursor
parent bfa21989c9
commit 0466b77328
31 changed files with 1492 additions and 269 deletions
+77 -1
View File
@@ -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) => {
+12 -8
View File
@@ -19,6 +19,7 @@ interface Props {
artist: Artist & { movement_name?: string };
debugMode?: boolean;
debugShowMore?: boolean;
canCheckup?: boolean;
portraitRevision?: number;
onBack: () => void;
onEnterGallery: () => void;
@@ -36,6 +37,7 @@ export default function ArtistBio({
artist,
debugMode = false,
debugShowMore = false,
canCheckup = true,
portraitRevision = 0,
onBack,
onEnterGallery,
@@ -322,14 +324,16 @@ export default function ArtistBio({
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+2 -1
View File
@@ -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 {
+64 -39
View File
@@ -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) => (
<defs>
{/* 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;
@@ -1557,20 +1579,21 @@ export default function MovementBands({
</linearGradient>
);
})}
</defs>
)}
</defs>
{fastGraphics ? (
<>
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch movement-branch-fast"
stroke={branch.colorTo}
fill="none"
/>
))}
<g mask="url(#movement-branch-cutout)">
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch movement-branch-fast"
stroke={branch.colorTo}
fill="none"
/>
))}
</g>
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
return (
@@ -1586,21 +1609,23 @@ export default function MovementBands({
</>
) : (
<>
{branches.map((branch) => {
const [fromId, toId] = branch.key.split('-').map(Number);
const branchHighlighted =
hoveredMovementId != null &&
(fromId === hoveredMovementId || toId === hoveredMovementId);
return (
<path
key={branch.key}
d={branch.d}
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
);
})}
<g mask="url(#movement-branch-cutout)">
{branches.map((branch) => {
const [fromId, toId] = branch.key.split('-').map(Number);
const branchHighlighted =
hoveredMovementId != null &&
(fromId === hoveredMovementId || toId === hoveredMovementId);
return (
<path
key={branch.key}
d={branch.d}
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
);
})}
</g>
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
+13 -8
View File
@@ -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,14 +737,16 @@ export default function PaintingDetailView({
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
{canCheckup && (
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
)}
<button
type="button"
className="debug-fix-btn"
+40 -8
View File
@@ -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>;
+5 -1
View File
@@ -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 },
+1
View File
@@ -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.",
+37
View File
@@ -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"
}
+1
View File
@@ -17,6 +17,7 @@
"influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
+37
View File
@@ -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": "Пароль обновлён"
}
+134 -70
View File
@@ -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,57 +1125,79 @@ export default function HomePage() {
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{t('checkup')}
</button>
{canImages && (
<>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
</>
)}
{canInfluences && (
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
)}
{canTours && (
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
)}
{canTranslations && (
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
)}
{canCheckup && (
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{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"
+149
View File
@@ -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;
}
}
+331
View File
@@ -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>
);
}