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:
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import AdminLogin from './components/AdminLogin';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
|
||||
import './admin.css';
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,13 @@ import './admin.css';
|
||||
export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
// Sessions last 24 hours (lib/admin/auth.ts), but nothing re-checked
|
||||
// authentication after the mount-time check below — leave a tab open
|
||||
// overnight and every admin request answered 401 while the UI still looked
|
||||
// signed in (#596). SESSION_EXPIRED_EVENT is raised by the fetch call sites
|
||||
// that noticed; this is what turns that into dropping back to the login
|
||||
// screen with an explanation instead of each of them failing quietly.
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
@@ -28,6 +36,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onSessionExpired() {
|
||||
setAuthenticated(false);
|
||||
setSessionExpired(true);
|
||||
}
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
|
||||
}, []);
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
@@ -51,7 +68,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <AdminLogin onSuccess={() => setAuthenticated(true)} />;
|
||||
return (
|
||||
<AdminLogin
|
||||
notice={sessionExpired ? 'Your session expired. Sign in again.' : undefined}
|
||||
onSuccess={() => {
|
||||
setSessionExpired(false);
|
||||
setAuthenticated(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;
|
||||
|
||||
71
app/admin/__tests__/AdminShell.test.tsx
Normal file
71
app/admin/__tests__/AdminShell.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
|
||||
import AdminShell from '../AdminShell';
|
||||
import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/admin',
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
* Sessions last 24 hours, but nothing re-checked authentication after the
|
||||
* mount-time check below — leave a tab open overnight and every admin
|
||||
* request answered 401 while the UI still looked signed in (#596).
|
||||
* SESSION_EXPIRED_EVENT, raised by any admin fetch call site that notices a
|
||||
* 401, is what this pins: it drops the whole panel back to the login screen
|
||||
* with an explanation, instead of each call site failing quietly.
|
||||
*/
|
||||
describe('AdminShell session expiry', () => {
|
||||
it('returns to the login screen with a notice when the session expires', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === '/api/admin/auth') {
|
||||
return new Response(JSON.stringify({ authenticated: true, enabled: true }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
// AdminDashboard's own status/doctor fetches — a failure there is
|
||||
// already handled as "unknown", not the thing under test here.
|
||||
return new Response(null, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminShell>
|
||||
<div>Panel content</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Panel content')).toBeTruthy());
|
||||
expect(screen.queryByLabelText('Password')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your session expired. Sign in again.')).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText('Panel content')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no notice on an ordinary first visit', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ authenticated: false, enabled: true }))),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminShell>
|
||||
<div>Panel content</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('Password')).toBeTruthy());
|
||||
expect(screen.queryByText('Your session expired. Sign in again.')).toBeNull();
|
||||
});
|
||||
});
|
||||
88
app/admin/__tests__/SaveBar.test.tsx
Normal file
88
app/admin/__tests__/SaveBar.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import SaveBar from '../components/SaveBar';
|
||||
|
||||
/**
|
||||
* The first component test in the project, and deliberately a small one: it is
|
||||
* here to show the shape (`// @vitest-environment jsdom` on the first line,
|
||||
* render, assert, cleanup) as much as to cover SaveBar.
|
||||
*
|
||||
* SaveBar earns it though. It decides whether an outcome reads as success or
|
||||
* failure by testing whether the message happens to start with "Error" — #600
|
||||
* replaces that with a typed state, and this pins the behaviour so the swap can
|
||||
* be verified rather than eyeballed.
|
||||
*/
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const props = {
|
||||
dirty: false,
|
||||
saving: false,
|
||||
saveMessage: '',
|
||||
onSave: () => {},
|
||||
label: 'Save',
|
||||
};
|
||||
|
||||
describe('SaveBar', () => {
|
||||
it('stays out of the way when there is nothing to act on', () => {
|
||||
const { container } = render(<SaveBar {...props} />);
|
||||
|
||||
// It is pinned over the page, so rendering it with nothing to say would
|
||||
// cover content for no reason.
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('announces unsaved changes', () => {
|
||||
render(<SaveBar {...props} dirty />);
|
||||
|
||||
expect(screen.getByText('Unsaved changes')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('only offers saving when there is something to save', () => {
|
||||
const { rerender } = render(<SaveBar {...props} saveMessage="Saved!" />);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
|
||||
|
||||
rerender(<SaveBar {...props} dirty />);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', false);
|
||||
});
|
||||
|
||||
it('blocks a second save while one is in flight', () => {
|
||||
render(<SaveBar {...props} dirty saving />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Saving...' });
|
||||
expect(button).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('calls onSave when clicked', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SaveBar {...props} dirty onSave={onSave} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('tells a failure from a success by the message prefix', () => {
|
||||
// Pinned as it is, not as it should be: the prefix check is the thing #600
|
||||
// replaces. A message that fails without starting with "Error" is styled as
|
||||
// a success today, which is precisely the argument for a typed state.
|
||||
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />);
|
||||
expect(screen.getByText('Error: Failed to save').className).toContain('error');
|
||||
|
||||
rerender(<SaveBar {...props} saveMessage="Saved!" />);
|
||||
expect(screen.getByText('Saved!').className).toContain('success');
|
||||
|
||||
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
|
||||
expect(screen.getByText('Could not reach the server').className).toContain('success');
|
||||
});
|
||||
|
||||
it('offers the preview link only where it belongs', () => {
|
||||
const { rerender } = render(<SaveBar {...props} dirty />);
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
|
||||
rerender(<SaveBar {...props} dirty showPreview />);
|
||||
// `?fresh=1` is what bypasses the cache, so the preview shows the save.
|
||||
expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining('/?fresh=1'));
|
||||
});
|
||||
});
|
||||
80
app/admin/__tests__/album-entries-round-trip.test.ts
Normal file
80
app/admin/__tests__/album-entries-round-trip.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAlbumEntries,
|
||||
serializeAlbumEntries,
|
||||
hasAlbumOptions,
|
||||
} from '../components/page-builder/albumEntries';
|
||||
import type { AlbumEntryObject } from '@/lib/config/schema';
|
||||
|
||||
/**
|
||||
* The page builder rewrites gallery.yaml wholesale on every save, so a field it
|
||||
* does not know about is not merely ignored — it is deleted. `location` was
|
||||
* lost this way (#614): an album deliberately placed at city level, or hidden
|
||||
* from the map, silently went back to exact coordinates the next time anything
|
||||
* unrelated was saved.
|
||||
*
|
||||
* The round-trip test below is the part that matters. It is driven by a sample
|
||||
* entry carrying every key of AlbumEntryObject, so the next field added to the
|
||||
* schema and forgotten here fails this suite instead of quietly disappearing
|
||||
* from someone's configuration.
|
||||
*/
|
||||
|
||||
const uuid = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
/**
|
||||
* One value per key of AlbumEntryObject. The type annotation is the enforcement:
|
||||
* adding a key to the schema without adding it here is a type error, and the
|
||||
* round-trip test then proves the builder preserves it.
|
||||
*/
|
||||
const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObject, 'grid'> = {
|
||||
title: 'Kloster Chorin',
|
||||
description: 'Brick gothic in the Uckermark',
|
||||
password: 'hunter2',
|
||||
heroImage: '22222222-2222-2222-2222-222222222222',
|
||||
sort: 'oldest',
|
||||
assetOrder: ['33333333-3333-3333-3333-333333333333'],
|
||||
coverPosition: '50% 25%',
|
||||
download: true,
|
||||
location: 'hidden',
|
||||
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
|
||||
};
|
||||
|
||||
describe('album entry round trip', () => {
|
||||
it('preserves every option a gallery.yaml entry can carry', () => {
|
||||
const parsed = parseAlbumEntries([{ [uuid]: everyOption }]);
|
||||
const [serialized] = serializeAlbumEntries(parsed);
|
||||
|
||||
expect(serialized).toEqual({ [uuid]: everyOption });
|
||||
});
|
||||
|
||||
it('keeps map precision across a save', () => {
|
||||
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
|
||||
|
||||
expect(parsed[0].location).toBe('city');
|
||||
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'city' } }]);
|
||||
});
|
||||
|
||||
it('does not collapse a location-only entry back to a bare id', () => {
|
||||
// This is the actual mechanism of the bug: an entry that looks "empty" is
|
||||
// written back as a plain UUID string, taking its options with it.
|
||||
const entry = { id: uuid, location: 'hidden' };
|
||||
|
||||
expect(hasAlbumOptions(entry)).toBe(true);
|
||||
expect(serializeAlbumEntries([entry])).toEqual([{ [uuid]: { location: 'hidden' } }]);
|
||||
});
|
||||
|
||||
it('carries a value this version does not recognise', () => {
|
||||
// Narrowing belongs to deriveGallery(), which rejects a typo loudly. The
|
||||
// builder's job is to hand back what it was given rather than drop it.
|
||||
const parsed = parseAlbumEntries([{ [uuid]: { location: 'district' } }]);
|
||||
|
||||
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'district' } }]);
|
||||
});
|
||||
|
||||
it('still collapses an entry that really is bare', () => {
|
||||
expect(serializeAlbumEntries([{ id: uuid }])).toEqual([uuid]);
|
||||
expect(serializeAlbumEntries([{ id: uuid, title: 'Just a title' }])).toEqual([
|
||||
{ [uuid]: 'Just a title' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
38
app/admin/components/__tests__/sessionExpiry.test.ts
Normal file
38
app/admin/components/__tests__/sessionExpiry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
app/admin/components/page-builder/albumEntries.ts
Normal file
87
app/admin/components/page-builder/albumEntries.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
app/admin/components/sessionExpiry.ts
Normal file
24
app/admin/components/sessionExpiry.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import PageBuilder from './components/PageBuilder';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* `/admin` and `/admin/pages` both rendered the page builder, so the same
|
||||
* screen had two addresses: two routes to keep in step, two things to find
|
||||
* when something about the builder changes, and no way to tell which one a
|
||||
* bookmark or a bug report meant.
|
||||
*
|
||||
* `/admin/pages` wins because that is what the Pages tab links to. `/admin`
|
||||
* stays as the entry point people type and bookmark — and it is what the
|
||||
* public diagnostic banner links to — so it redirects rather than disappearing.
|
||||
*/
|
||||
export default function AdminHomePage() {
|
||||
return <PageBuilder />;
|
||||
redirect('/admin/pages');
|
||||
}
|
||||
|
||||
@@ -298,3 +298,15 @@
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Neutral, not an error: "your session expired" is expected behaviour after
|
||||
24 hours, not a mistake the operator made (#596). */
|
||||
.admin-notice {
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-secondary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -473,15 +473,6 @@ h3 .svg-icon {
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
/* ── Status Dropdown Footer ── */
|
||||
.status-dropdown-footer {
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--admin-bg-elevated);
|
||||
border-top: 1px solid var(--admin-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 0.35rem 0.65rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
|
||||
@@ -60,10 +60,6 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.help-action {
|
||||
padding: 12px 4px 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.help-shortcuts-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user