From 76d12cff30269aec347f6b2cf1fe36b0d989267c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:44:38 +0200
Subject: [PATCH] feat: ask before leaving the admin panel with unsaved changes
#1255
---
.../__tests__/unsaved-changes-guard.test.tsx | 94 +++++++++++++++
app/(main)/admin/_tabs/auth.tsx | 3 +
app/(main)/admin/_tabs/branding.tsx | 3 +
.../admin/_tabs/plugin-config-panel.tsx | 23 +++-
app/(main)/admin/_tabs/plugins.tsx | 3 +
app/(main)/admin/_tabs/policy.tsx | 3 +
app/(main)/admin/_tabs/settings.tsx | 3 +
app/(main)/admin/_tabs/themes.tsx | 3 +
app/(main)/admin/_unsaved-changes-guard.tsx | 114 ++++++++++++++++++
app/(main)/admin/layout.tsx | 23 ++--
stores/admin-unsaved-store.ts | 66 ++++++++++
11 files changed, 325 insertions(+), 13 deletions(-)
create mode 100644 app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
create mode 100644 app/(main)/admin/_unsaved-changes-guard.tsx
create mode 100644 stores/admin-unsaved-store.ts
diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
new file mode 100644
index 000000000..34fa74eb8
--- /dev/null
+++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
@@ -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(<>>);
+ 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(<>>);
+ 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(<>>);
+ 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(<>>);
+ 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(<>>);
+ 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(
+ <>
+
+
+ Change Password
+ >,
+ );
+ 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(<>>);
+ const dirtyEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(dirtyEvent);
+ expect(dirtyEvent.defaultPrevented).toBe(true);
+
+ rerender(<>>);
+ const cleanEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(cleanEvent);
+ expect(cleanEvent.defaultPrevented).toBe(false);
+ });
+});
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 1ee2a3c2a..7d28e969b 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -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
Loading...
;
}
diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx
index c93da7367..b899a8256 100644
--- a/app/(main)/admin/_tabs/branding.tsx
+++ b/app/(main)/admin/_tabs/branding.tsx
@@ -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 Loading...
;
}
diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx
index d396c086a..9f5370614 100644
--- a/app/(main)/admin/_tabs/plugin-config-panel.tsx
+++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx
@@ -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 = {};
+ 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) {