feat: ask before leaving the admin panel with unsaved changes #1255

This commit is contained in:
Linus Rath
2026-10-16 22:44:38 +02:00
parent 20b0c9b0b5
commit 76d12cff30
11 changed files with 325 additions and 13 deletions
@@ -0,0 +1,94 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UnsavedChangesGuard } from '../_unsaved-changes-guard';
import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
function DirtySection({ id = 'settings', dirty }: { id?: string; dirty: boolean }) {
useAdminUnsavedChanges(id, dirty);
return null;
}
afterEach(() => {
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
});
describe('admin unsaved changes guard (#1255)', () => {
it('runs the action straight away when nothing is unsaved', () => {
render(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
expect(action).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => {
render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
expect(screen.getByRole('alertdialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
expect(action).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
});
it('Leave runs the action and drops the unsaved flag', () => {
render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
expect(action).toHaveBeenCalledTimes(1);
expect(useAdminUnsavedStore.getState().dirty).toEqual({});
});
it('Escape closes the prompt like Stay', () => {
render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(action).not.toHaveBeenCalled();
});
it('a scoped leave only looks at, and only clears, its own section', () => {
render(<><UnsavedChangesGuard /><DirtySection id="plugins" dirty /><DirtySection id="plugin-config" dirty={false} /></>);
const back = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
expect(back).toHaveBeenCalledTimes(1);
act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true));
act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
expect(back).toHaveBeenCalledTimes(2);
expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
});
it('holds a link click until the admin picks Leave', () => {
const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
render(
<>
<UnsavedChangesGuard />
<DirtySection dirty />
<a href="/admin/change-password" onClick={onLinkClick}>Change Password</a>
</>,
);
fireEvent.click(screen.getByText('Change Password'));
expect(onLinkClick).not.toHaveBeenCalled();
expect(screen.getByRole('alertdialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
expect(onLinkClick).toHaveBeenCalledTimes(1);
});
it('asks the browser to confirm closing the page only while edits are unsaved', () => {
const { rerender } = render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
const dirtyEvent = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(dirtyEvent);
expect(dirtyEvent.defaultPrevented).toBe(true);
rerender(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
const cleanEvent = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(cleanEvent);
expect(cleanEvent.defaultPrevented).toBe(false);
});
});
+3
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
interface ConfigEntry {
@@ -135,6 +136,8 @@ export function AuthTab() {
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('auth', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
+3
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
@@ -340,6 +341,8 @@ export function BrandingTab() {
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
useAdminUnsavedChanges('branding', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
+17 -6
View File
@@ -1,8 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
@@ -73,9 +74,9 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
return () => { cancelled = true; };
}, [pluginId]);
useEffect(() => {
if (!plugin?.configSchema) return;
const savedValues = useMemo(() => {
const initial: Record<string, string> = {};
if (!plugin?.configSchema) return initial;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
@@ -86,9 +87,19 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
initial[key] = '';
}
}
setFormValues(initial);
return initial;
}, [plugin, config]);
useEffect(() => {
if (!plugin?.configSchema) return;
setFormValues(savedValues);
}, [plugin, savedValues]);
const dirty = Object.keys(savedValues).some(key => (formValues[key] ?? '') !== savedValues[key]);
useAdminUnsavedChanges('plugin-config', dirty);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const handleBack = () => requestLeave(onBack, 'plugin-config');
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
@@ -166,7 +177,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<div className="space-y-4">
<button
type="button"
onClick={onBack}
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Plugins
@@ -184,7 +195,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
aria-label="Back to Plugins"
>
+3
View File
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save,
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { PluginConfigPanel } from './plugin-config-panel';
import { PluginApprovalsPanel } from './plugin-approvals-panel';
@@ -252,6 +253,8 @@ export function PluginsTab() {
}
}
useAdminUnsavedChanges('plugins', policyDirty);
if (configuringId) {
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
}
+3
View File
@@ -6,6 +6,7 @@ import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
import { generateUUID } from '@/lib/utils';
import {
@@ -249,6 +250,8 @@ export function PolicyTab() {
setSaving(false);
}
useAdminUnsavedChanges('policy', dirty);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
+3
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -83,6 +84,8 @@ export function SettingsTab() {
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('settings', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
+3
View File
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
// Derive from the single source of truth so newly added built-in themes show
@@ -297,6 +298,8 @@ export function ThemesTab() {
}
}
useAdminUnsavedChanges('themes', policyDirty);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useId } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from '@/components/icons';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store';
/**
* Asks before the admin leaves a section with unsaved edits (#1255). Tab
* buttons and sign out go through requestLeave themselves; links anywhere on
* the page are caught here, and closing or reloading the tab gets the
* browser's own prompt.
*/
export function UnsavedChangesGuard() {
const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved);
const pendingLeave = useAdminUnsavedStore((s) => s.pendingLeave);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave);
const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave);
const isOpen = pendingLeave !== null;
const id = useId();
const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true });
// On document, not the dialog, so Escape means "Stay" wherever focus is.
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
cancelLeave();
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [isOpen, cancelLeave]);
useEffect(() => {
if (!hasUnsaved) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [hasUnsaved]);
useEffect(() => {
if (!hasUnsaved) return;
// Capture phase on document runs before React's root listener, so a
// Next <Link> never starts its client-side navigation.
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
// Read the store, not the render: after "Leave" the click is replayed
// before React has removed this listener.
if (!selectHasUnsaved(useAdminUnsavedStore.getState())) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const anchor = (e.target as Element | null)?.closest?.('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target && anchor.target !== '_self') return;
if (anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin === window.location.origin
&& url.pathname === window.location.pathname
&& url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
requestLeave(() => {
// Nothing is dirty any more, so the second click goes through.
if (anchor.isConnected) anchor.click();
else window.location.assign(anchor.href);
});
};
document.addEventListener('click', onClick, true);
return () => document.removeEventListener('click', onClick, true);
}, [hasUnsaved, requestLeave]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onMouseDown={(e) => { if (e.target === e.currentTarget) cancelLeave(); }}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6 flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div className="flex-1 min-w-0">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
Unsaved changes
</h2>
<p id={`${id}-message`} className="mt-2 text-sm text-muted-foreground">
You have changes that haven&apos;t been saved. If you leave now, they will be lost.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={cancelLeave}>Stay</Button>
<Button variant="destructive" onClick={confirmLeave} className="shadow-sm">Leave</Button>
</div>
</div>
</div>
);
}
+16 -7
View File
@@ -33,6 +33,8 @@ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
@@ -80,6 +82,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const pathname = usePathname();
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
// Highlight the active tab only on /admin itself - on dynamic routes
// (e.g. /admin/plugins/[id]) no tab is "current".
const activeTab = pathname === '/admin' ? storeActiveTab : null;
@@ -175,9 +178,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
});
}
// Don't gate the login page
@@ -207,10 +212,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const active = activeTab === tab;
const showDot = tab === 'version' && hasUpdate;
const handleClick = () => {
setActiveTab(tab);
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
// we still need a real navigation back to /admin so the page renders.
if (pathname !== '/admin') router.push('/admin');
if (active) return;
requestLeave(() => {
setActiveTab(tab);
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
// we still need a real navigation back to /admin so the page renders.
if (pathname !== '/admin') router.push('/admin');
});
};
return (
<button
@@ -280,6 +288,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex bg-background">
<UnsavedChangesGuard />
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { create } from 'zustand';
interface AdminUnsavedState {
/** Sections of the admin panel that hold edits nobody has saved yet. */
dirty: Record<string, true>;
/** What to run once the admin chooses "Leave" in the prompt. */
pendingLeave: (() => void) | null;
/** The one section the pending leave discards, or null for all of them. */
pendingScope: string | null;
setDirty: (id: string, dirty: boolean) => void;
/**
* Runs `action` now, or asks first when something is unsaved (#1255).
* With `scope`, only that section's edits count, for leaving a nested
* panel while the section around it stays mounted.
*/
requestLeave: (action: () => void, scope?: string) => void;
confirmLeave: () => void;
cancelLeave: () => void;
}
export const useAdminUnsavedStore = create<AdminUnsavedState>()((set, get) => ({
dirty: {},
pendingLeave: null,
pendingScope: null,
setDirty: (id, dirty) => {
const current = get().dirty;
if (dirty === (id in current)) return;
const next = { ...current };
if (dirty) next[id] = true;
else delete next[id];
set({ dirty: next });
},
requestLeave: (action, scope) => {
const dirty = get().dirty;
if (scope ? !(scope in dirty) : Object.keys(dirty).length === 0) {
action();
return;
}
set({ pendingLeave: action, pendingScope: scope ?? null });
},
confirmLeave: () => {
const { pendingLeave: action, pendingScope: scope, dirty } = get();
// Clear first: the action unmounts the dirty section, and a hard
// navigation must not trip the beforeunload prompt a second time.
let next: Record<string, true> = {};
if (scope) {
next = { ...dirty };
delete next[scope];
}
set({ pendingLeave: null, pendingScope: null, dirty: next });
action?.();
},
cancelLeave: () => set({ pendingLeave: null, pendingScope: null }),
}));
export const selectHasUnsaved = (s: AdminUnsavedState) => Object.keys(s.dirty).length > 0;
/** Reports a section's unsaved edits to the admin layout's leave prompt. */
export function useAdminUnsavedChanges(id: string, dirty: boolean) {
const setDirty = useAdminUnsavedStore((s) => s.setDirty);
useEffect(() => {
setDirty(id, dirty);
}, [id, dirty, setDirty]);
useEffect(() => () => setDirty(id, false), [id, setDirty]);
}