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/__tests__/marketplace-install-version.test.tsx b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx index e33885cc5..b8efa0cef 100644 --- a/app/(main)/admin/__tests__/marketplace-install-version.test.tsx +++ b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx @@ -28,8 +28,16 @@ describe('installVersion', () => { it('asks before installing an older version and repeats with force when the admin agrees', async () => { responses = [json({ error: 'Version 2.0.9 is older than the installed 2.1.0.', downgrade: { installedVersion: '2.1.0' } }, 409), json({ warnings: [] })]; const asked: string[] = []; - const result = await installVersion(BODY, (message) => { asked.push(message); return true; }); - expect(asked).toEqual(['Version 2.0.9 is older than the installed 2.1.0.\n\nInstall the older version anyway?']); + const result = await installVersion(BODY, (q) => { asked.push(`${q.title} ${q.message}`); return true; }); + expect(asked).toEqual(['Install the older version anyway? Version 2.0.9 is older than the installed 2.1.0.']); + expect(calls.map((c) => c.body.force)).toEqual([undefined, true]); + expect(result.ok).toBe(true); + }); + + // The question is the page's ConfirmDialog, not window.confirm. + it('waits for the answer of the page dialog', async () => { + responses = [json({ error: 'withdrawn', yanked: { reason: null } }, 409), json({ warnings: [] })]; + const result = await installVersion(BODY, async (q) => q.title === 'Install it anyway?'); expect(calls.map((c) => c.body.force)).toEqual([undefined, true]); expect(result.ok).toBe(true); }); diff --git a/app/(main)/admin/__tests__/save-bar-switches.test.tsx b/app/(main)/admin/__tests__/save-bar-switches.test.tsx new file mode 100644 index 000000000..23a5d4ed2 --- /dev/null +++ b/app/(main)/admin/__tests__/save-bar-switches.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +// On an admin page with a save bar, every control waits for Save: the +// per-plugin switch and the telemetry consent used to save at once, next to +// policy switches that waited. + +const calls: Array<{ url: string; method: string; body: Record }> = []; +const routes: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + calls.push({ url, method, body: JSON.parse(String(init?.body ?? '{}')) }); + const data = method === 'GET' ? routes[url.split('?')[0]] ?? {} : { ok: true }; + return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } }); + }), + withBasePath: (p: string) => p, +})); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); +vi.mock('../_pages/plugin-approvals-panel', () => ({ PluginApprovalsPanel: () => null })); +vi.mock('../_pages/use-extension-icons', () => ({ useExtensionIcons: () => () => null })); + +import { PluginsPage } from '../_pages/plugins'; +import { TelemetryPage } from '../_pages/telemetry'; + +const writes = () => calls.filter((c) => c.method !== 'GET'); + +beforeEach(() => { + calls.length = 0; + routes['/api/admin/plugins'] = [ + { id: 'jitsi', name: 'Jitsi', version: '1.0.0', enabled: true, author: 'x', description: '', permissions: [] }, + ]; + routes['/api/admin/policy'] = { features: {}, themePolicy: {} }; + routes['/api/admin/telemetry'] = { + consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example', + consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 }, + }; +}); +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('admin plugins page', () => { + it('turns a plugin off only when Save is pressed', async () => { + render(); + const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' }); + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect(writes()).toEqual([]); + expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true }); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(writes()).toEqual([ + expect.objectContaining({ url: '/api/admin/plugins', method: 'PATCH', body: { id: 'jitsi', enabled: false } }), + ])); + await waitFor(() => expect(useAdminUnsavedStore.getState().dirty).toEqual({})); + }); + + it('puts the switch back on Discard without saving', async () => { + render(); + const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' }); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(writes()).toEqual([]); + }); +}); + +describe('admin telemetry page', () => { + it('sends the consent only when Save is pressed', async () => { + render(); + const consent = await screen.findByRole('switch', { name: 'Send anonymous heartbeats' }); + fireEvent.click(consent); + expect(writes()).toEqual([]); + expect(screen.getByTestId('admin-save-bar')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(writes()).toEqual([ + expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }), + ])); + }); +}); diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx index 34fa74eb8..7e5a92e95 100644 --- a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx +++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx @@ -92,3 +92,45 @@ describe('admin unsaved changes guard (#1255)', () => { expect(cleanEvent.defaultPrevented).toBe(false); }); }); + +// Admin pages switch through pushState, so browser Back and the phone's back +// gesture are a same-document popstate. With unsaved edits they must ask too. +describe('admin unsaved changes guard on Back', () => { + const popped = () => new Promise((resolve) => window.addEventListener('popstate', () => resolve(), { once: true })); + const back = async () => { + const done = popped(); + window.history.back(); + await act(async () => { await done; }); + }; + + it('keeps the page and asks when Back is pressed with unsaved edits; Stay keeps guarding', async () => { + window.history.pushState(null, '', '/admin/policy'); + window.history.pushState(null, '', '/admin/telemetry'); + render(<>); + + await back(); + expect(window.location.pathname).toBe('/admin/telemetry'); + expect(screen.getByRole('alertdialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); + + // Back asks again: the page is still guarded. + await back(); + expect(window.location.pathname).toBe('/admin/telemetry'); + expect(screen.getByRole('alertdialog')).toBeTruthy(); + }); + + it('Leave goes back to the previous page', async () => { + window.history.pushState(null, '', '/admin/policy'); + window.history.pushState(null, '', '/admin/telemetry'); + render(<>); + + await back(); + const done = popped(); + fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + await act(async () => { await done; }); + expect(window.location.pathname).toBe('/admin/policy'); + expect(useAdminUnsavedStore.getState().dirty).toEqual({}); + }); +}); 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(''); @@ -326,9 +329,14 @@ export function BrandingPage() { async function handleDeleteDomain() { if (!selectedHost) return; - if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { - return; - } + const ok = await confirm({ + title: `Remove the branding for ${selectedHost}?`, + message: 'The settings for this domain are removed. Files uploaded for it stay on disk.', + confirmText: 'Remove', + cancelText: 'Cancel', + variant: 'destructive', + }); + if (!ok) return; const next = domainEntries.filter(e => e.host !== selectedHost); const res = await apiFetch('/api/admin/config', { method: 'PATCH', @@ -346,17 +354,22 @@ export function BrandingPage() { } } + // Another domain's settings replace the form: the page's own leave prompt + // asks first when this one holds edits. function handleScopeChange(host: string | null) { - if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; - setSelectedHost(host); - setEdits({}); - setMessage(null); + requestLeave(() => { + setSelectedHost(host); + setEdits({}); + setMessage(null); + }, 'branding'); } const hasEdits = Object.keys(edits).length > 0; const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); useAdminUnsavedChanges('branding', hasEdits); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); + const { dialogProps: confirmProps, confirm } = useConfirmDialog(); if (loading) { return ( @@ -418,7 +431,7 @@ export function BrandingPage() {
- setMessage(null)} /> +
diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx index 51d9d8e2b..0680c1915 100644 --- a/app/(main)/admin/_pages/extension-detail.tsx +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -15,11 +15,13 @@ 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'; import { useExtensionIcons } from './use-extension-icons'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; export interface ConfigField { type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; @@ -72,6 +74,7 @@ interface Props { * can turn it off, what it may do, and the versions kept for a rollback. */ export function ExtensionDetailPage({ kind, id }: Props) { + const { dialogProps: confirmProps, confirm } = useConfirmDialog(); const words = KIND_LABEL[kind]; const listHref = adminPath(kind === 'plugin' ? 'plugins' : 'themes'); const [entry, setEntry] = useState(null); @@ -81,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 () => { @@ -124,7 +127,12 @@ export function ExtensionDetailPage({ kind, id }: Props) { }, [schema, savedValues]); const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]); - useAdminUnsavedChanges(`${kind}-config`, dirty); + // On/off and "force on" wait for Save like the settings below. + const [availabilityDraft, setAvailabilityDraft] = useState<{ enabled?: boolean; forceEnabled?: boolean }>({}); + const shownForced = availabilityDraft.forceEnabled ?? !!entry?.forceEnabled; + const shownEnabled = shownForced || (availabilityDraft.enabled ?? !!entry?.enabled); + const availabilityDirty = !!entry && (shownEnabled !== entry.enabled || shownForced !== !!entry.forceEnabled); + useAdminUnsavedChanges(`${kind}-config`, dirty || availabilityDirty); async function saveConfig() { if (!schema) return; @@ -202,21 +210,32 @@ export function ExtensionDetailPage({ kind, id }: Props) { } } - async function setEnabled(enabled: boolean) { - if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev)); + async function saveAvailability(): Promise { + if (!entry) return true; + const body: Record = {}; + if (shownForced !== !!entry.forceEnabled) body.forceEnabled = shownForced; + if (shownEnabled !== entry.enabled) body.enabled = shownEnabled; + const ok = await patch(body, 'availability'); + if (ok) setEntry((prev) => (prev ? { ...prev, enabled: shownEnabled, forceEnabled: shownForced } : prev)); + setAvailabilityDraft({}); + return ok; } - async function setForced(forceEnabled: boolean) { - const body: Record = { forceEnabled }; - if (forceEnabled) body.enabled = true; - if (await patch(body, 'forced')) { - setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev)); - } + async function save() { + if (availabilityDirty && !(await saveAvailability())) return; + if (dirty) await saveConfig(); + else setMessage({ type: 'success', text: 'Saved' }); } async function rollback(version: string) { if (!entry) return; - if (!confirm(`Put v${version} of "${entry.name}" back in place of v${entry.version}? The copy kept on this server is used.`)) return; + const ok = await confirm({ + title: `Roll back to v${version}?`, + message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.`, + confirmText: 'Roll back', + cancelText: 'Cancel', + }); + if (!ok) return; setBusy(`rollback:${version}`); setMessage(null); try { @@ -239,7 +258,14 @@ export function ExtensionDetailPage({ kind, id }: Props) { async function remove() { if (!entry) return; - if (!confirm(`Remove "${entry.name}" for every user? Its kept versions are removed too; this cannot be undone.`)) return; + const ok = await confirm({ + title: `Remove "${entry.name}"?`, + message: 'It is removed for every user, with the versions kept on this server. This cannot be undone.', + confirmText: 'Remove', + cancelText: 'Cancel', + variant: 'destructive', + }); + if (!ok) return; setBusy('remove'); setMessage(null); const res = await apiFetch(words.endpoint, { @@ -310,8 +336,13 @@ export function ExtensionDetailPage({ kind, id }: Props) { actions={( <>
); diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index aae7b42f9..ee3866ab5 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -35,8 +35,10 @@ 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'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -129,13 +131,14 @@ const tdClass = 'px-4 py-2 align-top'; const smallButtonClass = 'h-8 gap-1 text-xs'; export function MarketplaceDetailPage({ slug }: { slug: string }) { + const { dialogProps: confirmProps, confirm } = useConfirmDialog(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); 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 @@ -184,7 +187,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { const isUpdate = data.installed; setBusy(`install:${version}`); setMessage(null); - const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type }); + const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type }, confirm); setBusy(null); if (!result.ok) { setMessage({ type: 'error', text: result.error }); @@ -203,7 +206,13 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { async function handleRollback(version: string) { if (!data) return; - if (!confirm(`Put v${version} of "${data.extension.name}" back in place of v${data.installedVersion}? The copy kept on this server is used; the directory is not asked.`)) return; + const ok = await confirm({ + title: `Roll back to v${version}?`, + message: `"${data.extension.name}" v${version} replaces v${data.installedVersion}. The copy kept on this server is used; the directory is not asked.`, + confirmText: 'Roll back', + cancelText: 'Cancel', + }); + if (!ok) return; setBusy(`rollback:${version}`); setMessage(null); try { @@ -228,7 +237,14 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { async function handleUninstall() { if (!data) return; - if (!confirm(`Remove "${data.extension.name}"? Its kept versions are removed too; this cannot be undone.`)) return; + const ok = await confirm({ + title: `Remove "${data.extension.name}"?`, + message: 'The versions kept on this server are removed too. This cannot be undone.', + confirmText: 'Remove', + cancelText: 'Cancel', + variant: 'destructive', + }); + if (!ok) return; setUninstalling(true); setMessage(null); @@ -413,7 +429,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
)} - setMessage(null)} /> +
{data.requestedVersionMissing && ( diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx index a58925975..e2b7b3332 100644 --- a/app/(main)/admin/_pages/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -18,8 +18,10 @@ 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'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -78,12 +80,19 @@ const pillClass = (active: boolean) => cn( /** * Installs one version of an extension. A version the publisher withdrew, * or one older than the installed version, comes back as 409; the admin is - * asked, and the install is repeated with `force` if they insist. Returns - * the error text to show, or null. + * asked (in the page's ConfirmDialog), and the install is repeated with + * `force` if they insist. Returns the error text to show, or null. */ +export interface InstallQuestion { + title: string; + message: string; + confirmText: string; + cancelText: string; +} + export async function installVersion( body: { slug: string; version: string; type: 'plugin' | 'theme' }, - confirm: (message: string) => boolean = (m) => window.confirm(m), + confirm: (question: InstallQuestion) => boolean | Promise, ): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> { const send = (force: boolean) => apiFetch('/api/admin/marketplace', { method: 'POST', @@ -95,10 +104,14 @@ export async function installVersion( if (res.status === 409) { const data = await res.json().catch(() => ({})); if (data.yanked) { - if (!confirm(`${data.error}\n\nInstall it anyway?`)) return { ok: false, error: 'Not installed.' }; + if (!(await confirm({ title: 'Install it anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) { + return { ok: false, error: 'Not installed.' }; + } res = await send(true); } else if (data.downgrade) { - if (!confirm(`${data.error}\n\nInstall the older version anyway?`)) return { ok: false, error: 'Not installed.' }; + if (!(await confirm({ title: 'Install the older version anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) { + return { ok: false, error: 'Not installed.' }; + } res = await send(true); } else { return { ok: false, error: data.error || 'Installation failed' }; @@ -113,6 +126,7 @@ export async function installVersion( } export function MarketplacePage() { + const { dialogProps: confirmProps, confirm } = useConfirmDialog(); const [extensions, setExtensions] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); @@ -125,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); @@ -194,7 +208,7 @@ export function MarketplacePage() { async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean) { markInstalling(ext.slug, true); setMessage(null); - const result = await installVersion({ slug: ext.slug, version, type: ext.type }); + const result = await installVersion({ slug: ext.slug, version, type: ext.type }, confirm); markInstalling(ext.slug, false); if (!result.ok) { setMessage({ type: 'error', text: result.error }); @@ -256,7 +270,7 @@ export function MarketplacePage() { )} /> - setMessage(null)} /> +
{updates && noticeCount > 0 && ( diff --git a/app/(main)/admin/_pages/plugins.tsx b/app/(main)/admin/_pages/plugins.tsx index 536b4276f..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,8 +26,9 @@ export function PluginsPage() { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); - const [bulkBusy, setBulkBusy] = 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); const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY }); @@ -61,16 +62,18 @@ export function PluginsPage() { const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true; const requirePluginApproval = features.requirePluginApproval ?? true; const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features); - useAdminUnsavedChanges('plugins', policyDirty); + const isOn = (p: ExtensionEntry) => enabledDraft[p.id] ?? p.enabled; + const switched = plugins.filter((p) => isOn(p) !== p.enabled); + const dirty = policyDirty || switched.length > 0; + useAdminUnsavedChanges('plugins', dirty); function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) { setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } })); setMessage(null); } - async function savePolicy() { - setSavingPolicy(true); - setMessage(null); + /** Stores the policy; returns the error text, or null. */ + async function savePolicy(): Promise { try { const res = await apiFetch('/api/admin/policy', { method: 'PUT', @@ -79,18 +82,34 @@ export function PluginsPage() { }); if (res.ok) { setSavedPolicy(policy); - setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' }); - } else { - const data = await res.json().catch(() => ({})); - setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' }); + return null; } + const data = await res.json().catch(() => ({})); + return data.error || 'Could not save the plugin policy'; } catch { - setMessage({ type: 'error', text: 'Could not save the plugin policy' }); - } finally { - setSavingPolicy(false); + return 'Could not save the plugin policy'; } } + async function save() { + setSavingPolicy(true); + setMessage(null); + const errors: string[] = []; + for (const p of switched) { + const error = await setEnabled(p.id, isOn(p)); + if (error) errors.push(`${p.name}: ${error}`); + } + setEnabledDraft({}); + if (policyDirty) { + const error = await savePolicy(); + if (error) errors.push(error); + } + setSavingPolicy(false); + setMessage(errors.length > 0 + ? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` } + : { type: 'success', text: 'Plugins saved. Users see the change on their next login.' }); + } + async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -116,7 +135,8 @@ export function PluginsPage() { } } - async function setEnabled(id: string, enabled: boolean): Promise { + /** Turns one plugin on or off on the server; returns the error text, or null. */ + async function setEnabled(id: string, enabled: boolean): Promise { const res = await apiFetch('/api/admin/plugins', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -124,30 +144,18 @@ export function PluginsPage() { }); if (res.ok) { setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p))); - return true; + return null; } const data = await res.json().catch(() => ({})); - setMessage({ type: 'error', text: data.error || 'Could not change the plugin' }); - return false; + return data.error || 'Could not change the plugin'; } - async function setAll(enabled: boolean) { - const targets = plugins.filter((p) => p.enabled !== enabled); - if (targets.length === 0) return; - setBulkBusy(true); + function setAll(enabled: boolean) { + setEnabledDraft(Object.fromEntries(plugins.map((p) => [p.id, enabled]))); setMessage(null); - let failed = 0; - for (const p of targets) { - if (!(await setEnabled(p.id, enabled))) failed++; - } - await fetchPlugins(); - setBulkBusy(false); - setMessage(failed === 0 - ? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` } - : { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` }); } - const enabledCount = plugins.filter((p) => p.enabled).length; + const enabledCount = plugins.filter(isOn).length; return (
@@ -176,7 +184,6 @@ export function PluginsPage() { )} /> - setMessage(null)} />
@@ -201,10 +208,10 @@ export function PluginsPage() { <> {plugins.length > 1 && (
- -
@@ -244,8 +251,9 @@ export function PluginsPage() { )} control={( { setMessage(null); void setEnabled(plugin.id, checked); }} + checked={isOn(plugin)} + disabled={savingPolicy} + onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [plugin.id]: checked })); }} ariaLabel={`${plugin.name} enabled`} /> )} @@ -274,11 +282,11 @@ export function PluginsPage() {
setPolicy(savedPolicy)} - saveLabel="Save policy" + onSave={() => void save()} + onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }} + saveLabel="Save" note="Users see the change on their next login" />
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 a745ac45c..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,10 +45,15 @@ 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); const endpointDirty = status !== null && endpointDraft !== status.endpoint; - useAdminUnsavedChanges('telemetry', endpointDirty); + const consentDirty = status !== null && consentDraft !== null && consentDraft !== status.consent; + const dirty = endpointDirty || consentDirty; + useAdminUnsavedChanges('telemetry', dirty); async function refresh(): Promise { setLoading(true); @@ -66,38 +71,35 @@ export function TelemetryPage() { } useEffect(() => { void refresh(); }, []); - async function setConsent(consent: 'on' | 'off'): Promise { - setBusy('consent'); - setMessage(null); - try { - const r = await apiFetch('/api/admin/telemetry', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'set-consent', consent }), - }); - if (!r.ok) { - const j = (await r.json().catch(() => ({}))) as { error?: string }; - setMessage({ type: 'error', text: j.error ?? 'failed' }); - } - await refresh(); - } finally { setBusy(null); } + /** Sends one change; returns the error text, or null. */ + async function post(body: Record): Promise { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.ok) return null; + const j = (await r.json().catch(() => ({}))) as { error?: string }; + return j.error ?? 'failed'; } - async function saveEndpoint(): Promise { - setBusy('endpoint'); + async function save(): Promise { + setBusy('save'); setMessage(null); try { - const r = await apiFetch('/api/admin/telemetry', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), - }); - if (!r.ok) { - const j = (await r.json().catch(() => ({}))) as { error?: string }; - setMessage({ type: 'error', text: j.error ?? 'failed' }); - } else { - setMessage({ type: 'success', text: 'Endpoint saved.' }); + const errors: string[] = []; + if (consentDirty && consentDraft) { + const error = await post({ action: 'set-consent', consent: consentDraft }); + if (error) errors.push(`Heartbeats: ${error}`); } + if (endpointDirty) { + const error = await post({ action: 'set-endpoint', endpoint: endpointDraft }); + if (error) errors.push(`Endpoint: ${error}`); + } + setConsentDraft(null); + setMessage(errors.length > 0 + ? { type: 'error', text: `Couldn't save: ${errors.join('; ')}` } + : { type: 'success', text: 'Telemetry settings saved.' }); await refresh(); } finally { setBusy(null); } } @@ -134,6 +136,7 @@ export function TelemetryPage() { const envOverridden = status.consentSource === 'env'; const isOn = status.consent === 'on'; + const shownOn = (consentDraft ?? status.consent) === 'on'; const consentDescription = [ status.consent === 'pending' && 'Initialising - no heartbeats sent yet.', @@ -146,7 +149,6 @@ export function TelemetryPage() {
{header} - setMessage(null)} />
@@ -167,9 +169,9 @@ export function TelemetryPage() { void setConsent(checked ? 'on' : 'off')} + checked={shownOn} + disabled={busy === 'save' || envOverridden} + onChange={(checked) => { setConsentDraft(checked ? 'on' : 'off'); setMessage(null); }} /> @@ -240,11 +242,11 @@ export function TelemetryPage() {
void saveEndpoint()} - onDiscard={() => setEndpointDraft(status.endpoint)} - saveLabel="Save endpoint" + dirty={dirty} + saving={busy === 'save'} + onSave={() => void save()} + onDiscard={() => { setEndpointDraft(status.endpoint); setConsentDraft(null); }} + saveLabel="Save" />
); diff --git a/app/(main)/admin/_pages/themes.tsx b/app/(main)/admin/_pages/themes.tsx index e50896f7a..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,8 +42,9 @@ export function ThemesPage() { const iconFor = useExtensionIcons(); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); - const [bulkBusy, setBulkBusy] = 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); const [policy, setPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY })); const [savedPolicy, setSavedPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY })); @@ -74,7 +75,10 @@ export function ThemesPage() { const policyDirty = JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) || JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features); - useAdminUnsavedChanges('themes', policyDirty); + const isOn = (t: ExtensionEntry) => enabledDraft[t.id] ?? t.enabled; + const switched = themes.filter((t) => isOn(t) !== t.enabled); + const dirty = policyDirty || switched.length > 0; + useAdminUnsavedChanges('themes', dirty); function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) { setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } })); @@ -141,9 +145,8 @@ export function ThemesPage() { setMessage(null); } - async function savePolicy() { - setSavingPolicy(true); - setMessage(null); + /** Stores the policy; returns the error text, or null. */ + async function savePolicy(): Promise { try { const res = await apiFetch('/api/admin/policy', { method: 'PUT', @@ -152,18 +155,34 @@ export function ThemesPage() { }); if (res.ok) { setSavedPolicy(policy); - setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' }); - } else { - const data = await res.json().catch(() => ({})); - setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' }); + return null; } + const data = await res.json().catch(() => ({})); + return data.error || 'Could not save the theme policy'; } catch { - setMessage({ type: 'error', text: 'Could not save the theme policy' }); - } finally { - setSavingPolicy(false); + return 'Could not save the theme policy'; } } + async function save() { + setSavingPolicy(true); + setMessage(null); + const errors: string[] = []; + for (const t of switched) { + const error = await setEnabled(t.id, isOn(t)); + if (error) errors.push(`${t.name}: ${error}`); + } + setEnabledDraft({}); + if (policyDirty) { + const error = await savePolicy(); + if (error) errors.push(error); + } + setSavingPolicy(false); + setMessage(errors.length > 0 + ? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` } + : { type: 'success', text: 'Themes saved. Users see the change on their next login.' }); + } + async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -189,7 +208,8 @@ export function ThemesPage() { } } - async function setEnabled(id: string, enabled: boolean): Promise { + /** Turns one theme on or off on the server; returns the error text, or null. */ + async function setEnabled(id: string, enabled: boolean): Promise { const res = await apiFetch('/api/admin/themes', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -197,30 +217,18 @@ export function ThemesPage() { }); if (res.ok) { setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t))); - return true; + return null; } const data = await res.json().catch(() => ({})); - setMessage({ type: 'error', text: data.error || 'Could not change the theme' }); - return false; + return data.error || 'Could not change the theme'; } - async function setAll(enabled: boolean) { - const targets = themes.filter(t => t.enabled !== enabled); - if (targets.length === 0) return; - setBulkBusy(true); + function setAll(enabled: boolean) { + setEnabledDraft(Object.fromEntries(themes.map((t) => [t.id, enabled]))); setMessage(null); - let failed = 0; - for (const t of targets) { - if (!(await setEnabled(t.id, enabled))) failed++; - } - await fetchThemes(); - setBulkBusy(false); - setMessage(failed === 0 - ? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` } - : { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` }); } - const enabledCount = themes.filter(t => t.enabled).length; + const enabledCount = themes.filter(isOn).length; const themesEnabled = policy.features.themesEnabled ?? true; const userThemesEnabled = policy.features.userThemesEnabled ?? true; const lockColorMode = policy.themePolicy?.lockColorMode === true; @@ -254,7 +262,6 @@ export function ThemesPage() { )} /> - setMessage(null)} />
{themes.length > 1 && (
- -
@@ -310,8 +317,9 @@ export function ThemesPage() { )} control={( { setMessage(null); void setEnabled(theme.id, checked); }} + checked={isOn(theme)} + disabled={savingPolicy} + onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [theme.id]: checked })); }} ariaLabel={`${theme.name} enabled`} /> )} @@ -392,11 +400,11 @@ export function ThemesPage() {
setPolicy(savedPolicy)} - saveLabel="Save policy" + onSave={() => void save()} + onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }} + saveLabel="Save" note="Users see the change on their next login" />
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx index e9dcd6d0c..102962781 100644 --- a/app/(main)/admin/_pages/version.tsx +++ b/app/(main)/admin/_pages/version.tsx @@ -13,7 +13,7 @@ import { SettingsSection, SettingItem } from '@/components/settings/settings-sec import { Button } from '@/components/ui/button'; import { AdminPageHeader } from '@/components/admin/page-header'; import { Badge, type BadgeTone } from '@/components/admin/badge'; -import { Flash, useFlash } from '@/components/admin/flash-message'; +import { useAdminFeedback } from '@/components/admin/feedback'; import { adminPageDef } from '@/lib/admin/admin-nav'; import { apiFetch } from '@/lib/browser-navigation'; import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; @@ -64,7 +64,7 @@ export function VersionPage() { const [data, setData] = useState(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/_unsaved-changes-guard.tsx b/app/(main)/admin/_unsaved-changes-guard.tsx index ed07cfa58..bb1a63124 100644 --- a/app/(main)/admin/_unsaved-changes-guard.tsx +++ b/app/(main)/admin/_unsaved-changes-guard.tsx @@ -1,16 +1,42 @@ '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 { useEffect, useRef } from 'react'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store'; +// Marks the history entry the guard puts on top of a page with unsaved +// edits. It carries a copy of the page entry's own state, so Next's router +// treats it as that page. +const GUARD_KEY = '__adminUnsavedGuard'; + +function onGuardEntry(): boolean { + const state = window.history.state as Record | null; + return !!state && state[GUARD_KEY] === true; +} + +// Marks the page's own entry under the copy, so Back onto it is told apart +// from a later visit to the same page. +const GUARDED_KEY = '__adminUnsavedGuarded'; + +/** Puts the copy on top; returns the mark left on the page's entry under it. */ +function pushGuardEntry(): string { + const state = (window.history.state as Record | null) ?? {}; + const mark = `${Date.now()}-${Math.random()}`; + window.history.replaceState({ ...state, [GUARDED_KEY]: mark }, '', window.location.href); + window.history.pushState({ ...state, [GUARDED_KEY]: mark, [GUARD_KEY]: true }, '', window.location.href); + return mark; +} + +function guardedMark(): unknown { + const state = window.history.state as Record | null; + return state?.[GUARDED_KEY]; +} + /** * 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. + * the page are caught here, so are browser Back and the phone's back + * gesture, and closing or reloading the tab gets the browser's own prompt. */ export function UnsavedChangesGuard() { const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved); @@ -19,22 +45,39 @@ export function UnsavedChangesGuard() { 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. + // Back and the back gesture are a same-document popstate (the pages switch + // through pushState), which can't be cancelled. So while something is + // unsaved a copy of the page's entry sits on top: Back only takes that + // one off, the page stays, and the prompt asks. Stay puts the copy back; + // Leave goes back past the page for real. + // The mark on the page entry the copy was put on top of. + const guard = useRef(null); useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - e.preventDefault(); - e.stopPropagation(); - cancelLeave(); + if (!hasUnsaved || onGuardEntry()) return; + guard.current = pushGuardEntry(); + }, [hasUnsaved]); + + useEffect(() => { + const onPopState = () => { + // Landing on the page's own entry under the copy: Back took the copy off. + if (onGuardEntry() || !guard.current || guardedMark() !== guard.current) return; + if (!selectHasUnsaved(useAdminUnsavedStore.getState())) { + // A copy left over from edits saved or discarded since: Back goes + // on as asked. + guard.current = null; + window.history.back(); + return; + } + guard.current = pushGuardEntry(); + useAdminUnsavedStore.getState().requestLeave(() => { + guard.current = null; + window.history.go(-2); + }); }; - document.addEventListener('keydown', onKeyDown, true); - return () => document.removeEventListener('keydown', onKeyDown, true); - }, [isOpen, cancelLeave]); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); useEffect(() => { if (!hasUnsaved) return; @@ -76,39 +119,16 @@ export function UnsavedChangesGuard() { return () => document.removeEventListener('click', onClick, true); }, [hasUnsaved, requestLeave]); - if (!isOpen) return null; - return ( -
{ if (e.target === e.currentTarget) cancelLeave(); }} - > -
-
-
- -
-
-

- Unsaved changes -

-

- You have changes that haven't been saved. If you leave now, they will be lost. -

-
-
-
- - -
-
-
+ ); } 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/app/(main)/setup/__tests__/setup-i18n.test.tsx b/app/(main)/setup/__tests__/setup-i18n.test.tsx new file mode 100644 index 000000000..49754e19a --- /dev/null +++ b/app/(main)/setup/__tests__/setup-i18n.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import de from '@/locales/de/common.json'; +import en from '@/locales/en/common.json'; + +// The setup wizard follows the browser's language like the login page, +// instead of being English only. + +vi.unmock('next-intl'); +vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) })); +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async () => new Response(JSON.stringify({ state: 'bootstrap', authenticated: false, readOnly: false, partialConfig: null }), { + status: 200, headers: { 'content-type': 'application/json' }, + })), + getPathPrefix: () => '', + withBasePath: (p: string) => p, +})); +vi.mock('@/lib/setup/url-token', () => ({ takeSetupTokenFromUrl: () => '' })); + +const { default: SetupWizardPage } = await import('../page'); + +function renderIn(locale: string, messages: Record) { + return render( + + + , + ); +} + +describe('setup wizard language', () => { + it('shows the welcome step in German', async () => { + renderIn('de', de); + expect(await screen.findByText('Einrichtungstoken')).toBeTruthy(); + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Einrichtung von Bulwark Webmail'); + expect(screen.getByRole('button', { name: 'Fortfahren' })).toBeTruthy(); + expect(screen.queryByText('Setup token')).toBeNull(); + }); + + it('shows it in English for English', async () => { + renderIn('en', en); + expect(await screen.findByText('Setup token')).toBeTruthy(); + expect(screen.getByText('Server')).toBeTruthy(); + }); +}); diff --git a/app/(main)/setup/layout.tsx b/app/(main)/setup/layout.tsx index 61e6465b3..bd7f7b9f8 100644 --- a/app/(main)/setup/layout.tsx +++ b/app/(main)/setup/layout.tsx @@ -1,5 +1,25 @@ import type { ReactNode } from 'react'; +import { getLocale } from 'next-intl/server'; +import { IntlProvider } from '@/components/providers/intl-provider'; +import { defaultLocale } from '@/i18n/routing'; -export default function SetupLayout({ children }: { children: ReactNode }) { - return
{children}
; +/** + * /setup sits outside the [locale] tree, so it loads the message catalog + * itself, like /admin. The locale comes from the request (cookie or + * Accept-Language) and the browser's language takes over on the client, + * as on the login page. + */ +export default async function SetupLayout({ children }: { children: ReactNode }) { + const locale = await getLocale(); + let messages; + try { + messages = (await import(`@/locales/${locale}/common.json`)).default; + } catch { + messages = (await import(`@/locales/${defaultLocale}/common.json`)).default; + } + return ( + +
{children}
+
+ ); } diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index af8de969c..f956fdf08 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons'; import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; import { takeSetupTokenFromUrl } from '@/lib/setup/url-token'; @@ -82,17 +83,25 @@ const EMPTY_CONFIG: WizardConfig = { loginWebsiteUrl: '', }; +// The wizard follows the browser's language, like the login page; the +// labels are setup.steps.. const STEPS = [ - { id: 'welcome', label: 'Welcome' }, - { id: 'server', label: 'Server' }, - { id: 'auth', label: 'Auth' }, - { id: 'security', label: 'Security' }, - { id: 'logging', label: 'Logging' }, - { id: 'branding', label: 'Branding' }, - { id: 'review', label: 'Review' }, + { id: 'welcome' }, + { id: 'server' }, + { id: 'auth' }, + { id: 'security' }, + { id: 'logging' }, + { id: 'branding' }, + { id: 'review' }, ] as const; +type SetupT = ReturnType>; + export default function SetupWizardPage() { + const t = useTranslations('setup'); + // For the one-time status load: a language switch must not rerun it. + const tRef = useRef(t); + tRef.current = t; const router = useRouter(); const [tokenFromUrl, setTokenFromUrl] = useState(''); @@ -145,7 +154,7 @@ export default function SetupWizardPage() { } } } catch (e) { - if (!cancelled) setError(humanError(e)); + if (!cancelled) setError(humanError(e, tRef.current)); } finally { if (!cancelled) setBootstrapping(false); } @@ -165,7 +174,7 @@ export default function SetupWizardPage() { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`); + throw new Error(data.error ?? t('errors.token_rejected', { status: res.status })); } setAuthenticated(true); setStepIndex(1); @@ -180,7 +189,7 @@ export default function SetupWizardPage() { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`); + throw new Error(data.error ?? t('errors.step_failed', { status: res.status })); } } @@ -190,7 +199,7 @@ export default function SetupWizardPage() { } if (bootstrapping) { - return

Loading…

; + return

{t('loading')}

; } if (completed) { @@ -204,10 +213,9 @@ export default function SetupWizardPage() { if (readOnly) { return ( -

Configuration is read-only

+

{t('read_only.title')}

- The config volume is mounted read-only or ADMIN_CONFIG_READONLY is set. - Remount it read-write or unset that variable, then restart the container. + {t('read_only.body', { variable: 'ADMIN_CONFIG_READONLY' })}

); @@ -225,11 +233,11 @@ export default function SetupWizardPage() { {!authenticated ? ( { + onSubmit={async (token) => { try { - await submitToken(t); + await submitToken(token); } catch (e) { - setError(humanError(e)); + setError(humanError(e, t)); } }} /> @@ -243,7 +251,7 @@ export default function SetupWizardPage() { await saveStep(step, values); setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)); } catch (e) { - const msg = humanError(e); + const msg = humanError(e, t); setError(msg); // Session expired mid-flow - kick the user back to the // welcome step so they can re-enter the token without @@ -276,17 +284,19 @@ export default function SetupWizardPage() { // ─── Layout helpers ─────────────────────────────────────────────────────── function Header() { + const t = useTranslations('setup'); return (
-

Bulwark Webmail Setup

+

{t('title')}

- Configure your webmail instance from the browser. + {t('subtitle')}

); } function ProgressBar({ stepIndex }: { stepIndex: number }) { + const t = useTranslations('setup'); return (
@@ -310,7 +320,7 @@ function ProgressBar({ stepIndex }: { stepIndex: number }) { : 'text-muted-foreground') } > - {step.label} + {t(`steps.${step.id}`)}
))} @@ -320,6 +330,7 @@ function ProgressBar({ stepIndex }: { stepIndex: number }) { } function CompletedScreen() { + const t = useTranslations('setup'); return (
@@ -337,9 +348,9 @@ function CompletedScreen() {
-

You're all set!

+

{t('done.title')}

- Bulwark Webmail is configured and ready to use. + {t('done.body', { appName: 'Bulwark Webmail' })}

@@ -347,23 +358,24 @@ function CompletedScreen() { href={`${getPathPrefix()}/admin/login`} className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90" > - Sign in to admin dashboard + {t('done.admin_sign_in')} - Open webmail login + {t('done.webmail_login')}

- Taking you to the admin dashboard… + {t('done.redirecting')}

); } function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { + const t = useTranslations('setup'); const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` @@ -374,10 +386,9 @@ function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
-

You're running setup over plain HTTP

+

{t('insecure.title')}

- The setup token and admin password you enter here will travel in cleartext. - Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it. + {t('insecure.body')}

@@ -386,7 +397,7 @@ function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { href={httpsUrl} className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90" > - Try HTTPS + {t('insecure.try_https')} )}
@@ -402,6 +413,7 @@ function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { } function AlreadyConfiguredScreen() { + const t = useTranslations('setup'); return (
@@ -419,9 +431,9 @@ function AlreadyConfiguredScreen() {
-

Setup is already complete

+

{t('already.title')}

- Bulwark Webmail is configured. Sign in to continue. + {t('already.body', { appName: 'Bulwark Webmail' })}

@@ -429,13 +441,13 @@ function AlreadyConfiguredScreen() { href={`${getPathPrefix()}/admin/login`} className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90" > - Sign in to admin dashboard + {t('done.admin_sign_in')} - Open webmail login + {t('done.webmail_login')}
@@ -453,20 +465,21 @@ function CenteredCard({ children }: { children: ReactNode }) { } function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) { + const t = useTranslations('setup'); return (
-

{friendlyError(error)}

+

{friendlyError(error, t)}

); @@ -476,26 +489,19 @@ function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => voi * Translate raw API error strings into user-facing copy. Matches the friendly * tone of the JMAP probe cards. */ -function friendlyError(raw: string): string { +function friendlyError(raw: string, t: SetupT): string { const lower = raw.toLowerCase(); - if (lower.includes('invalid or expired token')) { - return "That setup token isn't valid anymore. Restart the container to get a fresh one from the logs."; - } - if (lower.includes('wizard session required')) { - return 'Your wizard session expired. Paste the setup token again to continue.'; - } - if (lower.includes('token required')) { - return 'Paste the setup token printed in the container logs to continue.'; - } - if (lower.includes('setup is not active')) { - return 'Setup has already finished. Reload to sign in.'; - } + if (lower.includes('invalid or expired token')) return t('errors.token_expired'); + if (lower.includes('wizard session required')) return t('errors.session_expired'); + if (lower.includes('token required')) return t('errors.token_required'); + if (lower.includes('setup is not active')) return t('errors.already_finished'); return raw; } // ─── Welcome / token step ──────────────────────────────────────────────── function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) { + const t = useTranslations('setup'); const [token, setToken] = useState(tokenFromUrl); const [submitting, setSubmitting] = useState(false); @@ -521,15 +527,15 @@ function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmi return ( -

Welcome

+

{t('welcome.title')}

- Paste the setup token printed in the container logs to continue. The token expires after 1 hour. + {t('welcome.body')}

- - setToken(v)} autoFocus required placeholder="32-byte hex token" /> + + setToken(v)} autoFocus required placeholder={t('welcome.token_placeholder')} /> - {submitting ? 'Verifying…' : 'Continue'} + {submitting ? t('welcome.verifying') : t('welcome.continue')} ); @@ -547,6 +553,7 @@ interface StepProps { } function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) { + const t = useTranslations('setup'); switch (stepIndex) { case 1: return ; @@ -561,7 +568,7 @@ function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: case 6: return ; default: - return

Loading…

; + return

{t('loading')}

; } } @@ -570,6 +577,7 @@ function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url'; function ServerStep({ config, setConfig, onNext }: Pick) { + const t = useTranslations('setup'); const [submitting, setSubmitting] = useState(false); const [probe, setProbe] = useState<{ status: ProbeStatus; message: string; url: string } | null>(null); const [probing, setProbing] = useState(false); @@ -591,18 +599,18 @@ function ServerStep({ config, setConfig, onNext }: Pick 0; @@ -701,11 +709,11 @@ function ServerStep({ config, setConfig, onNext }: Pick - - + + setConfig({ ...config, appName: v })} required /> - +
- {probing ? 'Testing…' : 'Test'} + {probing ? t('server.testing') : t('server.test')}
{isInsecureHttpUrl(config.jmapServerUrl) && ( @@ -737,10 +745,10 @@ function ServerStep({ config, setConfig, onNext }: Pick

- This URL uses plain HTTP. + {t('server.http_title')}

- Passwords and email contents will travel unencrypted between users and your server. Use https:// in production - terminate TLS on the mail server or a reverse proxy in front of it. + {t('server.http_body', { https: 'https://' })}

@@ -752,10 +760,10 @@ function ServerStep({ config, setConfig, onNext }: Pick

- This URL only resolves locally. + {t('server.local_title')}

- Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. https://mail.example.com) in production. + {t('server.local_body', { example: 'https://mail.example.com' })}

@@ -779,7 +787,7 @@ function ServerStep({ config, setConfig, onNext }: Pick

{probe.message}

- This can happen when a reverse proxy routes JMAP separately on the same domain. Otherwise, it usually means the URL is wrong. + {t('server.not_jmap_hint')}

@@ -790,7 +798,7 @@ function ServerStep({ config, setConfig, onNext }: Pick setConfirmedNonJmap(e.target.checked)} className="h-4 w-4" /> - I'm sure this is the right URL - continue anyway. + {t('server.not_jmap_confirm')}
) : ( @@ -810,9 +818,9 @@ function ServerStep({ config, setConfig, onNext }: Pick
-
Additional JMAP servers
+
{t('server.additional_title')}

- Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances. + {t('server.additional_hint')}

@@ -829,27 +837,27 @@ function ServerStep({ config, setConfig, onNext }: Pick (
- Server #{i + 1} + {t('server.row_title', { index: i + 1 })}
- + updateRow(i, { id: v })} placeholder="eu-1" required /> - + updateRow(i, { label: v })} placeholder="Europe (primary)" />
- + updateRow(i, { url: v })} placeholder="https://" type="url" required /> - + updateRow(i, { domains: v })} placeholder="example.com, mail.example.com" />
@@ -860,15 +868,15 @@ function ServerStep({ config, setConfig, onNext }: Pick - + Add server + + {t('server.add_server')} {config.jmapServers.length > 0 && ( setConfig({ ...config, jmapServerAutoPickByDomain: v })} - label="Auto-pick server by email domain" - hint="When a user types their email, automatically select the matching server from the list above." + label={t('server.auto_pick')} + hint={t('server.auto_pick_hint')} /> )} @@ -886,8 +894,8 @@ function ServerStep({ config, setConfig, onNext }: Pick setConfig({ ...config, stalwartFeaturesEnabled: v })} - label="Enable Stalwart-specific features" - hint="Adds password change and Sieve filter management. Safe to enable on non-Stalwart servers." + label={t('server.stalwart')} + hint={t('server.stalwart_hint')} />
@@ -905,7 +913,7 @@ function ServerStep({ config, setConfig, onNext }: Pick - {submitting ? 'Saving…' : probing ? 'Testing…' : 'Next'} + {submitting ? t('saving') : probing ? t('server.testing') : t('next')}
@@ -915,6 +923,7 @@ function ServerStep({ config, setConfig, onNext }: Pick) { + const t = useTranslations('setup'); const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { @@ -938,39 +947,39 @@ function AuthStep({ config, setConfig, onNext, onBack }: Pick - + setConfig({ ...config, oauthEnabled: v })} - label="Enable OAuth2 / OpenID Connect" + label={t('auth.oauth')} /> {config.oauthEnabled && ( <> setConfig({ ...config, oauthOnly: v })} - label="OAuth-only mode (hide password form)" + label={t('auth.oauth_only')} /> - + setConfig({ ...config, oauthClientId: v })} required /> - + setConfig({ ...config, oauthClientSecret: v })} type="password" - placeholder="paste secret" + placeholder={t('auth.client_secret_placeholder')} /> - + setConfig({ ...config, oauthIssuerUrl: v })} type="url" /> )}
- Back + {t('back')} - {submitting ? 'Saving…' : 'Next'} + {submitting ? t('saving') : t('next')}
@@ -989,6 +998,7 @@ function generateSessionSecret(): string { } function SecurityStep({ config, setConfig, onNext, onBack }: Pick) { + const t = useTranslations('setup'); const [submitting, setSubmitting] = useState(false); const [reveal, setReveal] = useState(false); const [customize, setCustomize] = useState(false); @@ -1023,28 +1033,28 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick
- Session secret generated + {t('security.generated')}

- A 32-byte secret was created for you. You only need to change this if you have a specific reason. + {t('security.generated_hint')}

{customize && ( - +
setReveal((v) => !v)} className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted" > - {reveal ? 'Hide' : 'Show'} + {reveal ? t('hide') : t('show')}
@@ -1072,8 +1082,8 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick setConfig({ ...config, settingsSyncEnabled: v })} - label="Sync user settings across devices" - hint="Stores user preferences server-side, encrypted with the session secret." + label={t('security.sync')} + hint={t('security.sync_hint')} disabled={!config.sessionSecret} /> @@ -1081,14 +1091,14 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick setConfig({ ...config, telemetryEnabled: v })} - label="Send anonymous usage stats to help improve Bulwark" - hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings." + label={t('security.telemetry')} + hint={t('security.telemetry_hint')} />
- Back + {t('back')} - {submitting ? 'Saving…' : 'Next'} + {submitting ? t('saving') : t('next')}
@@ -1098,6 +1108,7 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick) { + const t = useTranslations('setup'); const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { e.preventDefault(); @@ -1110,33 +1121,33 @@ function LoggingStep({ config, setConfig, onNext, onBack }: Pick - - + + setConfig({ ...config, logLevel: v as WizardConfig['logLevel'] })} options={[ { value: 'error', label: 'error' }, { value: 'warn', label: 'warn' }, - { value: 'info', label: 'info (recommended)' }, + { value: 'info', label: t('logging.level_info') }, { value: 'debug', label: 'debug' }, ]} />
- Back + {t('back')} - {submitting ? 'Saving…' : 'Next'} + {submitting ? t('saving') : t('next')}
@@ -1153,6 +1164,7 @@ type BrandingSlot = | 'loginLogoDarkUrl'; function BrandingStep({ config, setConfig, onNext, onBack }: Pick) { + const t = useTranslations('setup'); const [submitting, setSubmitting] = useState(false); async function handle(e: FormEvent) { @@ -1186,46 +1198,46 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick - + setConfig({ ...config, loginCompanyName: v })} />
setConfig({ ...config, faviconUrl: v })} /> setConfig({ ...config, loginLogoLightUrl: v })} /> setConfig({ ...config, loginLogoDarkUrl: v })} previewBg="dark" /> setConfig({ ...config, appLogoLightUrl: v })} /> setConfig({ ...config, appLogoDarkUrl: v })} @@ -1233,19 +1245,19 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick
- + setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> - + setConfig({ ...config, loginImprintUrl: v })} type="url" /> - + setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" />
- Back + {t('back')} - {submitting ? 'Saving…' : 'Next'} + {submitting ? t('saving') : t('next')}
@@ -1273,6 +1285,7 @@ function BrandingAsset({ onChange: (v: string) => void; previewBg?: 'light' | 'dark'; }) { + const t = useTranslations('setup'); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [showUrlField, setShowUrlField] = useState(false); @@ -1291,12 +1304,12 @@ function BrandingAsset({ }); const data = await res.json(); if (!res.ok) { - setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`); + setUploadError(data?.error ?? t('errors.upload_failed', { status: res.status })); return; } onChange(data.url); } catch (e) { - setUploadError(humanError(e)); + setUploadError(humanError(e, t)); } finally { setUploading(false); } @@ -1348,7 +1361,7 @@ function BrandingAsset({ {value ? ( ) : ( - click or drop + {t('branding.drop')} )}
@@ -1360,27 +1373,27 @@ function BrandingAsset({ onClick={clearAsset} className="text-xs text-muted-foreground hover:text-destructive shrink-0" > - Remove + {t('remove')} )}
{hint &&

{hint}

}
{uploading ? ( - Uploading… + {t('branding.uploading')} ) : value ? ( - {value.startsWith('/api/') ? 'Uploaded file' : value} + {value.startsWith('/api/') ? t('branding.uploaded') : value} ) : ( - SVG, PNG, JPEG, WebP or ICO · max 2 MB + {t('branding.formats')} )}
@@ -1391,7 +1404,7 @@ function BrandingAsset({ )} @@ -1406,6 +1419,7 @@ function BrandingAsset({ // ─── Review / finish step ───────────────────────────────────────────────── function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) { + const t = useTranslations('setup'); const [adminPassword, setAdminPassword] = useState(''); const [adminConfirm, setAdminConfirm] = useState(''); const [lockConfig, setLockConfig] = useState(false); @@ -1416,11 +1430,11 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack e.preventDefault(); setLocalError(null); if (adminPassword.length < 8) { - setLocalError('Admin password must be at least 8 characters.'); + setLocalError(t('review.too_short')); return; } if (adminPassword !== adminConfirm) { - setLocalError('Passwords do not match.'); + setLocalError(t('review.mismatch')); return; } setSubmitting(true); @@ -1432,12 +1446,12 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack }); if (!res.ok) { const data = await res.json().catch(() => ({})); - setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`); + setLocalError(data.error ?? t('errors.finish_failed', { status: res.status })); return; } onFinish(); } catch (e) { - setLocalError(humanError(e)); + setLocalError(humanError(e, t)); } finally { setSubmitting(false); } @@ -1452,66 +1466,66 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack return (
- + {/* Summary card with grouped sections */}
- } title="Server"> - - + } title={t('review.server')}> + + {config.jmapServers.length > 0 && ( )} - + - } title="Authentication"> + } title={t('review.auth')}> {config.oauthEnabled && config.oauthClientId && ( - + )} - } title="Security"> - - + } title={t('review.security')}> + + - + - } title="Logging"> - - + } title={t('review.logging')}> + + - } title="Branding"> + } title={t('review.branding')}> {config.loginCompanyName && ( - + )}
@@ -1523,28 +1537,28 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
-
Choose an admin password
+
{t('review.password_title')}

