From c9975eef3fb9f48d5fc7b87e761854108ed62758 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:05:51 +0100 Subject: [PATCH] fix: report admin results in the app's toasts instead of a banner under the page header --- .../admin/__tests__/admin-feedback.test.tsx | 53 +++++++++++++++++ app/(main)/admin/_layout-client.tsx | 12 +++- app/(main)/admin/_pages/auth.tsx | 5 +- app/(main)/admin/_pages/branding.tsx | 5 +- app/(main)/admin/_pages/extension-detail.tsx | 5 +- .../admin/_pages/marketplace-detail.tsx | 5 +- app/(main)/admin/_pages/marketplace.tsx | 5 +- app/(main)/admin/_pages/plugins.tsx | 5 +- app/(main)/admin/_pages/policy.tsx | 5 +- app/(main)/admin/_pages/settings.tsx | 5 +- app/(main)/admin/_pages/telemetry.tsx | 5 +- app/(main)/admin/_pages/themes.tsx | 5 +- app/(main)/admin/_pages/version.tsx | 5 +- app/(main)/admin/change-password/page.tsx | 5 +- .../admin/__tests__/flash-message.test.tsx | 31 ---------- components/admin/feedback.ts | 20 +++++++ components/admin/flash-message.tsx | 57 ------------------- 17 files changed, 108 insertions(+), 125 deletions(-) create mode 100644 app/(main)/admin/__tests__/admin-feedback.test.tsx delete mode 100644 components/admin/__tests__/flash-message.test.tsx create mode 100644 components/admin/feedback.ts delete mode 100644 components/admin/flash-message.tsx diff --git a/app/(main)/admin/__tests__/admin-feedback.test.tsx b/app/(main)/admin/__tests__/admin-feedback.test.tsx new file mode 100644 index 000000000..05851e646 --- /dev/null +++ b/app/(main)/admin/__tests__/admin-feedback.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +// The admin reports what an action did in the app's toasts, like Settings, +// not in a banner under the page header. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] })); +let fail = false; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'POST' && fail) { + return new Response(JSON.stringify({ error: 'not a URL' }), { status: 400, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ + consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example', + consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }), + withBasePath: (p: string) => p, +})); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); + +import { TelemetryPage } from '../_pages/telemetry'; + +afterEach(() => { + fail = false; + toast.success.mockClear(); + toast.error.mockClear(); + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +async function saveEndpoint(value: string) { + render(); + fireEvent.change(await screen.findByLabelText('Collector URL'), { target: { value } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); +} + +describe('admin feedback', () => { + it('says a save worked in a success toast, with no banner', async () => { + await saveEndpoint('https://collector.example'); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Telemetry settings saved.')); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('says a save failed in an error toast', async () => { + fail = true; + await saveEndpoint('nope'); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Couldn't save: Endpoint: not a URL")); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/app/(main)/admin/_layout-client.tsx b/app/(main)/admin/_layout-client.tsx index 1a91ca9ac..c486d4927 100644 --- a/app/(main)/admin/_layout-client.tsx +++ b/app/(main)/admin/_layout-client.tsx @@ -7,6 +7,15 @@ import { apiFetch } from '@/lib/browser-navigation'; import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; import { AdminShell } from '@/components/admin/admin-shell'; import { UnsavedChangesGuard } from './_unsaved-changes-guard'; +import { ToastContainer } from '@/components/ui/toast'; +import { useToastStore } from '@/stores/toast-store'; + +/** The app's toasts, which the admin reports its results in (R9). */ +function AdminToasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} /** * Gates the admin panel behind the admin session and draws its shell. The @@ -107,12 +116,13 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) { // Don't gate the login page if (pathname === '/admin/login') { - return <>{children}; + return <>{children}; } return ( <> + >({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [configReadOnly, setConfigReadOnly] = useState(false); useEffect(() => { @@ -163,7 +163,6 @@ export function AuthPage() {
- setMessage(null)} /> {setupOpen && (
(null); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [selectedHost, setSelectedHost] = useState(null); const [addingHost, setAddingHost] = useState(false); const [newHostInput, setNewHostInput] = useState(''); @@ -431,7 +431,6 @@ export function BrandingPage() {
- setMessage(null)} />
diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx index cb90eae4e..0680c1915 100644 --- a/app/(main)/admin/_pages/extension-detail.tsx +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -15,7 +15,7 @@ import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminLink } from '@/components/admin/admin-link'; import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; import { ExtensionIcon } from '@/components/admin/extension-icon'; import { cn } from '@/lib/utils'; @@ -84,7 +84,7 @@ export function ExtensionDetailPage({ kind, id }: Props) { const [revealSecrets, setRevealSecrets] = useState>({}); const [saving, setSaving] = useState(false); const [busy, setBusy] = useState(null); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const iconFor = useExtensionIcons(); const load = useCallback(async () => { @@ -354,7 +354,6 @@ export function ExtensionDetailPage({ kind, id }: Props) { {entry.description &&

{entry.description}

} - setMessage(null)} />
diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index 0eaa086e4..ee3866ab5 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -35,7 +35,7 @@ import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminLink } from '@/components/admin/admin-link'; import { AdminButtonLink } from '@/components/admin/button-link'; import { Badge } from '@/components/admin/badge'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { installVersion } from './marketplace'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; @@ -138,7 +138,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [uninstalling, setUninstalling] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [showSource, setShowSource] = useState(false); const [showManifest, setShowManifest] = useState(false); // The version being previewed, from the install link (?version=) or a row @@ -429,7 +429,6 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
)} - setMessage(null)} />
diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx index 68e34ce2f..e2b7b3332 100644 --- a/app/(main)/admin/_pages/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -18,7 +18,7 @@ import { AdminButtonLink } from '@/components/admin/button-link'; import { AdminList, AdminListRow } from '@/components/admin/list'; import { Badge } from '@/components/admin/badge'; import { EmptyState } from '@/components/admin/empty-state'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { ConfigNote, inputClass } from '@/components/admin/config-items'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; @@ -139,7 +139,7 @@ export function MarketplacePage() { const [directoryUrl, setDirectoryUrl] = useState(null); const [channel, setChannel] = useState<'stable' | 'beta'>('stable'); const [installing, setInstalling] = useState>(new Set()); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [error, setError] = useState(null); const [updates, setUpdates] = useState(null); const [updatesLoading, setUpdatesLoading] = useState(true); @@ -270,7 +270,6 @@ export function MarketplacePage() { )} /> - setMessage(null)} />
diff --git a/app/(main)/admin/_pages/plugins.tsx b/app/(main)/admin/_pages/plugins.tsx index c1d770dae..74e9c19ab 100644 --- a/app/(main)/admin/_pages/plugins.tsx +++ b/app/(main)/admin/_pages/plugins.tsx @@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list'; import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; import { EmptyState } from '@/components/admin/empty-state'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { ExtensionIcon } from '@/components/admin/extension-icon'; import { PluginApprovalsPanel } from './plugin-approvals-panel'; import { useExtensionIcons } from './use-extension-icons'; @@ -26,7 +26,7 @@ export function PluginsPage() { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); // On/off per plugin waits for Save, like the policy switches below. const [enabledDraft, setEnabledDraft] = useState>({}); const fileInputRef = useRef(null); @@ -184,7 +184,6 @@ export function PluginsPage() { )} /> - setMessage(null)} />
diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index e1ae84045..c8e88a1c0 100644 --- a/app/(main)/admin/_pages/policy.tsx +++ b/app/(main)/admin/_pages/policy.tsx @@ -21,7 +21,7 @@ import { Button } from '@/components/ui/button'; import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; // `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized @@ -98,7 +98,7 @@ export function PolicyPage() { const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [dirty, setDirty] = useState(false); useEffect(() => { fetchPolicy(); }, []); @@ -291,7 +291,6 @@ export function PolicyPage() {
- setMessage(null)} /> {loading ? (
diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx index 987ea6333..617a9ced6 100644 --- a/app/(main)/admin/_pages/settings.tsx +++ b/app/(main)/admin/_pages/settings.tsx @@ -8,7 +8,7 @@ import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { SettingsSection, SectionNav } from '@/components/settings/settings-section'; import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminSaveBar } from '@/components/admin/save-bar'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items'; import { JmapServersSection } from './_jmap-servers-section'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; @@ -26,7 +26,7 @@ export function SettingsPage() { const [edits, setEdits] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); useEffect(() => { fetchConfig(); @@ -111,7 +111,6 @@ export function SettingsPage() {
- setMessage(null)} />
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index 23ceaf91d..73cdae984 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -6,7 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/setting import { Button } from '@/components/ui/button'; import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminSaveBar } from '@/components/admin/save-bar'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { ConfigNote, inputClass } from '@/components/admin/config-items'; import { adminPageDef } from '@/lib/admin/admin-nav'; import { apiFetch } from '@/lib/browser-navigation'; @@ -45,7 +45,7 @@ export function TelemetryPage() { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [endpointDraft, setEndpointDraft] = useState(''); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); // The heartbeat switch waits for Save like the endpoint (one page, one // save bar). const [consentDraft, setConsentDraft] = useState<'on' | 'off' | null>(null); @@ -149,7 +149,6 @@ export function TelemetryPage() {
{header} - setMessage(null)} />
diff --git a/app/(main)/admin/_pages/themes.tsx b/app/(main)/admin/_pages/themes.tsx index c9e1db2ac..f9701e63f 100644 --- a/app/(main)/admin/_pages/themes.tsx +++ b/app/(main)/admin/_pages/themes.tsx @@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list'; import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; import { EmptyState } from '@/components/admin/empty-state'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; import { cn } from '@/lib/utils'; import { ExtensionIcon } from '@/components/admin/extension-icon'; @@ -42,7 +42,7 @@ export function ThemesPage() { const iconFor = useExtensionIcons(); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); // On/off per theme waits for Save, like the policy switches below. const [enabledDraft, setEnabledDraft] = useState>({}); const fileInputRef = useRef(null); @@ -262,7 +262,6 @@ export function ThemesPage() { )} /> - setMessage(null)} />
(null); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); async function refresh(): Promise { setLoading(true); @@ -133,7 +133,6 @@ export function VersionPage() {
{header} - setMessage(null)} />
diff --git a/app/(main)/admin/change-password/page.tsx b/app/(main)/admin/change-password/page.tsx index 2881c69cf..bb1b9073c 100644 --- a/app/(main)/admin/change-password/page.tsx +++ b/app/(main)/admin/change-password/page.tsx @@ -6,7 +6,7 @@ import { Loader2 } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; import { Button } from '@/components/ui/button'; import { AdminPageHeader } from '@/components/admin/page-header'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; import { cn } from '@/lib/utils'; @@ -15,7 +15,7 @@ export default function ChangePasswordPage() { const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [message, setMessage] = useFlash(); + const setMessage = useAdminFeedback(); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { @@ -61,7 +61,6 @@ export default function ChangePasswordPage() {
- setMessage(null)} />
{fields.map((field) => ( diff --git a/components/admin/__tests__/flash-message.test.tsx b/components/admin/__tests__/flash-message.test.tsx deleted file mode 100644 index d0729a620..000000000 --- a/components/admin/__tests__/flash-message.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Flash } from '../flash-message'; - -describe('Flash', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('renders nothing without a message', () => { - const { container } = render( {}} />); - expect(container.firstChild).toBeNull(); - }); - - it('shows an error as an alert until dismissed', () => { - const onDismiss = vi.fn(); - render(); - expect(screen.getByRole('alert').textContent).toContain('Save failed'); - act(() => { vi.advanceTimersByTime(10_000); }); - expect(onDismiss).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('lets a success go away by itself', () => { - const onDismiss = vi.fn(); - render(); - expect(screen.getByRole('status').textContent).toContain('Saved'); - act(() => { vi.advanceTimersByTime(6_000); }); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); -}); diff --git a/components/admin/feedback.ts b/components/admin/feedback.ts new file mode 100644 index 000000000..db2058a77 --- /dev/null +++ b/components/admin/feedback.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback } from 'react'; +import { toast } from '@/stores/toast-store'; + +export type AdminFeedback = { type: 'success' | 'error'; text: string } | null; + +/** + * Reports the outcome of an admin action in a toast, the same feedback the + * rest of the app uses (R9). A success goes away by itself, an error stays + * longer. `null` says nothing: it used to clear the page banner this + * replaces, and pages still pass it before an action starts. + */ +export function useAdminFeedback(): (feedback: AdminFeedback) => void { + return useCallback((feedback: AdminFeedback) => { + if (!feedback) return; + if (feedback.type === 'success') toast.success(feedback.text); + else toast.error(feedback.text); + }, []); +} diff --git a/components/admin/flash-message.tsx b/components/admin/flash-message.tsx deleted file mode 100644 index b91d6ccb8..000000000 --- a/components/admin/flash-message.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; -import { CheckCircle2, AlertTriangle, X } from '@/components/icons'; -import { cn } from '@/lib/utils'; - -export type FlashMessage = { type: 'success' | 'error'; text: string } | null; - -/** Page-level feedback state; the message is shown with ``. */ -export function useFlash(): [FlashMessage, Dispatch>] { - return useState(null); -} - -interface FlashProps { - message: FlashMessage; - onDismiss: () => void; - className?: string; -} - -/** - * The outcome of the last action, under the page header. A success goes - * away by itself; an error stays until dismissed or the next action. - */ -export function Flash({ message, onDismiss, className }: FlashProps) { - useEffect(() => { - if (message?.type !== 'success') return; - const timer = setTimeout(onDismiss, 6000); - return () => clearTimeout(timer); - }, [message, onDismiss]); - - if (!message) return null; - const success = message.type === 'success'; - const Icon = success ? CheckCircle2 : AlertTriangle; - return ( -
- - {message.text} - -
- ); -}