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
+134
-70
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user