- You'll use this to sign in at /admin. Minimum 8 characters. + {t('review.password_hint', { path: '/admin' })}

- + {passwordTooShort && ( -

At least 8 characters.

+

{t('review.too_short')}

)}
- + {adminConfirm.length > 0 && !passwordsMatch && ( -

Passwords don't match.

+

{t('review.mismatch')}

)} {passwordsMatch && adminPassword.length >= 8 && ( -

Looks good.

+

{t('review.looks_good')}

)}
@@ -1553,16 +1567,16 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack {/* Advanced */}
- Advanced - Show - Hide + {t('review.advanced')} + {t('show')} + {t('hide')}
@@ -1579,9 +1593,9 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack )}
- Back + {t('back')} - {submitting ? 'Applying…' : 'Apply & Finish'} + {submitting ? t('review.applying') : t('review.finish')}
@@ -1863,8 +1877,8 @@ function detectInsecureContext(): boolean { return true; } -function humanError(e: unknown): string { +function humanError(e: unknown, t: SetupT): string { if (e instanceof Error) return e.message; if (typeof e === 'string') return e; - return 'Unknown error'; + return t('unknown_error'); } diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index bc65878bf..6c4027212 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/lib/browser-navigation', () => ({ })); import { AdminShell } from '../admin-shell'; +import { useUpdateStore } from '@/stores/update-store'; function setDesktop(desktop: boolean) { window.matchMedia = vi.fn((query: string) => ({ @@ -56,4 +57,16 @@ describe('AdminShell', () => { expect(screen.getByText('Sign out of admin')).toBeTruthy(); expect(screen.getByText('Change password')).toBeTruthy(); }); + + // The dot next to Version was a span with aria-label, which screen + // readers don't announce. + it('announces a waiting update on the Version entry', () => { + useUpdateStore.setState({ + status: { updateAvailable: true, severity: 'minor', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' }, + startPolling: () => {}, + } as never); + render(shell({ authenticated: true, isStalwartAutoLogin: false })); + const dot = screen.getAllByRole('img', { name: 'Update available' })[0]; + expect(dot.getAttribute('title')).toBe('Update available'); + }); }); 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/admin-shell.tsx b/components/admin/admin-shell.tsx index e78a3ec63..e3a1e5b38 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -181,10 +181,15 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, ); } if (id === 'version' && hasUpdate) { + // A dot only: role="img" makes screen readers announce its label, which + // they skip on a plain span; the title shows it on hover. + const label = updateImportant ? 'Important update available' : 'Update available'; return ( ); } 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} - -
- ); -} diff --git a/components/filters/__tests__/filter-rule-modal-period.test.tsx b/components/filters/__tests__/filter-rule-modal-period.test.tsx index 0109630bb..c931ca1ab 100644 --- a/components/filters/__tests__/filter-rule-modal-period.test.tsx +++ b/components/filters/__tests__/filter-rule-modal-period.test.tsx @@ -102,7 +102,7 @@ describe('filter rule period (dialog)', () => { fireEvent.click(toggle()); save(); expect(onSave).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith('validation_period_empty'); + expect(screen.getByRole('alert').textContent).toBe('validation_period_empty'); }); it('will not take a date without its time for an open end', () => { @@ -114,7 +114,7 @@ describe('filter rule period (dialog)', () => { Object.defineProperty(endInput(), 'validity', { value: { badInput: true }, configurable: true }); save(); expect(onSave).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid'); }); it('refuses a year it cannot store, rather than leave that end open', () => { @@ -124,15 +124,14 @@ describe('filter rule period (dialog)', () => { fireEvent.change(endInput(), { target: { value: '20266-10-16T18:00' } }); save(); expect(onSave).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid'); // The last minute of 9999 in New York is year 10000 in UTC already. - toastError.mockClear(); useSettingsStore.setState({ timeZone: 'America/New_York' }); fireEvent.change(endInput(), { target: { value: '9999-12-31T23:59' } }); save(); expect(onSave).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid'); }); it('offers no year past 9999', () => { @@ -149,7 +148,7 @@ describe('filter rule period (dialog)', () => { fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } }); save(); expect(onSave).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith('validation_period_order'); + expect(screen.getByRole('alert').textContent).toBe('validation_period_order'); }); it('shows a saved period in the user time zone', () => { diff --git a/components/filters/__tests__/filter-rule-modal.test.tsx b/components/filters/__tests__/filter-rule-modal.test.tsx index a71b909fb..6e9062919 100644 --- a/components/filters/__tests__/filter-rule-modal.test.tsx +++ b/components/filters/__tests__/filter-rule-modal.test.tsx @@ -35,7 +35,7 @@ describe('FilterRuleModal header names', () => { fireEvent.click(screen.getByText('save')); expect(onSave).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith('validation_header_name'); + expect(screen.getByRole('alert').textContent).toBe('validation_header_name'); }); it('saves a valid header name, trimmed', () => { @@ -44,7 +44,7 @@ describe('FilterRuleModal header names', () => { fireEvent.click(screen.getByText('save')); - expect(toast.error).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).toBeNull(); expect(onSave).toHaveBeenCalledTimes(1); expect(onSave.mock.calls[0][0].conditions).toEqual([ { field: 'header', comparator: 'contains', value: 'yes', headerName: 'X-Spam_Flag-2' }, @@ -59,6 +59,42 @@ describe('FilterRuleModal header names', () => { fireEvent.click(screen.getByText('save')); expect(onSave).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith('validation_header_name'); + expect(screen.getByRole('alert').textContent).toBe('validation_header_name'); + }); +}); + +// A new rule starts with "Move to folder" and no folder. Saving it says so +// under the actions and marks the folder field, instead of a toast in a +// corner of the window. +describe('FilterRuleModal incomplete action', () => { + const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 }, + { id: 'news', name: 'News', sortOrder: 1 }, + ] as never; + + it('points at the folder field when no folder was chosen', async () => { + vi.mocked(toast.error).mockClear(); + const onSave = vi.fn(); + render( {}} />); + fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } }); + fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(onSave).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions'); + const folder = screen.getByRole('combobox', { name: 'move_to_folder' }); + expect(folder.getAttribute('aria-invalid')).toBe('true'); + + fireEvent.change(folder, { target: { value: 'news' } }); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]); + }); + + it('starts a changed action without a folder too, like a new row', () => { + render( {}} />); + fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } }); + expect((screen.getByRole('combobox', { name: 'move_to_folder' }) as HTMLSelectElement).value).toBe(''); }); }); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 22528d0b9..e813d2331 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -4,9 +4,10 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Plus, Trash2 } from "@/components/icons"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; -import { toast } from "@/stores/toast-store"; +import { Plus, X } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { Dialog } from "@/components/ui/dialog"; +import { useDiscardChanges } from "@/components/settings/use-discard-changes"; import type { FilterRule, FilterCondition, @@ -189,7 +190,18 @@ export function FilterRuleModal({ const periodStartRef = useRef(null); const periodEndRef = useRef(null); - const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); + // Unsaved input is dropped only when the user says so (R5): Escape, the + // X and Cancel ask, a click outside is ignored. + const draft = JSON.stringify([ + name, matchType, conditions, actions, stopProcessing, includeSpam, applyToExisting, periodOn, periodStart, periodEnd, + ]); + const [initialDraft] = useState(draft); + const dirty = draft !== initialDraft; + const { confirmDiscard, discardDialog } = useDiscardChanges(); + const requestClose = useCallback(async () => { + if (await confirmDiscard(dirty)) onClose(); + }, [confirmDiscard, dirty, onClose]); + const nameRef = useRef(null); const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => { const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared)); @@ -271,46 +283,50 @@ export function FilterRuleModal({ return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; }, [offerApplyToExisting, validConditions, validActions, periodOn]); + // What keeps the rule from being saved, said under the part it is about + // once Save was pressed, and updated as the user fixes it. + const [showErrors, setShowErrors] = useState(false); + const [halfFilledPeriod, setHalfFilledPeriod] = useState(false); + const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined; + const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined; + const errors = { + name: !name.trim() ? t("validation_empty_name") : null, + conditions: validConditions.length === 0 + ? t("validation_empty_conditions") + // Stalwart's Sieve compiler refuses any other header name, and with + // it the whole script, so every other rule would stop working too. + : validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? "")) + ? t("validation_header_name") + : null, + actions: validActions.length === 0 ? t("validation_empty_actions") : null, + period: !periodOn + ? null + // A field the browser holds half filled in (a date without its time) + // reads as empty, and must not pass for an open end of the period. + : activeFrom === null || activeUntil === null || halfFilledPeriod + ? t("validation_period_invalid") + : !activeFrom && !activeUntil + ? t("validation_period_empty") + : activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom) + ? t("validation_period_order") + : null, + }; + const hasErrors = Object.values(errors).some(Boolean); + const shown = (error: string | null) => (showErrors ? error : null); + const handleSave = useCallback(() => { + const halfFilled = periodOn && + (!!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput); + setHalfFilledPeriod(halfFilled); + if (halfFilled || hasErrors) { + setShowErrors(true); + // Focus goes to the first part that needs fixing. + requestAnimationFrame(() => { + document.querySelector('[data-rule-error="true"]')?.focus(); + }); + return; + } const trimmedName = name.trim(); - if (!trimmedName) { - toast.error(t("validation_empty_name")); - return; - } - - if (validConditions.length === 0) { - toast.error(t("validation_empty_conditions")); - return; - } - // Stalwart's Sieve compiler refuses any other header name, and with it - // the whole script, so every other rule would stop working too. - if (validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))) { - toast.error(t("validation_header_name")); - return; - } - - if (validActions.length === 0) { - toast.error(t("validation_empty_actions")); - return; - } - - const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined; - const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined; - // A field the browser holds half filled in (a date without its time) - // reads as empty, and must not pass for an open end of the period. - const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput; - if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) { - toast.error(t("validation_period_invalid")); - return; - } - if (periodOn && !activeFrom && !activeUntil) { - toast.error(t("validation_period_empty")); - return; - } - if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) { - toast.error(t("validation_period_order")); - return; - } onSave({ id: start?.id || generateUUID(), @@ -324,10 +340,10 @@ export function FilterRuleModal({ // is off: the settings page merges the result into the stored rule, and // a field left out would keep its old value there. includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, - activeFrom, - activeUntil, + activeFrom: activeFrom ?? undefined, + activeUntil: activeUntil ?? undefined, }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -392,11 +408,6 @@ export function FilterRuleModal({ if (updates.type && updates.type !== "forward") { delete updated.keepCopy; } - if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) { - const firstMb = hierarchicalMailboxes[0]; - updated.value = firstMb ? (mailboxPathMap.get(firstMb.id) || firstMb.name) : ""; - if (firstMb) updated.mailboxId = firstMb.id; - } return updated; }) ); @@ -411,40 +422,43 @@ export function FilterRuleModal({ "px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"; return ( -
-
@@ -797,7 +813,7 @@ export function FilterRuleModal({ type="datetime-local" max={PERIOD_INPUT_MAX} value={periodStart} - onChange={(e) => setPeriodStart(e.target.value)} + onChange={(e) => { setPeriodStart(e.target.value); setHalfFilledPeriod(false); }} className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" /> @@ -808,12 +824,13 @@ export function FilterRuleModal({ type="datetime-local" max={PERIOD_INPUT_MAX} value={periodEnd} - onChange={(e) => setPeriodEnd(e.target.value)} + onChange={(e) => { setPeriodEnd(e.target.value); setHalfFilledPeriod(false); }} className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" />

{t("period_hint")}

+
)} @@ -847,15 +864,18 @@ export function FilterRuleModal({ )} -
- - -
- - + + {discardDialog} + + ); +} + +/** Why the rule can't be saved yet, under the part it is about. */ +function FieldError({ id, text }: { id?: string; text: string | null }) { + if (!text) return null; + return ( + ); } diff --git a/components/filters/sieve-editor-modal.tsx b/components/filters/sieve-editor-modal.tsx index 0aeee9ecc..4f47f4d2c 100644 --- a/components/filters/sieve-editor-modal.tsx +++ b/components/filters/sieve-editor-modal.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { X, AlertTriangle, CheckCircle, Loader2 } from "@/components/icons"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { Dialog } from "@/components/ui/dialog"; +import { AlertTriangle, CheckCircle, Loader2 } from "@/components/icons"; +import { useDiscardChanges } from "@/components/settings/use-discard-changes"; interface SieveEditorModalProps { content: string; @@ -28,8 +29,14 @@ export function SieveEditorModal({ } | null>(null); const [showSaveWarning, setShowSaveWarning] = useState(false); - const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); const textareaRef = useRef(null); + const dirty = script !== content; + const { confirmDiscard, discardDialog } = useDiscardChanges(); + // Escape, the X, Cancel: an edited script is only dropped when the user + // says so; a click outside is ignored while it is edited. + const requestClose = useCallback(async () => { + if (await confirmDiscard(dirty)) onClose(); + }, [confirmDiscard, dirty, onClose]); const lineCount = script.split("\n").length; @@ -54,10 +61,6 @@ export function SieveEditorModal({ onSave(script); }, [script, showSaveWarning, onSave]); - useEffect(() => { - textareaRef.current?.focus(); - }, []); - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Tab") { e.preventDefault(); @@ -72,33 +75,49 @@ export function SieveEditorModal({ }; return ( -
-