release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
import { reportIfSessionExpired } from './sessionExpiry';
import * as Icons from './Icons';
interface Props {
@@ -15,10 +16,11 @@ interface Props {
}
const TABS = [
{ label: 'Pages', href: '/admin/pages', match: /^\/admin(\/pages)?$/ },
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ },
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ },
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
];
@@ -47,7 +49,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
async function handleReload() {
setSaving(true);
try {
await fetch('/api/admin/reload', { method: 'POST' });
const res = await fetch('/api/admin/reload', { method: 'POST' });
// A 401 or a 500 used to be indistinguishable from success here — the
// result was never checked at all (#596).
if (!res.ok) {
if (!reportIfSessionExpired(res)) {
alert(`Reload failed (HTTP ${res.status}).`);
}
return;
}
// Refresh status after reload
await fetchStatus();
} finally {
@@ -141,15 +151,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<div className="admin-header-left">
<h1>Immich Folio</h1>
<nav className="admin-tabs">
{TABS.map((t) => (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${t.match.test(pathname) ? 'active' : ''}`}
>
{t.label}
</Link>
))}
{TABS.map((t) => {
const active = t.match.test(pathname);
return (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{t.label}
</Link>
);
})}
</nav>
</div>
<div className="admin-header-right">
@@ -247,24 +261,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
)}
</div>
</div>
<div className="status-dropdown-footer">
<Link
href="/admin/diagnostics"
className="admin-btn admin-btn-sm"
onClick={() => setShowStatus(false)}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</Link>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowBackupModal(true);
}}
>
<Icons.IconArchive size={14} /> Manage Backups
</button>
</div>
</div>
</>
)}

View File

@@ -4,9 +4,11 @@ import { useState } from 'react';
interface Props {
onSuccess: () => void;
/** Shown above the form, e.g. "Your session expired. Sign in again." (#596) */
notice?: string;
}
export default function AdminLogin({ onSuccess }: Props) {
export default function AdminLogin({ onSuccess, notice }: Props) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -44,6 +46,8 @@ export default function AdminLogin({ onSuccess }: Props) {
<p>Admin Panel</p>
</div>
{notice && <div className="admin-notice">{notice}</div>}
<div className="admin-field">
<label htmlFor="admin-password">Password</label>
<input

View File

@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
@@ -70,17 +69,11 @@ export default function HelpView() {
</span>
<span className="settings-group-desc">
Checks the things that fail silently — a wrong proxy hop count, a deleted album ID, a
plaintext password, an album Immich does not consider shared. It also sits behind the
status badge in the top bar, which is where it belongs when the question is whether
anything needs attention right now.
plaintext password, an album Immich does not consider shared. Open it from the{' '}
<strong>Diagnostics</strong> tab in the top bar; the status badge next to it still gives
a quick read on whether anything needs attention right now.
</span>
</div>
<div className="help-action">
<Link href="/admin/diagnostics" className="admin-btn">
<Icons.IconShieldCheck size={14} /> Open diagnostics
</Link>
</div>
</div>
</div>
);

View File

@@ -26,6 +26,7 @@ import {
import AssetPicker from './AssetPicker';
import { BlockBadge } from './BlockBadge';
import { useUnsavedGuard } from './useUnsavedGuard';
import { reportIfSessionExpired } from './sessionExpiry';
import { EssayView } from '@/app/[...path]/EssayView';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import './journal-studio.css';
@@ -435,23 +436,33 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
title: string;
} | null>(null);
/** Set when the entry could not be fetched; blocks saving over it. */
const [loadError, setLoadError] = useState<string | null>(null);
// Load entry
useEffect(() => {
async function load() {
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
if (res.ok) {
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} else {
alert('Failed to load journal entry');
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
} catch {
alert('Error loading journal entry');
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} catch (err) {
console.error('Failed to load journal entry:', err);
// An empty editor saved over the entry replaces it with nothing, so
// this blocks the save rather than only reporting it.
setLoadError(err instanceof Error ? err.message : 'The entry could not be loaded.');
} finally {
setLoading(false);
}
@@ -504,6 +515,9 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
// Save
const handleSave = async () => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
@@ -516,7 +530,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
if (res.ok) {
setDirty(false);
} else {
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
alert(data.error || 'Failed to save');
}
@@ -636,6 +650,25 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
);
}
/**
* Replaces the editor rather than sitting above it: an empty editor saved
* over the entry would replace the text with nothing.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This entry could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
overwrite the entry.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
</button>
</div>
);
}
return (
<div className="journal-editor-container">
{/* Top Bar */}

View File

@@ -27,6 +27,7 @@ import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import { parseAlbumEntries, serializeAlbumEntries } from './page-builder/albumEntries';
import {
seedCoverGrid,
type ActiveEditAlbumAddress,
@@ -40,8 +41,7 @@ import {
} from './page-builder/types';
import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard';
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import { reportIfSessionExpired } from './sessionExpiry';
import {
IconCamera,
IconFolder,
@@ -62,73 +62,6 @@ interface GalleryState {
subpages: Subpage[];
}
// ── Helpers ────────────────────────────────────────────────────
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition,
);
}
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
};
});
}
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
return { [entry.id]: val };
});
}
// ── Sortable Hero Tile ─────────────────────────────────────────
function SortableHeroTile({
@@ -269,6 +202,12 @@ export default function PageBuilder() {
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbumInfo[]>([]);
const [loading, setLoading] = useState(true);
/**
* Set when gallery.yaml could not be fetched. It blocks saving: the builder
* writes the whole file from its own state, so saving an empty builder
* publishes an empty gallery.
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -356,25 +295,35 @@ export default function PageBuilder() {
async function loadData() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
]);
if (galleryRes.ok) {
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
if (!galleryRes.ok) {
throw new Error(
galleryRes.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${galleryRes.status}.`,
);
}
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
// A failed album list is survivable — it only empties the picker, and
// saving with it empty changes nothing in gallery.yaml.
if (albumsRes.ok) {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
} finally {
setLoading(false);
}
@@ -424,6 +373,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
@@ -451,6 +401,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(
@@ -474,6 +425,9 @@ export default function PageBuilder() {
// ── Save ──────────────────────────────────────────────────────
async function handleSave() {
// Not reachable from the UI in this state, but Cmd+S still gets here.
if (loadError) return;
setSaving(true);
setSaveMessage('');
@@ -497,6 +451,7 @@ export default function PageBuilder() {
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => {
@@ -530,7 +485,7 @@ export default function PageBuilder() {
setDirty(false);
setSaveMessage(data.message || 'Saved successfully!');
setTimeout(() => setSaveMessage(''), 5000);
} else {
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
}
@@ -729,7 +684,12 @@ export default function PageBuilder() {
}
// ── Album Removal ────────────────────────────────────────────
// Confirmed like removeSubpage: an album entry carries its grid overrides,
// cover asset and manual assetOrder with it, there is no undo stack in the
// builder, and the only escape from an accidental click used to be
// reloading the page — which discards every other unsaved edit too (#597).
function removeStandaloneAlbum(index: number) {
if (!confirm('Remove this album from the gallery?')) return;
setGallery((g) => ({
...g,
albums: g.albums.filter((_, i) => i !== index),
@@ -738,6 +698,7 @@ export default function PageBuilder() {
}
function removeSubpageAlbum(subpageIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the subpage?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -749,6 +710,7 @@ export default function PageBuilder() {
}
function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the section?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -765,6 +727,7 @@ export default function PageBuilder() {
// ── Hero Management ──────────────────────────────────────────
function removeHero(index: number) {
if (!confirm('Remove this photo from the home page hero?')) return;
setGallery((g) => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -813,6 +776,26 @@ export default function PageBuilder() {
);
}
/**
* Replaces the builder rather than sitting above it. An empty builder looks
* exactly like a site with no pages yet, and saving it used to publish an
* empty gallery.yaml over a working one.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>The page structure could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty builder cannot
overwrite your gallery.
</p>
<button className="admin-btn admin-btn-secondary" onClick={loadData}>
Try again
</button>
</div>
);
}
// Filter standalone albums
const filteredAlbums = gallery.albums.filter((album) => {
if (!searchQuery) return true;

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import * as Icons from './Icons';
import SaveBar from './SaveBar';
import { useUnsavedGuard } from './useUnsavedGuard';
import { reportIfSessionExpired } from './sessionExpiry';
import ToggleCard from './fields/ToggleCard';
import OptionGrid, { toOptions } from './fields/OptionGrid';
// Direct import from the theme module, not from '@/lib/config': the config
@@ -587,6 +588,12 @@ export default function SettingsEditor() {
source: 'env' | 'settings' | 'none';
} | null>(null);
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
* empty form saved over a live settings.yaml replaces it with whatever one
* field the user happened to touch.
*/
const [loadError, setLoadError] = useState<string | null>(null);
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
@@ -671,15 +678,28 @@ export default function SettingsEditor() {
async function loadSettings() {
setLoading(true);
setLoadError(null);
try {
const res = await fetch('/api/admin/settings');
if (res.ok) {
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
if (!res.ok) {
// 401 is the common one: the session lasts 24h and nothing re-checks
// it, so a tab left open overnight lands here. reportIfSessionExpired
// also drops the whole panel back to the login screen (#596); the
// thrown message below still covers the brief moment before that
// re-render happens, and any other non-ok status.
reportIfSessionExpired(res);
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
} finally {
setLoading(false);
}
@@ -726,6 +746,9 @@ export default function SettingsEditor() {
}
async function handleSave() {
// The form is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
setSaving(true);
setSaveMessage('');
@@ -750,9 +773,15 @@ export default function SettingsEditor() {
setSaveMessage(data.message || 'Saved!');
router.refresh();
setTimeout(() => setSaveMessage(''), 5000);
} else {
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
// A rejected save names the fields that caused it. Listing them beats
// "could not be saved" over a form with forty inputs; putting the
// message next to each input is the job of #600.
const fields: string[] = Array.isArray(err.fields)
? err.fields.map((f: { field?: string }) => f.field || 'settings')
: [];
setSaveMessage(`Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`);
}
} catch {
setSaveMessage('Error: Failed to save');
@@ -763,23 +792,36 @@ export default function SettingsEditor() {
async function loadAboutContent() {
setAboutLoading(true);
setAboutMessage('');
try {
const res = await fetch('/api/admin/about');
if (res.ok) {
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
} catch (err) {
console.error('Failed to load about content:', err);
// aboutLoaded stays false, which is what blocks the save below: an empty
// editor written to about.md would replace the page with nothing.
setAboutMessage(
`Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
);
} finally {
setAboutLoading(false);
}
}
async function saveAboutContent() {
if (!aboutLoaded) return;
setAboutSaving(true);
setAboutMessage('');
const cleanedMeta = { ...aboutMeta };
@@ -842,6 +884,26 @@ export default function SettingsEditor() {
);
}
/**
* Deliberately replaces the form rather than sitting above it. An empty form
* is indistinguishable from a site with no settings yet, and editing one
* field in it used to publish that field as the entire settings.yaml.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>Settings could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until they load, so an empty form cannot
overwrite your configuration.
</p>
<button className="admin-btn admin-btn-secondary" onClick={loadSettings}>
Try again
</button>
</div>
);
}
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
@@ -898,6 +960,7 @@ export default function SettingsEditor() {
key={sec.id}
href={`/admin/settings/${sec.id}`}
className={`settings-nav-item ${activeSection === sec.id ? 'active' : ''}`}
aria-current={activeSection === sec.id ? 'page' : undefined}
>
{sec.label}
</Link>

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import AdminDashboard from '../AdminDashboard';
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
}));
afterEach(cleanup);
/**
* `handleReload` used to await POST /api/admin/reload without checking
* `res.ok`, so a 401 or a 500 was indistinguishable from success (#596).
*/
describe('AdminDashboard reload failure reporting', () => {
it('reports a non-ok, non-401 reload failure', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 500 });
}
return new Response(null, { status: 500 }); // status/doctor probes
}),
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
});
it('raises the session-expired event instead of alerting on a 401', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 401 });
}
return new Response(null, { status: 500 });
}),
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
expect(alertMock).not.toHaveBeenCalled();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does not alert when the reload succeeds', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 200 });
}
return new Response(null, { status: 500 });
});
vi.stubGlobal('fetch', fetchMock);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([url, init]) => url === '/api/admin/reload' && init?.method === 'POST',
),
).toHaveLength(1),
);
expect(alertMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { SESSION_EXPIRED_EVENT, reportIfSessionExpired } from '../sessionExpiry';
/**
* Sessions last 24 hours, but nothing re-checked authentication after the
* mount-time check in AdminShell — every admin action after that just failed
* quietly with the UI still looking signed in (#596). This is the one place
* every admin fetch call site is meant to route a non-ok response through.
*/
describe('reportIfSessionExpired', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('dispatches the event and returns true for a 401', () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
const result = reportIfSessionExpired(new Response(null, { status: 401 }));
expect(result).toBe(true);
expect(listener).toHaveBeenCalledOnce();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does nothing and returns false for other statuses', () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
for (const status of [200, 400, 403, 404, 500]) {
expect(reportIfSessionExpired(new Response(null, { status }))).toBe(false);
}
expect(listener).not.toHaveBeenCalled();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
});

View File

@@ -0,0 +1,87 @@
/**
* The gallery.yaml ↔ page-builder translation for album entries.
*
* Extracted from PageBuilder.tsx so it can be tested on its own: this is the
* code that decides what survives a save, and a field it does not know about
* is discarded silently rather than failing (#614).
*/
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import type { AlbumEntry } from './types';
export type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
export function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition ||
entry.location ||
entry.download,
);
}
export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
location: value.location,
download: value.download,
};
});
}
export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
// No UI sets this yet; it is preserved so that saving an unrelated change
// cannot quietly republish exact coordinates for an album deliberately
// placed at city level or hidden from the map (#469).
if (entry.location) val.location = entry.location;
// Same story as location: set by hand in YAML since #475, with no UI, so
// the builder was turning original downloads back off on the next save.
if (entry.download) val.download = entry.download;
return { [entry.id]: val };
});
}

View File

@@ -13,6 +13,17 @@ export interface AlbumEntry {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string;
/**
* Map precision for this album: `exact`, `city`, `country` or `hidden` (#469).
*
* Carried verbatim rather than narrowed. The builder has no UI for it yet, so
* its only job is to hand back exactly what it was given — including a value
* this version does not recognise, which deriveGallery() is the right place
* to reject.
*/
location?: string;
/** Whether visitors may download the originals from this album (#475). */
download?: boolean;
}
export interface Section {
@@ -37,6 +48,8 @@ export interface Subpage {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** The album-cover tiles on this page only (#523). */
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** Map precision for every album here that does not set its own (#469). */
location?: string;
}
/**

View File

@@ -0,0 +1,24 @@
/**
* Fired on `window` by an admin action whose request came back 401 — the
* session cookie expired (sessions last 24 hours) or was cleared elsewhere.
* AdminShell listens and drops back to the login screen with an explanation,
* instead of each save/reload path failing quietly while the UI still looks
* signed in (#596).
*
* Same pattern as DOCTOR_LEVEL_EVENT in systemHealth.ts: a window event
* rather than a prop or context, because the components that need to raise
* this (PageBuilder, SettingsEditor, JournalStudio, AdminDashboard) sit
* several routes below AdminShell, which owns the auth gate.
*/
export const SESSION_EXPIRED_EVENT = 'folio:admin-session-expired';
/**
* Call with the Response from an admin fetch. If it is a 401, dispatches the
* event and returns true, so the caller can skip its own generic error
* handling for that response and let AdminShell take over instead.
*/
export function reportIfSessionExpired(res: Response): boolean {
if (res.status !== 401) return false;
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
return true;
}