From 295c0d6c49fa268c89e57379de43045a0e11e3aa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:31:58 +0100 Subject: [PATCH 01/29] fix: show menus and dialogs at once with reduced motion on, so they take focus --- app/globals.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/globals.css b/app/globals.css index 691c1f587..1be628671 100644 --- a/app/globals.css +++ b/app/globals.css @@ -730,7 +730,11 @@ optgroup { *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + /* Every element transitions `all` by default once it has a duration. + Visibility is left out: a transition from hidden would keep a newly + shown menu or dialog hidden for a frame, and focus() would miss it. */ + transition-property: all, visibility !important; + transition-duration: 0.01ms, 0s !important; scroll-behavior: auto !important; } } @@ -741,7 +745,8 @@ optgroup { [data-animations="off"] *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + transition-property: all, visibility !important; + transition-duration: 0.01ms, 0s !important; scroll-behavior: auto !important; } From ae412e0cda44dc615df2ffbbef5a0db2ba66258e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:05:41 +0100 Subject: [PATCH 02/29] feat: show notifications, the command palette and the account avatar in every app's phone header --- components/calendar/calendar-toolbar.tsx | 2 + components/contacts/contact-list.tsx | 2 + components/files/files-header.tsx | 2 + .../__tests__/phone-header-actions.test.tsx | 107 +++++++++++ components/layout/mobile-header.tsx | 84 +-------- components/layout/phone-header-actions.tsx | 174 ++++++++++++++++++ components/plugins/plugin-page-app.tsx | 2 + components/settings/settings-app.tsx | 3 + 8 files changed, 295 insertions(+), 81 deletions(-) create mode 100644 components/layout/__tests__/phone-header-actions.test.tsx create mode 100644 components/layout/phone-header-actions.tsx diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 2ba57c760..c5ca2b993 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons"; import { startOfWeek } from "date-fns"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; import type { Calendar } from "@/lib/jmap/types"; @@ -210,6 +211,7 @@ export function CalendarToolbar({ + {/* Row 2: View switcher pills + calendar toggle */} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index f736593a7..1c8f9b8d7 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -10,6 +10,7 @@ import { ContactContextMenu } from "./contact-context-menu"; import { useContactExportBusy } from "./contact-export"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/stores/contact-store"; @@ -352,6 +353,7 @@ export function ContactList({ )} + diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index 4070c05b1..1a7d94695 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -10,6 +10,7 @@ import { import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { PluginActions } from "@/components/plugins/plugin-actions"; @@ -333,6 +334,7 @@ export function FilesHeader({ )} + )} diff --git a/components/layout/__tests__/phone-header-actions.test.tsx b/components/layout/__tests__/phone-header-actions.test.tsx new file mode 100644 index 000000000..5b5be97f5 --- /dev/null +++ b/components/layout/__tests__/phone-header-actions.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The phone header cluster (⋮ with notifications and the command palette, +// then the account avatar) that every app shows at phone width. + +const media = vi.hoisted(() => ({ mobile: true, desktop: false })); +vi.mock('@/hooks/use-media-query', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobile: () => media.mobile, + useIsDesktop: () => media.desktop, +})); + +const { PhoneHeaderActions, PhoneAppHeader } = await import('../phone-header-actions'); +const { ContactList } = await import('@/components/contacts/contact-list'); +const { ContextMenuItem } = await import('@/components/ui/context-menu'); + +beforeEach(() => { + media.mobile = true; + media.desktop = false; +}); + +describe('PhoneHeaderActions', () => { + it('shows the menu and the account avatar on a phone', () => { + render(); + expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + // Notifications and the command palette (both "title" as keys in tests). + expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title', 'title']); + }); + + it('opens the command palette from the menu', () => { + const opened = vi.fn(); + window.addEventListener('bulwark:command-palette', opened); + render(); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + fireEvent.click(screen.getAllByRole('menuitem')[1]); + expect(opened).toHaveBeenCalled(); + window.removeEventListener('bulwark:command-palette', opened); + }); + + it('lists the app entries first and closes the menu when one is picked', () => { + const run = vi.fn(); + render( + { close(); run(); }} />} + /> + ); + fireEvent.click(screen.getByRole('button', { name: 'more' })); + const items = screen.getAllByRole('menuitem'); + expect(items[0]).toHaveTextContent('Import'); + fireEvent.click(items[0]); + expect(run).toHaveBeenCalled(); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('draws nothing where the rail holds these controls', () => { + media.desktop = true; + media.mobile = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('draws nothing on a tablet for an app that keeps the rail there', () => { + media.mobile = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + render(); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + }); +}); + +describe('PhoneAppHeader', () => { + it('gives a screen without a header a title and the cluster', () => { + render(); + expect(screen.getByRole('heading', { name: 'Kanban' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + }); +}); + +describe('apps use it', () => { + it('puts the cluster in the Contacts list header on a phone', () => { + render( + ()} + onToggleSelection={vi.fn()} + onSelectRangeContacts={vi.fn()} + onSelectAll={vi.fn()} + onClearSelection={vi.fn()} + onBulkDelete={vi.fn()} + onBulkAddToGroup={vi.fn()} + onBulkExport={vi.fn()} + onEditContact={vi.fn()} + onDeleteContact={vi.fn()} + onAddContactToGroup={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument(); + }); +}); diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 654c47625..72b706e0e 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons"; +import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useUIStore } from "@/stores/ui-store"; import { useShallow } from "zustand/react/shallow"; @@ -10,84 +9,7 @@ import { useIsDesktop } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { AccountSwitcher } from "@/components/layout/account-switcher"; -import { PluginActions } from "@/components/plugins/plugin-actions"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; -import { ContextMenuItem } from "@/components/ui/context-menu"; -import { openCommandPalette } from "@/lib/app-commands"; -import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center"; - -/** Plugin buttons in the top bar are not about any one item. */ -const TOP_BAR_CONTEXT: Record = {}; - -/** - * The phone header's overflow menu: the notifications (the desktop bell), the - * command palette (no Ctrl+K on a phone) and what the desktop top bar shows - * as buttons (plugin top-bar-actions) and has no room for here. - */ -function MobileHeaderMenu() { - const t = useTranslations('top_bar'); - const tPalette = useTranslations('command_palette'); - const tNotifications = useTranslations('notification_center'); - const unread = useUnreadNotificationCount(); - const [showNotifications, setShowNotifications] = useState(false); - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - const close = useCallback(() => setOpen(false), []); - const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); - - useEffect(() => { - if (!open) return; - const onPointerDown = (e: MouseEvent) => { - if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; - setOpen(false); - }; - document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); - }, [open, menuRef]); - - return ( -
- - {open && ( -
- 0 ? formatBadgeCount(unread) : undefined} - onClick={() => { close(); setShowNotifications(true); }} - /> - { close(); openCommandPalette(); }} - /> - -
- )} - {showNotifications && setShowNotifications(false)} />} -
- ); -} +import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions"; interface MobileHeaderProps { title: string; @@ -246,7 +168,7 @@ export function MobileHeader({ )} - + {/* Which account you are reading is otherwise only visible after opening the drawer. Last in this group so it sits on the far diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx new file mode 100644 index 000000000..fee8315dc --- /dev/null +++ b/components/layout/phone-header-actions.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { MoreVertical, Terminal, Bell } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { AccountSwitcher } from "@/components/layout/account-switcher"; +import { PluginActions } from "@/components/plugins/plugin-actions"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; +import { openCommandPalette } from "@/lib/app-commands"; +import { cn } from "@/lib/utils"; +import { + NotificationSheet, + formatBadgeCount, + useUnreadNotificationCount, +} from "@/components/notifications/notification-center"; + +/** Plugin buttons in the top bar are not about any one item. */ +const TOP_BAR_CONTEXT: Record = {}; + +export interface PhoneHeaderMenuProps { + /** + * The app's own entries, listed above the shell's. They get the function + * that closes the menu, to call when one is picked. + */ + menuItems?: (close: () => void) => ReactNode; +} + +/** + * The phone header's overflow menu: the app's own entries if it has any, the + * notifications (the desktop bell), the command palette (no Ctrl+K on a + * phone) and what the desktop top bar shows as buttons (plugin + * top-bar-actions) and has no room for here. + */ +export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { + const t = useTranslations("top_bar"); + const tPalette = useTranslations("command_palette"); + const tNotifications = useTranslations("notification_center"); + const unread = useUnreadNotificationCount(); + const [showNotifications, setShowNotifications] = useState(false); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const close = useCallback(() => setOpen(false), []); + const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open, menuRef]); + + const appItems = menuItems?.(close); + + return ( +
+ setOpen((v) => !v)} + className={cn(open && "bg-accent")} + label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")} + title="" + aria-haspopup="menu" + aria-expanded={open} + > + + {unread > 0 && ( + + {open && ( +
+ {appItems && ( + <> + {appItems} + + + )} + 0 ? formatBadgeCount(unread) : undefined} + onClick={() => { close(); setShowNotifications(true); }} + /> + { close(); openCommandPalette(); }} + /> + +
+ )} + {showNotifications && setShowNotifications(false)} />} +
+ ); +} + +export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps { + /** + * Below which width the app shows the phone shell (bottom tab bar, no + * rail): "md" for apps that keep the rail on a tablet, "lg" for those that + * use the phone shell up to the desktop. Above it nothing is drawn, as the + * rail or the Mountain View top bar holds these controls there. + */ + below?: "md" | "lg"; + className?: string; + /** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */ + onSelectAllInboxes?: () => void; + allInboxesSelected?: boolean; +} + +/** + * The cluster at the end of every app's phone header (answer 12 of the + * 2026-10-27 usability test): a ⋮ with notifications and the command palette, + * then the account avatar. Drop it in as the last child of the header row. + */ +export function PhoneHeaderActions({ + below = "lg", + className, + menuItems, + onSelectAllInboxes, + allInboxesSelected = false, +}: PhoneHeaderActionsProps) { + const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); + const shown = below === "md" ? isMobile : !isDesktop; + if (!shown) return null; + + return ( +
+ + {/* Which account you are in is otherwise only visible in Settings. + Last, so it sits on the far edge, mirrored under RTL by the flex row. */} + +
+ ); +} + +export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps { + title: string; + /** Controls drawn before the title, such as a back or menu button. */ + leading?: ReactNode; +} + +/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */ +export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) { + const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); + const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop; + if (!shown) return null; + return ( +
+ {leading} +

{title}

+ +
+ ); +} diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx index 8a0f76939..d6f3ad189 100644 --- a/components/plugins/plugin-page-app.tsx +++ b/components/plugins/plugin-page-app.tsx @@ -8,6 +8,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/ import { useEmailStore } from "@/stores/email-store"; import { usePluginStore } from "@/stores/plugin-store"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { PhoneAppHeader } from "@/components/layout/phone-header-actions"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { MvTopBar } from "@/components/layout/mv-top-bar"; @@ -144,6 +145,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) { )}
+ {!isEmbedded && } {inlineApp && }
{content} diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 9de63f354..acdb7a001 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -63,6 +63,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; +import { PhoneHeaderActions } from '@/components/layout/phone-header-actions'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; @@ -844,6 +845,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{activeTabLabel}

+
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{t('title')}

+
From 95d8230bb48c04f9aaca6fa7a757592603622654 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:09:24 +0100 Subject: [PATCH 03/29] fix: give the command palette a close button on phones and drop the keyboard shortcuts entry on touch screens --- .../__tests__/command-palette.test.tsx | 27 +++++++++++++++++++ .../command-palette/command-palette.tsx | 20 +++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 7ccfc07b3..e7e2d2b49 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -184,3 +184,30 @@ describe('command palette', () => { expect(screen.getByRole('option', { name: 'shortcuts' })).toBeInTheDocument(); }); }); + +describe('command palette on a phone', () => { + const realMatchMedia = window.matchMedia; + beforeEach(() => { + window.matchMedia = ((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, onchange: null, + addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + afterEach(() => { window.matchMedia = realMatchMedia; }); + + it('has a close button, as it fills the screen with nothing to tap around it', () => { + render(); + act(() => appCommands.openCommandPalette()); + act(() => { fireEvent.click(screen.getByRole('button', { name: 'close' })); }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('offers no keyboard shortcuts on a touch screen', () => { + render(); + act(() => appCommands.openCommandPalette()); + expect(screen.queryByRole('option', { name: 'shortcuts' })).toBeNull(); + expect(screen.getByRole('option', { name: 'compose' })).toBeInTheDocument(); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 7adbc0286..298b9de77 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -16,6 +16,7 @@ import { Settings, Sun, UserCircle, + X, iconForName, } from "@/components/icons"; import type { AppIcon } from "@/components/icons"; @@ -37,6 +38,8 @@ import { shortcutAlternatives } from "@/lib/shortcut-keys"; import { toUnicodeEmail } from "@/lib/idn"; import { isEditableEventTarget } from "@/lib/keyboard"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { IconButton } from "@/components/ui/icon-button"; import { cn } from "@/lib/utils"; interface PaletteCommand { @@ -131,6 +134,7 @@ export function CommandPaletteHost() { function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) { const t = useTranslations("command_palette"); + const tCommon = useTranslations("common"); const tSidebar = useTranslations("sidebar"); const tGlobalSearch = useTranslations("global_search"); const router = useRouter(); @@ -150,8 +154,11 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS const registeredGroups = useRegisteredCommandGroups(); const accounts = useAccountStore((s) => s.accounts); const activeAccountId = useAuthStore((s) => s.activeAccountId); + // A touch screen has no keyboard to use the shortcuts with (R8). + const touch = useMediaQuery("(pointer: coarse)"); - const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true }); + const inputRef = useRef(null); + const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current }); const commands = useMemo(() => { const go = (href: string) => () => router.push(href); @@ -209,7 +216,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"), }); } - list.push({ id: "shortcuts", group: actions, icon: Keyboard, label: t("shortcuts"), shortcut: "?", run: onShowShortcuts }); + if (!touch) { + list.push({ id: "shortcuts", group: actions, icon: Keyboard, label: t("shortcuts"), shortcut: "?", run: onShowShortcuts }); + } // Account: switch to each other signed-in account, sign out. for (const entry of accounts) { if (entry.id === activeAccountId) continue; @@ -235,7 +244,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS }); } return list; - }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts]); + }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]); const needle = query.trim(); const visible = needle @@ -301,6 +310,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
void; onS className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" /> Esc + {/* Full screen on a phone: no backdrop to tap and no Escape key (R4). */} + + +
    Date: Wed, 28 Oct 2026 03:12:59 +0100 Subject: [PATCH 04/29] fix: keep the keyboard shortcuts button and All inboxes in the Mountain View top bar outside Mail --- .../__tests__/mv-top-bar-cluster.test.tsx | 67 +++++++++++++++++++ components/layout/mv-top-bar.tsx | 56 +++++++++++----- components/mail/mail-app.tsx | 1 + lib/app-commands.ts | 2 +- 4 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 components/layout/__tests__/mv-top-bar-cluster.test.tsx diff --git a/components/layout/__tests__/mv-top-bar-cluster.test.tsx b/components/layout/__tests__/mv-top-bar-cluster.test.tsx new file mode 100644 index 000000000..ad7854147 --- /dev/null +++ b/components/layout/__tests__/mv-top-bar-cluster.test.tsx @@ -0,0 +1,67 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mountain View's right-hand cluster is the same in every app: the keyboard +// shortcuts button and the avatar's "All inboxes" row work outside mail too. + +const nav = vi.hoisted(() => ({ push: vi.fn() })); +const switcher = vi.hoisted(() => ({ onSelectAllInboxes: undefined as undefined | (() => void) })); + +vi.mock('../account-switcher', () => ({ + AccountSwitcher: (props: { onSelectAllInboxes?: () => void }) => { + switcher.onSelectAllInboxes = props.onSelectAllInboxes; + return null; + }, +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false })); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }), + usePathname: () => '/calendar', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const { MvTopBar } = await import('../mv-top-bar'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useAccountStore } = await import('@/stores/account-store'); +const { usePolicyStore } = await import('@/stores/policy-store'); +const { DEFAULT_POLICY } = await import('@/lib/admin/types'); +const appCommands = await import('@/lib/app-commands'); + +const twoAccounts = [ + { id: 'a', isConnected: true }, + { id: 'b', isConnected: true }, +]; + +beforeEach(() => { + nav.push.mockClear(); + switcher.onSelectAllInboxes = undefined; + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, unifiedCrossAccountEnabled: true } } }); + useSettingsStore.setState({ enableUnifiedMailbox: true, unifiedCrossAccount: true } as never); + useAccountStore.setState({ accounts: twoAccounts } as never); +}); + +describe('Mountain View top bar outside mail', () => { + it('opens the keyboard shortcuts without a handler from the app', () => { + const opened = vi.fn(); + window.addEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened); + render(); + fireEvent.click(screen.getByRole('button', { name: 'keyboard_shortcuts' })); + expect(opened).toHaveBeenCalled(); + window.removeEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened); + }); + + it('offers "All inboxes" and opens mail on it', () => { + render(); + expect(switcher.onSelectAllInboxes).toBeTypeOf('function'); + act(() => switcher.onSelectAllInboxes!()); + expect(nav.push).toHaveBeenCalledWith('/'); + expect(appCommands.peekParkedAppCommand()).toBe('all-inboxes'); + }); + + it('leaves "All inboxes" out when the merged inbox cannot be built', () => { + useAccountStore.setState({ accounts: [twoAccounts[0]] } as never); + render(); + expect(switcher.onSelectAllInboxes).toBeUndefined(); + }); +}); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index 0a666d1cc..f3e74cf9a 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -6,7 +6,7 @@ import { Folder, Grid3x3, Users, - HelpCircle, + Keyboard, Mail, Menu, Puzzle, @@ -17,7 +17,7 @@ import { } from "@/components/icons"; import type { AppIcon as AppIconType } from "@/components/icons"; import { useTranslations } from "next-intl"; -import { Link, usePathname } from "@/i18n/navigation"; +import { Link, usePathname, useRouter } from "@/i18n/navigation"; import { useAuthStore } from "@/stores/auth-store"; import { useShallow } from "zustand/react/shallow"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -31,6 +31,9 @@ import { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { cn } from "@/lib/utils"; +import { openKeyboardShortcuts, parkAppCommand, requestAppCommand } from "@/lib/app-commands"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useAccountStore } from "@/stores/account-store"; import { AccountSwitcher } from "@/components/layout/account-switcher"; import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; import { PluginActions } from "@/components/plugins/plugin-actions"; @@ -76,11 +79,23 @@ interface MvTopBarProps { onInlineApp?: (appId: string, url: string, name: string) => void; onCloseInlineApp?: () => void; activeAppId?: string | null; - /** Opens the merged across-accounts inbox from the account popover. */ + /** + * Opens the merged across-accounts inbox from the account popover. Outside + * mail the bar opens mail on that inbox itself, when it can be built. + */ onSelectAllInboxes?: () => void; allInboxesSelected?: boolean; } +/** Whether the merged inbox can be built: the terms the mail sidebar's unified section uses. */ +function useAllInboxesReachable(): boolean { + const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); + const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); + const gate = usePolicyStore((s) => s.isFeatureEnabled("unifiedCrossAccountEnabled")); + const connected = useAccountStore((s) => s.accounts.filter((a) => a.isConnected).length); + return enableUnifiedMailbox && unifiedCrossAccount && gate && connected > 1; +} + /** * Mountain View's global header: one 64px row that owns the hamburger, the * brand, the search field and the account cluster. It only renders on @@ -103,6 +118,18 @@ export function MvTopBar({ const t = useTranslations("sidebar"); const tSearch = useTranslations("advanced_search"); const pathname = usePathname(); + const router = useRouter(); + const allInboxesReachable = useAllInboxesReachable(); + // Every app gets the same cluster: shortcuts and "All inboxes" work from + // Calendar, Contacts, Files and Settings too, not only from mail. + const showShortcuts = onShowShortcuts ?? openKeyboardShortcuts; + const selectAllInboxes = onSelectAllInboxes ?? (allInboxesReachable + ? () => { + if (requestAppCommand("all-inboxes")) return; + parkAppCommand("all-inboxes"); + router.push("/"); + } + : undefined); const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed); @@ -246,18 +273,15 @@ export function MvTopBar({
    - {onShowShortcuts && ( - - )} + {/* Admin (Stalwart admins), as in the navigation rail this bar replaces. A hard navigation because /admin lives outside the [locale] tree. */} {isStalwartAdmin && ( @@ -407,7 +431,7 @@ export function MvTopBar({
    diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bbd5971b7..46bfa3c5a 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1148,6 +1148,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isMobile) setMobileSearchOpen(true); else keyboardHandlers.onFocusSearch?.(); }, + 'all-inboxes': () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox), }, isAuthenticated && !!client); // Shared mail refresh: re-fetch mailboxes (unread counts) + the active list diff --git a/lib/app-commands.ts b/lib/app-commands.ts index e220b6cf5..b5298775d 100644 --- a/lib/app-commands.ts +++ b/lib/app-commands.ts @@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'; -export type AppCommand = 'compose' | 'search'; +export type AppCommand = 'compose' | 'search' | 'all-inboxes'; export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette'; From fbbeac5291af8ddfa168ab0d185e356f9a506fcf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:15:02 +0100 Subject: [PATCH 05/29] fix: move the focus into the notification panel when it opens, and close it when Tab leaves --- .../__tests__/notification-center.test.tsx | 31 +++++++++++++++++ .../notifications/notification-center.tsx | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/components/notifications/__tests__/notification-center.test.tsx b/components/notifications/__tests__/notification-center.test.tsx index 8bbe2ba38..29f302714 100644 --- a/components/notifications/__tests__/notification-center.test.tsx +++ b/components/notifications/__tests__/notification-center.test.tsx @@ -138,3 +138,34 @@ describe('notification bell', () => { expect(screen.queryByRole('dialog')).toBeNull(); }); }); + +describe('notification panel and the keyboard', () => { + it('moves the focus into the panel when it opens', () => { + act(() => add({ level: 'error', title: 'a', body: '' })); + render(); + fireEvent.click(bell()); + expect(screen.getByRole('button', { name: 'mark_all_read' })).toHaveFocus(); + }); + + it('focuses the panel itself when it has nothing to act on', () => { + render(); + fireEvent.click(bell()); + expect(screen.getByRole('dialog', { name: 'title' })).toHaveFocus(); + }); + + it('closes and returns to the bell when Tab leaves the panel', () => { + act(() => add({ level: 'error', title: 'a', body: '' })); + render(); + fireEvent.click(bell()); + const clear = screen.getByRole('button', { name: 'clear' }); + clear.focus(); + act(() => { fireEvent.keyDown(clear, { key: 'Tab' }); }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(bell()).toHaveFocus(); + + fireEvent.click(bell()); + const markAll = screen.getByRole('button', { name: 'mark_all_read' }); + act(() => { fireEvent.keyDown(markAll, { key: 'Tab', shiftKey: true }); }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx index ee6a09964..dd91dc95d 100644 --- a/components/notifications/notification-center.tsx +++ b/components/notifications/notification-center.tsx @@ -153,6 +153,11 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo ); } +/** The panel's enabled controls, in tab order. */ +function panelControls(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll("button:not(:disabled), a[href]")); +} + /** The phone version: the panel over the whole screen. */ export function NotificationSheet({ onClose }: { onClose: () => void }) { const t = useTranslations("notification_center"); @@ -205,6 +210,32 @@ export function NotificationBell({ placement, className }: { placement: "side" | if (open) position(); }, [open, position]); + // The panel is portalled to the end of , so Tab from the bell would + // never reach it: focus moves in on open, to its first control (or the + // panel itself while every control is disabled). + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + (panelControls(panel)[0] ?? panel).focus(); + }, [open]); + + // Tab past either end of the panel closes it and goes back to the bell, so + // the next Tab carries on from where the panel was opened. + const onPanelKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !panelRef.current) return; + const controls = panelControls(panelRef.current); + const first = controls[0]; + const last = controls[controls.length - 1]; + const active = document.activeElement; + const leaving = controls.length === 0 + || (e.shiftKey ? active === first || active === panelRef.current : active === last); + if (!leaving) return; + e.preventDefault(); + setOpen(false); + buttonRef.current?.focus(); + }; + useEffect(() => { if (!open) return; const onPointerDown = (e: MouseEvent) => { @@ -264,6 +295,8 @@ export function NotificationBell({ placement, className }: { placement: "side" | ref={panelRef} role="dialog" aria-label={t("title")} + tabIndex={-1} + onKeyDown={onPanelKeyDown} style={style} className="z-50 w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(32rem,80vh)] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg" > From 2c4e259ddc15c637fdd481de71632981d441f4c7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:19:00 +0100 Subject: [PATCH 06/29] fix: point the phone tour at the bottom tab bar and skip steps with nothing on screen to show --- components/layout/navigation-rail.tsx | 1 + components/tour/__tests__/tour.test.tsx | 90 +++++++++++++++++++++++++ components/tour/tour-overlay.tsx | 67 +++++++++++------- components/tour/tour-provider.tsx | 13 +++- components/tour/tour-steps.ts | 10 +++ 5 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 components/tour/__tests__/tour.test.tsx diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 55abf84b6..e18fbc43e 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -371,6 +371,7 @@ export function NavigationRail({ key={item.id} href={item.href} onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')} + data-tour={`nav-${item.id}`} className={cn( "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "transition-colors duration-150", diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx new file mode 100644 index 000000000..5f4722774 --- /dev/null +++ b/components/tour/__tests__/tour.test.tsx @@ -0,0 +1,90 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The tour on a phone, where the rail's anchors are not rendered, and its +// keyboard handling. + +const tour = vi.hoisted(() => ({ + currentStep: 0, + nextStep: vi.fn(), + prevStep: vi.fn(), + stopTour: vi.fn(), +})); + +vi.mock('../tour-provider', () => ({ + useTour: () => ({ + currentStep: tour.currentStep, + totalSteps: 3, + steps: [ + { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, + { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, + { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, + ], + nextStep: tour.nextStep, + prevStep: tour.prevStep, + stopTour: tour.stopTour, + }), +})); + +const { TourOverlay, findTourTarget } = await import('../tour-overlay'); +const { getTourSteps } = await import('../tour-steps'); + +/** jsdom lays nothing out: give the elements marked `data-size` a box. */ +const realRect = HTMLElement.prototype.getBoundingClientRect; +beforeEach(() => { + vi.useFakeTimers(); + tour.currentStep = 0; + tour.nextStep.mockClear(); + tour.prevStep.mockClear(); + tour.stopTour.mockClear(); + HTMLElement.prototype.getBoundingClientRect = function () { + const sized = this.hasAttribute('data-size'); + return { top: 10, left: 10, width: sized ? 40 : 0, height: sized ? 40 : 0, right: 50, bottom: 50, x: 10, y: 10, toJSON: () => ({}) } as DOMRect; + }; + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(() => { + vi.useRealTimers(); + HTMLElement.prototype.getBoundingClientRect = realRect; + hosts.splice(0).forEach((h) => h.remove()); + document.documentElement.removeAttribute('dir'); +}); + +const hosts: HTMLElement[] = []; +function anchors(html: string) { + const host = document.createElement('div'); + hosts.push(host); + host.innerHTML = html; + document.body.appendChild(host); +} + +describe('tour targets', () => { + it('points at the copy of an anchor that is on screen', () => { + anchors(''); + expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar'); + }); + + it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(1100); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + }); + + it('shows a step whose target is on screen', () => { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(1100); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog', { name: 'one' })).toBeInTheDocument(); + }); + + it('leaves out the shortcuts step on a touch screen and contacts when they are off', () => { + const ids = (o: Partial[0]>) => + getTourSteps({ isDemoMode: false, supportsCalendar: true, supportsWebDAV: true, ...o }).map((s) => s.id); + expect(ids({})).toContain('shortcuts'); + expect(ids({ touch: true })).not.toContain('shortcuts'); + expect(ids({})).toContain('nav-contacts'); + expect(ids({ supportsContacts: false })).not.toContain('nav-contacts'); + }); +}); diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index 27674fd1f..cc0ccc7f5 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -18,15 +18,31 @@ const PADDING = 8; const TOOLTIP_GAP = 12; const TOOLTIP_MAX_W = 360; +/** + * The first element for the selector that is on screen. The same anchor can + * exist twice (the rail and the phone's tab bar); one with no size is not + * rendered in this layout and has nothing to point at. + */ +export function findTourTarget(selector: string): Element | null { + for (const el of document.querySelectorAll(selector)) { + const r = el.getBoundingClientRect(); + if (r.width > 0 || r.height > 0) return el; + } + return null; +} + function getTargetRect(selector: string): Rect | null { - const el = document.querySelector(selector); + const el = findTourTarget(selector); if (!el) return null; const r = el.getBoundingClientRect(); - // Element might exist but be hidden (zero dimensions) - if (r.width === 0 && r.height === 0) return null; return { top: r.top, left: r.left, width: r.width, height: r.height }; } +/** Polls (100 ms apart) before a step whose target is in the DOM but has no size is skipped. */ +const HIDDEN_TARGET_ATTEMPTS = 10; +/** Polls before a step whose target never appeared (another page loading) is skipped. */ +const MISSING_TARGET_ATTEMPTS = 50; + function computeTooltipPosition( target: Rect, placement: "top" | "bottom" | "left" | "right", @@ -90,7 +106,13 @@ export function TourOverlay() { // Use refs for callbacks to avoid stale closures in timers/intervals const updatePositionRef = useRef<() => void>(() => {}); const nextStepRef = useRef<() => void>(() => {}); + const prevStepRef = useRef<() => void>(() => {}); + // A step with nothing to point at is skipped in the direction the user went. + const directionRef = useRef<1 | -1>(1); + const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]); + const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]); nextStepRef.current = nextStep; + prevStepRef.current = prevStep; const focusTrapRef = useFocusTrap({ isActive: visible, @@ -123,7 +145,6 @@ export function TourOverlay() { // Wait for target element to appear, then show useEffect(() => { if (!step) return; - console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`); setVisible(false); // Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position // instead of disappearing and reappearing @@ -140,20 +161,16 @@ export function TourOverlay() { } let attempts = 0; - const maxAttempts = 50; // 5 seconds let cancelled = false; const tryFind = () => { if (cancelled) return true; - const el = document.querySelector(step.target); + const el = findTourTarget(step.target); if (el) { - const rect = el.getBoundingClientRect(); - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`); el.scrollIntoView({ behavior: "smooth", block: "nearest" }); // Delay after scroll for layout to settle pendingTimerRef.current = setTimeout(() => { if (cancelled) return; - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`); updatePositionRef.current(); setVisible(true); // Second position update after tooltip renders with final dimensions @@ -163,24 +180,26 @@ export function TourOverlay() { }, 200); return true; } - if (attempts % 10 === 0) { - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`); - } return false; }; if (tryFind()) return () => { cancelled = true; }; - // Poll for element appearance (for page navigation) + // Poll for the element to appear (another page loading). One that is in + // the DOM but not rendered in this layout (the rail on a phone) is given + // up on sooner. const interval = setInterval(() => { attempts++; - if (tryFind() || attempts >= maxAttempts) { + if (tryFind()) { clearInterval(interval); - if (attempts >= maxAttempts && !cancelled) { - // Skip this step if element never appears - console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`); - nextStepRef.current(); - } + return; + } + const limit = document.querySelector(step.target) ? HIDDEN_TARGET_ATTEMPTS : MISSING_TARGET_ATTEMPTS; + if (attempts >= limit) { + clearInterval(interval); + if (cancelled) return; + if (directionRef.current === -1 && currentStep > 0) prevStepRef.current(); + else nextStepRef.current(); } }, 100); @@ -219,11 +238,11 @@ export function TourOverlay() { if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); e.stopPropagation(); - nextStep(); + goNext(); } else if (e.key === "ArrowLeft") { e.preventDefault(); e.stopPropagation(); - prevStep(); + goPrev(); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); @@ -232,7 +251,7 @@ export function TourOverlay() { }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [nextStep, prevStep, stopTour]); + }, [goNext, goPrev, stopTour]); // Re-position after tooltip content renders with new dimensions useEffect(() => { @@ -373,7 +392,7 @@ export function TourOverlay() {
    diff --git a/components/totp-reauth-dialog.tsx b/components/totp-reauth-dialog.tsx index 51a3958dc..7b4d45e1b 100644 --- a/components/totp-reauth-dialog.tsx +++ b/components/totp-reauth-dialog.tsx @@ -1,107 +1,79 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useTranslations } from "next-intl"; import { useTotpReauthStore } from "@/stores/totp-reauth-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Dialog } from "@/components/ui/dialog"; import { Shield } from "@/components/icons"; /** - * Modal dialog that prompts the user for a fresh TOTP code when their - * 2FA session expires (TOTP rotates every ~30 seconds). + * Asks for a fresh TOTP code when a 2FA session expires (TOTP rotates every + * ~30 seconds). * - * Rendered once at the app root level. The JMAP client triggers it via - * the useTotpReauthStore when a 401 is received on a TOTP-authenticated session. + * Mounted once in the locale layout, so the prompt opens in whichever app + * made the request. The JMAP client opens it through useTotpReauthStore + * when a 401 comes back on a TOTP-authenticated session. */ export function TotpReauthDialog() { + const t = useTranslations("totp_reauth"); + const tCommon = useTranslations("common"); const { isOpen, submit, cancel } = useTotpReauthStore(); const [code, setCode] = useState(""); const inputRef = useRef(null); - const dialogRef = useFocusTrap({ - isActive: isOpen, - onEscape: cancel, - restoreFocus: true, - }); - useEffect(() => { - if (isOpen) { - setCode(""); - setTimeout(() => inputRef.current?.focus(), 50); - } + if (isOpen) setCode(""); }, [isOpen]); - if (!isOpen) return null; - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (code.length >= 6) { - submit(code); - } + if (code.length >= 6) submit(code); }; return ( -
    -
    -
    -
    -
    - -
    -
    -

    Session Expired

    -

    Your 2FA code has rotated

    -
    + +
    - -

    - Enter a fresh authentication code from your authenticator app to continue. -

    - -

    - To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication - (either Stalwart's built-in OAuth or an external identity provider). -

    - -
    - setCode(e.target.value.replace(/\D/g, ''))} - className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" - placeholder="000000" - autoComplete="one-time-code" - aria-label="Authentication code" - /> -
    - - -
    -
    -
    -
    + } + size="sm" + showClose={false} + dirty={code.length > 0} + initialFocus={inputRef} + onSubmit={handleSubmit} + footer={ + <> + + + + } + > +

    + {t("oauth_hint")} +

    + setCode(e.target.value.replace(/\D/g, ""))} + className="h-12 text-center font-mono tracking-widest text-lg" + placeholder="000000" + autoComplete="one-time-code" + aria-label={t("code_label")} + /> + ); } diff --git a/locales/ar/common.json b/locales/ar/common.json index f302c0a3a..691b31dc5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -76,6 +76,13 @@ "back_to_login": "العودة إلى تسجيل الدخول" } }, + "totp_reauth": { + "title": "انتهت الجلسة", + "description": "تغيّر رمز المصادقة الثنائية. أدخل رمزًا جديدًا من تطبيق المصادقة للمتابعة.", + "oauth_hint": "لكي لا تظهر هذه المطالبة مجددًا، اطلب من المسؤول تفعيل تسجيل الدخول عبر OAuth (خدمة OAuth المدمجة في Stalwart أو موفّر هوية خارجي).", + "code_label": "رمز المصادقة", + "verify": "تحقّق" + }, "sidebar": { "close": "إغلاق", "compose": "إنشاء رسالة", diff --git a/locales/ca/common.json b/locales/ca/common.json index c74f3a6e6..3620809c7 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -76,6 +76,13 @@ "back_to_login": "Torna a l'inici de sessió" } }, + "totp_reauth": { + "title": "La sessió ha caducat", + "description": "El codi de doble factor ha canviat. Introduïu un codi nou de l'aplicació d'autenticació per continuar.", + "oauth_hint": "Perquè aquest avís no torni a aparèixer, demaneu a l'administrador que activi l'inici de sessió amb OAuth (l'OAuth integrat de Stalwart o un proveïdor d'identitat extern).", + "code_label": "Codi d'autenticació", + "verify": "Verifica" + }, "sidebar": { "close": "Tanca", "compose": "Redacta", diff --git a/locales/cs/common.json b/locales/cs/common.json index 04311030b..2b9719d6b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -76,6 +76,13 @@ "back_to_login": "Zpět na přihlášení" } }, + "totp_reauth": { + "title": "Relace vypršela", + "description": "Váš dvoufázový kód se změnil. Pro pokračování zadejte nový kód z ověřovací aplikace.", + "oauth_hint": "Aby se tato výzva neopakovala, požádejte správce o zapnutí přihlašování přes OAuth (vestavěné OAuth ve Stalwartu nebo externího poskytovatele identity).", + "code_label": "Ověřovací kód", + "verify": "Ověřit" + }, "sidebar": { "close": "Zavřít", "compose": "Napsat", diff --git a/locales/da/common.json b/locales/da/common.json index 517caa46f..2abefd24f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -76,6 +76,13 @@ "back_to_login": "Tilbage til login" } }, + "totp_reauth": { + "title": "Sessionen er udløbet", + "description": "Din tofaktorkode er ændret. Indtast en ny kode fra din godkendelsesapp for at fortsætte.", + "oauth_hint": "Bed din administrator om at slå login med OAuth til (Stalwarts indbyggede OAuth eller en ekstern identitetsudbyder), så du ikke bliver spurgt igen.", + "code_label": "Godkendelseskode", + "verify": "Bekræft" + }, "sidebar": { "close": "Luk", "compose": "Ny besked", diff --git a/locales/de/common.json b/locales/de/common.json index 141274200..b1c39a1cf 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -76,6 +76,13 @@ "back_to_login": "Zurück zur Anmeldung" } }, + "totp_reauth": { + "title": "Sitzung abgelaufen", + "description": "Ihr Zwei-Faktor-Code hat sich geändert. Geben Sie einen neuen Code aus Ihrer Authenticator-App ein, um fortzufahren.", + "oauth_hint": "Damit diese Abfrage nicht wiederkommt, bitten Sie Ihre Administration, die Anmeldung über OAuth einzuschalten (das integrierte OAuth von Stalwart oder einen externen Identitätsanbieter).", + "code_label": "Authentifizierungscode", + "verify": "Bestätigen" + }, "sidebar": { "close": "Schließen", "compose": "Verfassen", diff --git a/locales/en/common.json b/locales/en/common.json index e25ebab8b..486064e82 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -76,6 +76,13 @@ "back_to_login": "Back to login" } }, + "totp_reauth": { + "title": "Session expired", + "description": "Your two-factor code has changed. Enter a new code from your authenticator app to continue.", + "oauth_hint": "To stop this prompt from coming back, ask your administrator to turn on OAuth sign-in (Stalwart's built-in OAuth or an external identity provider).", + "code_label": "Authentication code", + "verify": "Verify" + }, "sidebar": { "close": "Close", "compose": "Compose", diff --git a/locales/es/common.json b/locales/es/common.json index 5dd4f01d5..36451b967 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -76,6 +76,13 @@ "back_to_login": "Volver al inicio de sesión" } }, + "totp_reauth": { + "title": "La sesión ha caducado", + "description": "Tu código de doble factor ha cambiado. Introduce un código nuevo de tu aplicación de autenticación para continuar.", + "oauth_hint": "Para que este aviso no vuelva a aparecer, pide a tu administrador que active el inicio de sesión con OAuth (el OAuth integrado de Stalwart o un proveedor de identidad externo).", + "code_label": "Código de autenticación", + "verify": "Verificar" + }, "sidebar": { "close": "Cerrar", "compose": "Redactar", diff --git a/locales/et/common.json b/locales/et/common.json index 36cbb878a..a8bd12a62 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -76,6 +76,13 @@ "back_to_login": "Tagasi" } }, + "totp_reauth": { + "title": "Seanss on aegunud", + "description": "Teie kaheastmelise tuvastuse kood on muutunud. Jätkamiseks sisestage autentimisrakendusest uus kood.", + "oauth_hint": "Et see küsimus ei korduks, paluge administraatoril lülitada sisse OAuth-sisselogimine (Stalwarti sisseehitatud OAuth või väline identiteedipakkuja).", + "code_label": "Autentimiskood", + "verify": "Kinnita" + }, "sidebar": { "close": "Sulge", "compose": "Koosta", diff --git a/locales/fa/common.json b/locales/fa/common.json index 09ab7af4f..e5c0151bf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -76,6 +76,13 @@ "back_to_login": "بازگشت به صفحه ورود" } }, + "totp_reauth": { + "title": "نشست منقضی شد", + "description": "کد احراز هویت دومرحله‌ای شما تغییر کرده است. برای ادامه، کد جدیدی از برنامهٔ احراز هویت وارد کنید.", + "oauth_hint": "برای اینکه این درخواست دوباره نمایش داده نشود، از مدیر بخواهید ورود با OAuth را فعال کند (OAuth داخلی Stalwart یا یک ارائه‌دهندهٔ هویت خارجی).", + "code_label": "کد احراز هویت", + "verify": "تأیید" + }, "sidebar": { "close": "بستن", "compose": "ایمیل جدید", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8be4a0523..42fdc729d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -76,6 +76,13 @@ "back_to_login": "Retour à la connexion" } }, + "totp_reauth": { + "title": "Session expirée", + "description": "Votre code à deux facteurs a changé. Saisissez un nouveau code de votre application d'authentification pour continuer.", + "oauth_hint": "Pour ne plus voir cette demande, demandez à votre administrateur d'activer la connexion OAuth (l'OAuth intégré de Stalwart ou un fournisseur d'identité externe).", + "code_label": "Code d'authentification", + "verify": "Vérifier" + }, "sidebar": { "close": "Fermer", "compose": "Composer", diff --git a/locales/he/common.json b/locales/he/common.json index 9ad7d19ef..bc339c75a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -75,6 +75,13 @@ "jmap_server_label": "שרת", "jmap_server_auto_picked": "שרת שנבחר מדומיין הדוא״ל שלך." }, + "totp_reauth": { + "title": "פג תוקף ההפעלה", + "description": "קוד האימות הדו-שלבי השתנה. כדי להמשיך, הזינו קוד חדש מאפליקציית האימות.", + "oauth_hint": "כדי שהבקשה הזו לא תחזור, בקשו מהמנהל להפעיל כניסה עם OAuth (ה-OAuth המובנה של Stalwart או ספק זהויות חיצוני).", + "code_label": "קוד אימות", + "verify": "אימות" + }, "sidebar": { "unified_mailbox": "תיבת דואר מאוחדת", "close": "סגור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 758e15995..9753edd6e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -76,6 +76,13 @@ "back_to_login": "Vissza a bejelentkezéshez" } }, + "totp_reauth": { + "title": "A munkamenet lejárt", + "description": "A kétlépcsős kód megváltozott. A folytatáshoz adjon meg egy új kódot a hitelesítő alkalmazásból.", + "oauth_hint": "Hogy ez a kérés ne jelenjen meg újra, kérje meg a rendszergazdát, hogy kapcsolja be az OAuth-bejelentkezést (a Stalwart beépített OAuth-ját vagy egy külső identitásszolgáltatót).", + "code_label": "Hitelesítő kód", + "verify": "Ellenőrzés" + }, "sidebar": { "close": "Bezárás", "compose": "Új levél", diff --git a/locales/it/common.json b/locales/it/common.json index cb2f68866..37be7eab2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -76,6 +76,13 @@ "back_to_login": "Torna al login" } }, + "totp_reauth": { + "title": "Sessione scaduta", + "description": "Il codice a due fattori è cambiato. Inserisci un nuovo codice dall'app di autenticazione per continuare.", + "oauth_hint": "Per non ricevere più questa richiesta, chiedi all'amministratore di attivare l'accesso con OAuth (l'OAuth integrato di Stalwart o un provider di identità esterno).", + "code_label": "Codice di autenticazione", + "verify": "Verifica" + }, "sidebar": { "close": "Chiudi", "compose": "Scrivi", diff --git a/locales/ja/common.json b/locales/ja/common.json index a7a76b7bb..86a57673b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -76,6 +76,13 @@ "back_to_login": "ログインに戻る" } }, + "totp_reauth": { + "title": "セッションの有効期限が切れました", + "description": "2 段階認証コードが変わりました。続けるには、認証アプリの新しいコードを入力してください。", + "oauth_hint": "この確認を繰り返さないようにするには、OAuth によるサインイン(Stalwart の組み込み OAuth または外部の ID プロバイダー)を有効にするよう管理者に依頼してください。", + "code_label": "認証コード", + "verify": "確認" + }, "sidebar": { "close": "閉じる", "compose": "作成", diff --git a/locales/ko/common.json b/locales/ko/common.json index 432de0376..21b3fcf13 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -76,6 +76,13 @@ "back_to_login": "로그인 화면으로 돌아가기" } }, + "totp_reauth": { + "title": "세션이 만료되었습니다", + "description": "2단계 인증 코드가 바뀌었습니다. 계속하려면 인증 앱의 새 코드를 입력하세요.", + "oauth_hint": "이 요청이 다시 나타나지 않게 하려면 관리자에게 OAuth 로그인(Stalwart 내장 OAuth 또는 외부 ID 공급자)을 켜 달라고 요청하세요.", + "code_label": "인증 코드", + "verify": "확인" + }, "sidebar": { "close": "닫기", "compose": "메일 쓰기", diff --git a/locales/lv/common.json b/locales/lv/common.json index be3aa7cb9..daf206341 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -76,6 +76,13 @@ "back_to_login": "Atgriezties pie pierakstīšanās" } }, + "totp_reauth": { + "title": "Sesija ir beigusies", + "description": "Jūsu divfaktoru kods ir mainījies. Lai turpinātu, ievadiet jaunu kodu no autentifikācijas lietotnes.", + "oauth_hint": "Lai šis pieprasījums vairs neparādītos, lūdziet administratoram ieslēgt pieteikšanos ar OAuth (Stalwart iebūvēto OAuth vai ārēju identitātes nodrošinātāju).", + "code_label": "Autentifikācijas kods", + "verify": "Apstiprināt" + }, "sidebar": { "close": "Aizvērt", "compose": "Rakstīt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 3ac66f6cb..ecca7cdce 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -76,6 +76,13 @@ "back_to_login": "Нэвтрэх рүү буцах" } }, + "totp_reauth": { + "title": "Сесс дууссан", + "description": "Таны хоёр шатлалт кодыг сольсон байна. Үргэлжлүүлэхийн тулд баталгаажуулах аппаас шинэ код оруулна уу.", + "oauth_hint": "Энэ хүсэлт дахин гарахгүй байлгахын тулд админаас OAuth нэвтрэлтийг (Stalwart-ын суурилуулсан OAuth эсвэл гадны таних үйлчилгээ) асаахыг хүснэ үү.", + "code_label": "Баталгаажуулах код", + "verify": "Баталгаажуулах" + }, "sidebar": { "close": "Хаах", "compose": "Бичилэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index bce385f8e..224084986 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -76,6 +76,13 @@ "back_to_login": "Tilbake til pålogging" } }, + "totp_reauth": { + "title": "Økten er utløpt", + "description": "Tofaktorkoden din er endret. Skriv inn en ny kode fra autentiseringsappen for å fortsette.", + "oauth_hint": "Be administratoren om å slå på pålogging med OAuth (Stalwarts innebygde OAuth eller en ekstern identitetsleverandør), så slipper du denne forespørselen.", + "code_label": "Autentiseringskode", + "verify": "Bekreft" + }, "sidebar": { "close": "Lukk", "compose": "Skriv", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5b111f177..28dea6ba8 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -76,6 +76,13 @@ "back_to_login": "Terug naar inloggen" } }, + "totp_reauth": { + "title": "Sessie verlopen", + "description": "Je tweestapscode is veranderd. Voer een nieuwe code uit je authenticator-app in om door te gaan.", + "oauth_hint": "Vraag je beheerder om aanmelden via OAuth aan te zetten (de ingebouwde OAuth van Stalwart of een externe identiteitsprovider), dan krijg je deze vraag niet meer.", + "code_label": "Verificatiecode", + "verify": "Bevestigen" + }, "sidebar": { "close": "Sluiten", "compose": "Nieuw bericht", diff --git a/locales/pl/common.json b/locales/pl/common.json index 079d25f31..40806f3d4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -76,6 +76,13 @@ "back_to_login": "Powrót do logowania" } }, + "totp_reauth": { + "title": "Sesja wygasła", + "description": "Twój kod dwuskładnikowy się zmienił. Aby kontynuować, wpisz nowy kod z aplikacji uwierzytelniającej.", + "oauth_hint": "Aby ta prośba się nie powtarzała, poproś administratora o włączenie logowania przez OAuth (wbudowany OAuth w Stalwart lub zewnętrzny dostawca tożsamości).", + "code_label": "Kod uwierzytelniający", + "verify": "Zweryfikuj" + }, "sidebar": { "close": "Zamknij", "compose": "Napisz", diff --git a/locales/pt/common.json b/locales/pt/common.json index 38fcaf6d8..fe8cab340 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -76,6 +76,13 @@ "back_to_login": "Voltar ao login" } }, + "totp_reauth": { + "title": "Sessão expirada", + "description": "O seu código de dois fatores mudou. Introduza um código novo da aplicação de autenticação para continuar.", + "oauth_hint": "Para deixar de ver este pedido, peça ao administrador que ative o início de sessão com OAuth (o OAuth integrado do Stalwart ou um fornecedor de identidade externo).", + "code_label": "Código de autenticação", + "verify": "Verificar" + }, "sidebar": { "close": "Fechar", "compose": "Escrever", diff --git a/locales/ro/common.json b/locales/ro/common.json index 88868221f..9d5783f49 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -76,6 +76,13 @@ "back_to_login": "Înapoi la autentificare" } }, + "totp_reauth": { + "title": "Sesiunea a expirat", + "description": "Codul de autentificare în doi pași s-a schimbat. Introduceți un cod nou din aplicația de autentificare pentru a continua.", + "oauth_hint": "Ca această solicitare să nu mai apară, cereți administratorului să activeze conectarea prin OAuth (OAuth-ul integrat în Stalwart sau un furnizor de identitate extern).", + "code_label": "Cod de autentificare", + "verify": "Verifică" + }, "sidebar": { "close": "Închide", "compose": "Compune", diff --git a/locales/ru/common.json b/locales/ru/common.json index 63eafde10..a0a88227b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -76,6 +76,13 @@ "back_to_login": "Вернуться ко входу" } }, + "totp_reauth": { + "title": "Сеанс истёк", + "description": "Код двухфакторной аутентификации сменился. Чтобы продолжить, введите новый код из приложения-аутентификатора.", + "oauth_hint": "Чтобы этот запрос больше не появлялся, попросите администратора включить вход через OAuth (встроенный OAuth Stalwart или внешний поставщик удостоверений).", + "code_label": "Код аутентификации", + "verify": "Подтвердить" + }, "sidebar": { "close": "Закрыть", "compose": "Создать", diff --git a/locales/sk/common.json b/locales/sk/common.json index 29ba9a7e2..e8c76b9b5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -76,6 +76,13 @@ "back_to_login": "Späť na prihlásenie" } }, + "totp_reauth": { + "title": "Relácia vypršala", + "description": "Váš dvojfaktorový kód sa zmenil. Ak chcete pokračovať, zadajte nový kód z overovacej aplikácie.", + "oauth_hint": "Aby sa táto výzva neopakovala, požiadajte správcu o zapnutie prihlasovania cez OAuth (vstavané OAuth v Stalwarte alebo externého poskytovateľa identity).", + "code_label": "Overovací kód", + "verify": "Overiť" + }, "sidebar": { "unified_mailbox": "Zjednotená schránka", "close": "Zavrieť", diff --git a/locales/th/common.json b/locales/th/common.json index 8ed6ed3ef..cf468cab5 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -177,6 +177,13 @@ "back_to_login": "กลับไปหน้าเข้าสู่ระบบ" } }, + "totp_reauth": { + "title": "เซสชันหมดอายุ", + "description": "รหัสยืนยันตัวตนสองขั้นตอนของคุณเปลี่ยนแล้ว ป้อนรหัสใหม่จากแอปยืนยันตัวตนเพื่อดำเนินการต่อ", + "oauth_hint": "หากไม่ต้องการให้ระบบถามอีก โปรดขอให้ผู้ดูแลระบบเปิดการลงชื่อเข้าใช้ด้วย OAuth (OAuth ในตัวของ Stalwart หรือผู้ให้บริการข้อมูลประจำตัวภายนอก)", + "code_label": "รหัสยืนยันตัวตน", + "verify": "ยืนยัน" + }, "sidebar": { "close": "ปิดแถบด้านข้าง", "compose": "เขียนจดหมาย", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6175b06c0..1f1fe35bc 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -76,6 +76,13 @@ "back_to_login": "Giriş sayfasına dön" } }, + "totp_reauth": { + "title": "Oturumun süresi doldu", + "description": "İki adımlı doğrulama kodunuz değişti. Devam etmek için kimlik doğrulama uygulamanızdan yeni bir kod girin.", + "oauth_hint": "Bu isteğin tekrar çıkmaması için yöneticinizden OAuth ile oturum açmayı (Stalwart'ın yerleşik OAuth'u veya harici bir kimlik sağlayıcı) açmasını isteyin.", + "code_label": "Doğrulama kodu", + "verify": "Doğrula" + }, "sidebar": { "close": "Kapat", "compose": "Yeni E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index b6c42bf7c..f27652d00 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -76,6 +76,13 @@ "back_to_login": "Назад до входу" } }, + "totp_reauth": { + "title": "Сеанс завершився", + "description": "Код двофакторної автентифікації змінився. Щоб продовжити, введіть новий код із застосунку автентифікації.", + "oauth_hint": "Щоб цей запит більше не з'являвся, попросіть адміністратора ввімкнути вхід через OAuth (вбудований OAuth Stalwart або зовнішнього постачальника ідентичності).", + "code_label": "Код автентифікації", + "verify": "Підтвердити" + }, "sidebar": { "close": "Закрити", "compose": "Скласти", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3c122fa20..b81169291 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -76,6 +76,13 @@ "back_to_login": "返回登入頁面" } }, + "totp_reauth": { + "title": "工作階段已過期", + "description": "您的雙重驗證碼已變更。請輸入驗證器應用程式中的新驗證碼以繼續。", + "oauth_hint": "若不想再看到此提示,請請管理員啟用 OAuth 登入(Stalwart 內建的 OAuth 或外部身分提供者)。", + "code_label": "驗證碼", + "verify": "驗證" + }, "sidebar": { "close": "關閉", "compose": "撰寫郵件", diff --git a/locales/zh/common.json b/locales/zh/common.json index 890c771e7..c1f8ef6ea 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -76,6 +76,13 @@ "back_to_login": "返回登录" } }, + "totp_reauth": { + "title": "会话已过期", + "description": "您的两步验证码已更新。请输入身份验证器应用中的新验证码以继续。", + "oauth_hint": "如不想再看到此提示,请让管理员启用 OAuth 登录(Stalwart 内置的 OAuth 或外部身份提供商)。", + "code_label": "验证码", + "verify": "验证" + }, "sidebar": { "close": "关闭", "compose": "撰写邮件", From 57c283e730f00da25fe8a7b8d387421142930beb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:35:12 +0100 Subject: [PATCH 10/29] fix: translate the login page's subtitle, theme menu and version details, and open the version details from the keyboard --- .../login/__tests__/login-page-i18n.test.tsx | 65 ++++++++++ app/(main)/[locale]/login/page.tsx | 120 ++++++++++++------ locales/ar/common.json | 12 ++ locales/ca/common.json | 12 ++ locales/cs/common.json | 12 ++ locales/da/common.json | 12 ++ locales/de/common.json | 12 ++ locales/en/common.json | 12 ++ locales/es/common.json | 12 ++ locales/et/common.json | 12 ++ locales/fa/common.json | 12 ++ locales/fr/common.json | 12 ++ locales/he/common.json | 12 ++ locales/hu/common.json | 12 ++ locales/it/common.json | 12 ++ locales/ja/common.json | 12 ++ locales/ko/common.json | 12 ++ locales/lv/common.json | 12 ++ locales/mn/common.json | 12 ++ locales/nb/common.json | 12 ++ locales/nl/common.json | 12 ++ locales/pl/common.json | 12 ++ locales/pt/common.json | 12 ++ locales/ro/common.json | 12 ++ locales/ru/common.json | 12 ++ locales/sk/common.json | 12 ++ locales/th/common.json | 12 ++ locales/tr/common.json | 12 ++ locales/uk/common.json | 12 ++ locales/zh-TW/common.json | 12 ++ locales/zh/common.json | 12 ++ 31 files changed, 493 insertions(+), 40 deletions(-) create mode 100644 app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx diff --git a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx new file mode 100644 index 000000000..40b45873c --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The login page in another language: the subtitle, the theme menu and the +// version details come from the translations, and the version details open +// from the keyboard, not only on hover. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'de' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + loginShowHeading: true, + loginShowSubtitle: true, + loginShowVersion: true, + loginShowTotp: true, + loginShowRememberMe: true, + }), +})); + +const { default: LoginPage } = await import('../page'); + +describe('login page translations', () => { + it('shows the translated subtitle whatever the app is called', async () => { + render(); + expect(await screen.findByText('subtitle', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(screen.queryByText('Sign in to your account')).toBeNull(); + }, 30_000); + + it('labels the theme menu from the translations', async () => { + render(); + const button = await screen.findByRole('button', { name: 'label: system' }, { timeout: 10000 }); + fireEvent.click(button); + expect(screen.getByRole('menu', { name: 'label' })).toBeInTheDocument(); + expect(screen.getByRole('menuitemradio', { name: 'dark' })).toBeInTheDocument(); + }, 30_000); + + it('opens the version details from a button', async () => { + render(); + const trigger = await screen.findByRole('button', { name: /^v\d/ }, { timeout: 10000 }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { fireEvent.click(trigger); }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + const details = screen.getByRole('group', { name: 'details' }); + expect(details).toHaveTextContent('version_line'); + expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument(); + act(() => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index efc06c706..76cb20e1e 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { Suspense, useState, useEffect, useRef, useCallback } from "react"; +import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; @@ -57,19 +58,46 @@ function findServerByDomain(servers: PublicJmapServerEntry[], email: string | un const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; +/** Labels are keys under settings.appearance.theme. */ const THEME_OPTIONS = [ - { value: "light" as const, icon: Sun, label: "Light" }, - { value: "dark" as const, icon: Moon, label: "Dark" }, - { value: "system" as const, icon: Monitor, label: "System" }, + { value: "light" as const, icon: Sun, label: "light" }, + { value: "dark" as const, icon: Moon, label: "dark" }, + { value: "system" as const, icon: Monitor, label: "system" }, ]; function VersionBadge() { + const t = useTranslations("login.version"); const [copied, setCopied] = useState(false); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const detailsId = useId(); const banner = useUpdateStore(useShallow(selectBanner)); const startPolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startPolling(); }, [startPolling]); + // Opened by click or keyboard (hover alone left keyboard and touch users + // without it): closed again by Escape or a click elsewhere. + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // Stays English: it is pasted into bug reports. const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`; const handleCopy = () => { @@ -83,10 +111,10 @@ function VersionBadge() { const triggerText = !banner ? `v${APP_VERSION}` : banner.severity === "security" - ? "Security update available" + ? t("security_update") : banner.severity === "deprecated" - ? "Version no longer supported" - : "New version available"; + ? t("unsupported") + : t("new_version"); const triggerColor = !banner ? "text-muted-foreground/40" @@ -95,41 +123,52 @@ function VersionBadge() { : "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400"; const triggerClass = cn( - "peer text-center text-xs transition-colors", + "peer text-center text-xs transition-colors rounded-sm underline-offset-2 hover:underline", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", triggerColor, - banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default", - ); - - const trigger = banner?.url ? ( - - {triggerText} - - ) : ( -

    {triggerText}

    ); return ( -
    - {trigger} -
    +
    + +
    -

    Version: {APP_VERSION}

    -

    Build: {GIT_COMMIT}

    +

    {t("version_line", { value: APP_VERSION })}

    +

    {t("build_line", { value: GIT_COMMIT })}

    {banner?.latest && ( -

    Latest: {banner.latest}

    +

    {t("latest_line", { value: banner.latest })}

    )} {banner?.advisory && ( -

    {banner.advisory}

    +

    {banner.advisory}

    + )} + {banner?.url && ( + + {t("release_notes")} + )}
    - + + {copied ? : } +
    @@ -139,6 +178,7 @@ function VersionBadge() { function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); + const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; @@ -988,12 +1028,12 @@ function LoginPageContent() { ? "bg-secondary border-border text-foreground shadow-md" : "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border" )} - aria-label={`Theme: ${currentThemeOption.label}`} + aria-label={`${tTheme("label")}: ${tTheme(currentThemeOption.label)}`} aria-expanded={showThemeMenu} aria-haspopup="menu" > - {currentThemeOption.label} + {tTheme(currentThemeOption.label)} {showThemeMenu && ( @@ -1002,7 +1042,7 @@ function LoginPageContent() { onKeyDown={onThemeMenuKeyDown} className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50" role="menu" - aria-label="Theme selection" + aria-label={tTheme("label")} > {THEME_OPTIONS.map((option) => { const Icon = option.icon; @@ -1022,7 +1062,7 @@ function LoginPageContent() { )} > - {option.label} + {tTheme(option.label)} {isActive && } ); @@ -1148,12 +1188,12 @@ function LoginPageContent() { ? "bg-secondary border-border text-foreground shadow-md" : "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border" )} - aria-label={`Theme: ${currentThemeOption.label}`} + aria-label={`${tTheme("label")}: ${tTheme(currentThemeOption.label)}`} aria-expanded={showThemeMenu} aria-haspopup="menu" > - {currentThemeOption.label} + {tTheme(currentThemeOption.label)} {showThemeMenu && ( @@ -1162,7 +1202,7 @@ function LoginPageContent() { onKeyDown={onThemeMenuKeyDown} className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50" role="menu" - aria-label="Theme selection" + aria-label={tTheme("label")} > {THEME_OPTIONS.map((option) => { const Icon = option.icon; @@ -1182,7 +1222,7 @@ function LoginPageContent() { )} > - {option.label} + {tTheme(option.label)} {isActive && } ); @@ -1211,7 +1251,7 @@ function LoginPageContent() { )} {loginShowSubtitle && (

    - {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")} + {isAddAccountMode ? t("add_account_subtitle") : t("subtitle")}

    )}
    diff --git a/locales/ar/common.json b/locales/ar/common.json index 691b31dc5..c1bc8093e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "تسجيل الدخول عبر SSO", "add_account_title": "إضافة حساب", "add_account_subtitle": "تسجيل الدخول بحساب آخر", + "subtitle": "سجّل الدخول إلى حسابك", + "version": { + "details": "تفاصيل الإصدار", + "version_line": "الإصدار: {value}", + "build_line": "البنية: {value}", + "latest_line": "الأحدث: {value}", + "copy": "نسخ معلومات الإصدار", + "security_update": "يتوفر تحديث أمني", + "unsupported": "لم يعد هذا الإصدار مدعومًا", + "new_version": "يتوفر إصدار جديد", + "release_notes": "ملاحظات الإصدار" + }, "cancel": "إلغاء", "website": "الموقع الإلكتروني", "imprint": "بيانات الناشر", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3620809c7..2265c1db3 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Inicia la sessió amb SSO", "add_account_title": "Afegeix un compte", "add_account_subtitle": "Inicieu la sessió amb un altre compte", + "subtitle": "Inicieu la sessió al vostre compte", + "version": { + "details": "Detalls de la versió", + "version_line": "Versió: {value}", + "build_line": "Compilació: {value}", + "latest_line": "Més recent: {value}", + "copy": "Copia la informació de la versió", + "security_update": "Hi ha una actualització de seguretat", + "unsupported": "Aquesta versió ja no té suport", + "new_version": "Hi ha una versió nova", + "release_notes": "Notes de la versió" + }, "cancel": "Cancel·la", "website": "Lloc web", "imprint": "Avís legal", diff --git a/locales/cs/common.json b/locales/cs/common.json index 2b9719d6b..a5751e9f9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Přihlásit se přes SSO", "add_account_title": "Přidat účet", "add_account_subtitle": "Přihlásit se k jinému účtu", + "subtitle": "Přihlaste se ke svému účtu", + "version": { + "details": "Podrobnosti o verzi", + "version_line": "Verze: {value}", + "build_line": "Sestavení: {value}", + "latest_line": "Nejnovější: {value}", + "copy": "Kopírovat informace o verzi", + "security_update": "Je k dispozici bezpečnostní aktualizace", + "unsupported": "Tato verze už není podporována", + "new_version": "Je k dispozici nová verze", + "release_notes": "Poznámky k vydání" + }, "cancel": "Zrušit", "website": "Webové stránky", "imprint": "Tiráž", diff --git a/locales/da/common.json b/locales/da/common.json index 2abefd24f..69bec66e7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Log ind med SSO", "add_account_title": "Tilføj konto", "add_account_subtitle": "Log ind med en anden konto", + "subtitle": "Log ind på din konto", + "version": { + "details": "Versionsdetaljer", + "version_line": "Version: {value}", + "build_line": "Build: {value}", + "latest_line": "Nyeste: {value}", + "copy": "Kopiér versionsoplysninger", + "security_update": "Sikkerhedsopdatering tilgængelig", + "unsupported": "Versionen understøttes ikke længere", + "new_version": "Ny version tilgængelig", + "release_notes": "Udgivelsesnoter" + }, "cancel": "Annuller", "website": "Hjemmeside", "imprint": "Impressum", diff --git a/locales/de/common.json b/locales/de/common.json index b1c39a1cf..e78c5a2d6 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Mit SSO anmelden", "add_account_title": "Konto hinzufügen", "add_account_subtitle": "Mit einem anderen Konto anmelden", + "subtitle": "Melden Sie sich bei Ihrem Konto an", + "version": { + "details": "Versionsdetails", + "version_line": "Version: {value}", + "build_line": "Build: {value}", + "latest_line": "Neueste: {value}", + "copy": "Versionsinfo kopieren", + "security_update": "Sicherheitsupdate verfügbar", + "unsupported": "Version wird nicht mehr unterstützt", + "new_version": "Neue Version verfügbar", + "release_notes": "Versionshinweise" + }, "cancel": "Abbrechen", "website": "Webseite", "imprint": "Impressum", diff --git a/locales/en/common.json b/locales/en/common.json index 486064e82..0eefae9e6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Sign in with SSO", "add_account_title": "Add Account", "add_account_subtitle": "Sign in with another account", + "subtitle": "Sign in to your account", + "version": { + "details": "Version details", + "version_line": "Version: {value}", + "build_line": "Build: {value}", + "latest_line": "Latest: {value}", + "copy": "Copy version info", + "security_update": "Security update available", + "unsupported": "Version no longer supported", + "new_version": "New version available", + "release_notes": "Release notes" + }, "cancel": "Cancel", "website": "Website", "imprint": "Imprint", diff --git a/locales/es/common.json b/locales/es/common.json index 36451b967..9a8105aa1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Iniciar sesión con SSO", "add_account_title": "Agregar cuenta", "add_account_subtitle": "Iniciar sesión con otra cuenta", + "subtitle": "Inicia sesión en tu cuenta", + "version": { + "details": "Detalles de la versión", + "version_line": "Versión: {value}", + "build_line": "Compilación: {value}", + "latest_line": "Última: {value}", + "copy": "Copiar información de la versión", + "security_update": "Actualización de seguridad disponible", + "unsupported": "Esta versión ya no tiene soporte", + "new_version": "Nueva versión disponible", + "release_notes": "Notas de la versión" + }, "cancel": "Cancelar", "website": "Sitio web", "imprint": "Aviso legal", diff --git a/locales/et/common.json b/locales/et/common.json index a8bd12a62..5a9692f60 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Logi sisse SSO-ga", "add_account_title": "Lisa konto", "add_account_subtitle": "Logi sisse teise kontoga", + "subtitle": "Logige oma kontole sisse", + "version": { + "details": "Versiooni üksikasjad", + "version_line": "Versioon: {value}", + "build_line": "Järk: {value}", + "latest_line": "Uusim: {value}", + "copy": "Kopeeri versiooniteave", + "security_update": "Saadaval on turvavärskendus", + "unsupported": "Seda versiooni enam ei toetata", + "new_version": "Saadaval on uus versioon", + "release_notes": "Väljalaskemärkmed" + }, "cancel": "Tühista", "website": "Veebileht", "imprint": "Väljaandja andmed", diff --git a/locales/fa/common.json b/locales/fa/common.json index e5c0151bf..da6a7f2cc 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "ورود با SSO", "add_account_title": "افزودن حساب", "add_account_subtitle": "ورود با حساب دیگر", + "subtitle": "به حساب خود وارد شوید", + "version": { + "details": "جزئیات نسخه", + "version_line": "نسخه: {value}", + "build_line": "بیلد: {value}", + "latest_line": "جدیدترین: {value}", + "copy": "کپی اطلاعات نسخه", + "security_update": "به‌روزرسانی امنیتی موجود است", + "unsupported": "این نسخه دیگر پشتیبانی نمی‌شود", + "new_version": "نسخهٔ جدید موجود است", + "release_notes": "یادداشت‌های انتشار" + }, "cancel": "انصراف", "website": "وبسایت", "imprint": "اطلاعات حقوقی", diff --git a/locales/fr/common.json b/locales/fr/common.json index 42fdc729d..6f8761056 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Se connecter avec SSO", "add_account_title": "Ajouter un compte", "add_account_subtitle": "Se connecter avec un autre compte", + "subtitle": "Connectez-vous à votre compte", + "version": { + "details": "Détails de la version", + "version_line": "Version : {value}", + "build_line": "Build : {value}", + "latest_line": "Dernière : {value}", + "copy": "Copier les informations de version", + "security_update": "Mise à jour de sécurité disponible", + "unsupported": "Cette version n'est plus prise en charge", + "new_version": "Nouvelle version disponible", + "release_notes": "Notes de version" + }, "cancel": "Annuler", "website": "Site web", "imprint": "Mentions légales", diff --git a/locales/he/common.json b/locales/he/common.json index bc339c75a..011fde4e8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -47,6 +47,18 @@ "sign_in_sso": "היכנס באמצעות SSO", "add_account_title": "הוסף חשבון", "add_account_subtitle": "היכנס עם חשבון אחר", + "subtitle": "התחברו לחשבון שלכם", + "version": { + "details": "פרטי הגרסה", + "version_line": "גרסה: {value}", + "build_line": "בנייה: {value}", + "latest_line": "העדכנית ביותר: {value}", + "copy": "העתקת פרטי הגרסה", + "security_update": "יש עדכון אבטחה", + "unsupported": "הגרסה הזו כבר לא נתמכת", + "new_version": "יש גרסה חדשה", + "release_notes": "הערות שחרור" + }, "cancel": "ביטול", "website": "אתר", "imprint": "פרטי מוציא לאור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9753edd6e..754305723 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Bejelentkezés SSO-val", "add_account_title": "Fiók hozzáadása", "add_account_subtitle": "Bejelentkezés másik fiókkal", + "subtitle": "Jelentkezzen be a fiókjába", + "version": { + "details": "Verzió részletei", + "version_line": "Verzió: {value}", + "build_line": "Build: {value}", + "latest_line": "Legújabb: {value}", + "copy": "Verzióadatok másolása", + "security_update": "Biztonsági frissítés érhető el", + "unsupported": "Ez a verzió már nem támogatott", + "new_version": "Új verzió érhető el", + "release_notes": "Kiadási megjegyzések" + }, "cancel": "Mégse", "website": "Weboldal", "imprint": "Impresszum", diff --git a/locales/it/common.json b/locales/it/common.json index 37be7eab2..119871be5 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Accedi con SSO", "add_account_title": "Aggiungi account", "add_account_subtitle": "Accedi con un altro account", + "subtitle": "Accedi al tuo account", + "version": { + "details": "Dettagli versione", + "version_line": "Versione: {value}", + "build_line": "Build: {value}", + "latest_line": "Ultima: {value}", + "copy": "Copia informazioni sulla versione", + "security_update": "Aggiornamento di sicurezza disponibile", + "unsupported": "Versione non più supportata", + "new_version": "Nuova versione disponibile", + "release_notes": "Note di rilascio" + }, "cancel": "Annulla", "website": "Sito web", "imprint": "Note legali", diff --git a/locales/ja/common.json b/locales/ja/common.json index 86a57673b..489d7f48f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "SSOでサインイン", "add_account_title": "アカウントを追加", "add_account_subtitle": "別のアカウントでサインイン", + "subtitle": "アカウントにサインイン", + "version": { + "details": "バージョンの詳細", + "version_line": "バージョン: {value}", + "build_line": "ビルド: {value}", + "latest_line": "最新: {value}", + "copy": "バージョン情報をコピー", + "security_update": "セキュリティ更新があります", + "unsupported": "このバージョンはサポートが終了しました", + "new_version": "新しいバージョンがあります", + "release_notes": "リリースノート" + }, "cancel": "キャンセル", "website": "ウェブサイト", "imprint": "サイト運営者情報", diff --git a/locales/ko/common.json b/locales/ko/common.json index 21b3fcf13..667707691 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "SSO로 로그인", "add_account_title": "계정 추가", "add_account_subtitle": "다른 계정으로 로그인하기", + "subtitle": "계정에 로그인하세요", + "version": { + "details": "버전 정보", + "version_line": "버전: {value}", + "build_line": "빌드: {value}", + "latest_line": "최신: {value}", + "copy": "버전 정보 복사", + "security_update": "보안 업데이트가 있습니다", + "unsupported": "더 이상 지원되지 않는 버전입니다", + "new_version": "새 버전이 있습니다", + "release_notes": "릴리스 노트" + }, "cancel": "취소", "website": "웹사이트", "imprint": "회사 소개", diff --git a/locales/lv/common.json b/locales/lv/common.json index daf206341..53fe7511a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Pierakstīties ar SSO", "add_account_title": "Pievienot kontu", "add_account_subtitle": "Pierakstīties citā kontā", + "subtitle": "Piesakieties savā kontā", + "version": { + "details": "Versijas informācija", + "version_line": "Versija: {value}", + "build_line": "Būvējums: {value}", + "latest_line": "Jaunākā: {value}", + "copy": "Kopēt versijas informāciju", + "security_update": "Pieejams drošības atjauninājums", + "unsupported": "Šī versija vairs netiek atbalstīta", + "new_version": "Pieejama jauna versija", + "release_notes": "Laidiena piezīmes" + }, "cancel": "Atcelt", "website": "Tīmekļa vietne", "imprint": "Rekvizīti", diff --git a/locales/mn/common.json b/locales/mn/common.json index ecca7cdce..c673150a5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "SSO-ээр нэвтрэх", "add_account_title": "Бүртгэл нэмэх", "add_account_subtitle": "Өөр бүртгэлээр нэвтрэх", + "subtitle": "Бүртгэлдээ нэвтэрнэ үү", + "version": { + "details": "Хувилбарын дэлгэрэнгүй", + "version_line": "Хувилбар: {value}", + "build_line": "Бүтээц: {value}", + "latest_line": "Хамгийн сүүлийн: {value}", + "copy": "Хувилбарын мэдээллийг хуулах", + "security_update": "Аюулгүй байдлын шинэчлэл гарсан", + "unsupported": "Энэ хувилбарыг дэмжихээ больсон", + "new_version": "Шинэ хувилбар гарсан", + "release_notes": "Хувилбарын тэмдэглэл" + }, "cancel": "Болих", "website": "Вэбсайт", "imprint": "Хуулийн мэдээлэл", diff --git a/locales/nb/common.json b/locales/nb/common.json index 224084986..8b25396ba 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Logg på med SSO", "add_account_title": "Legg til konto", "add_account_subtitle": "Logg på med en annen konto", + "subtitle": "Logg på kontoen din", + "version": { + "details": "Versjonsdetaljer", + "version_line": "Versjon: {value}", + "build_line": "Bygg: {value}", + "latest_line": "Nyeste: {value}", + "copy": "Kopier versjonsinformasjon", + "security_update": "Sikkerhetsoppdatering tilgjengelig", + "unsupported": "Versjonen støttes ikke lenger", + "new_version": "Ny versjon tilgjengelig", + "release_notes": "Utgivelsesnotater" + }, "cancel": "Avbryt", "website": "Nettsted", "imprint": "Juridisk informasjon", diff --git a/locales/nl/common.json b/locales/nl/common.json index 28dea6ba8..d501ec9cd 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Inloggen met SSO", "add_account_title": "Account toevoegen", "add_account_subtitle": "Inloggen met een ander account", + "subtitle": "Meld je aan bij je account", + "version": { + "details": "Versiedetails", + "version_line": "Versie: {value}", + "build_line": "Build: {value}", + "latest_line": "Nieuwste: {value}", + "copy": "Versie-informatie kopiëren", + "security_update": "Beveiligingsupdate beschikbaar", + "unsupported": "Deze versie wordt niet meer ondersteund", + "new_version": "Nieuwe versie beschikbaar", + "release_notes": "Releaseopmerkingen" + }, "cancel": "Annuleren", "website": "Website", "imprint": "Colofon", diff --git a/locales/pl/common.json b/locales/pl/common.json index 40806f3d4..4ea8489d4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Zaloguj się przez SSO", "add_account_title": "Dodaj konto", "add_account_subtitle": "Zaloguj się na inne konto", + "subtitle": "Zaloguj się na swoje konto", + "version": { + "details": "Szczegóły wersji", + "version_line": "Wersja: {value}", + "build_line": "Kompilacja: {value}", + "latest_line": "Najnowsza: {value}", + "copy": "Kopiuj informacje o wersji", + "security_update": "Dostępna aktualizacja zabezpieczeń", + "unsupported": "Ta wersja nie jest już wspierana", + "new_version": "Dostępna nowa wersja", + "release_notes": "Informacje o wydaniu" + }, "cancel": "Anuluj", "website": "Strona internetowa", "imprint": "Impressum", diff --git a/locales/pt/common.json b/locales/pt/common.json index fe8cab340..175c6b5e2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Entrar com SSO", "add_account_title": "Adicionar conta", "add_account_subtitle": "Entrar com outra conta", + "subtitle": "Inicie sessão na sua conta", + "version": { + "details": "Detalhes da versão", + "version_line": "Versão: {value}", + "build_line": "Compilação: {value}", + "latest_line": "Mais recente: {value}", + "copy": "Copiar informações da versão", + "security_update": "Atualização de segurança disponível", + "unsupported": "Esta versão já não é suportada", + "new_version": "Nova versão disponível", + "release_notes": "Notas de lançamento" + }, "cancel": "Cancelar", "website": "Site", "imprint": "Informações legais", diff --git a/locales/ro/common.json b/locales/ro/common.json index 9d5783f49..3ee0c0726 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Conectați-vă cu SSO", "add_account_title": "Adăugare cont", "add_account_subtitle": "Conectați-vă cu un alt cont", + "subtitle": "Conectați-vă la contul dvs.", + "version": { + "details": "Detalii despre versiune", + "version_line": "Versiune: {value}", + "build_line": "Build: {value}", + "latest_line": "Cea mai nouă: {value}", + "copy": "Copiază informațiile despre versiune", + "security_update": "Actualizare de securitate disponibilă", + "unsupported": "Versiunea nu mai este acceptată", + "new_version": "Versiune nouă disponibilă", + "release_notes": "Note de lansare" + }, "cancel": "Anulează", "website": "Site web", "imprint": "Mențiuni legale", diff --git a/locales/ru/common.json b/locales/ru/common.json index a0a88227b..e9cba9a9d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Войти через SSO", "add_account_title": "Добавить аккаунт", "add_account_subtitle": "Войдите с другим аккаунтом", + "subtitle": "Войдите в свою учётную запись", + "version": { + "details": "Сведения о версии", + "version_line": "Версия: {value}", + "build_line": "Сборка: {value}", + "latest_line": "Последняя: {value}", + "copy": "Скопировать сведения о версии", + "security_update": "Доступно обновление безопасности", + "unsupported": "Эта версия больше не поддерживается", + "new_version": "Доступна новая версия", + "release_notes": "Примечания к выпуску" + }, "cancel": "Отмена", "website": "Веб-сайт", "imprint": "Выходные данные", diff --git a/locales/sk/common.json b/locales/sk/common.json index e8c76b9b5..ed206d5a0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Prihlásiť sa cez SSO", "add_account_title": "Pridať účet", "add_account_subtitle": "Prihlásiť sa iným účtom", + "subtitle": "Prihláste sa do svojho účtu", + "version": { + "details": "Podrobnosti o verzii", + "version_line": "Verzia: {value}", + "build_line": "Zostavenie: {value}", + "latest_line": "Najnovšia: {value}", + "copy": "Kopírovať informácie o verzii", + "security_update": "K dispozícii je bezpečnostná aktualizácia", + "unsupported": "Táto verzia už nie je podporovaná", + "new_version": "K dispozícii je nová verzia", + "release_notes": "Poznámky k vydaniu" + }, "cancel": "Zrušiť", "website": "Webová stránka", "imprint": "Impressum", diff --git a/locales/th/common.json b/locales/th/common.json index cf468cab5..14b8c55ef 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -151,6 +151,18 @@ "sign_in_sso": "เข้าสู่ระบบด้วย SSO", "add_account_title": "เพิ่มบัญชี", "add_account_subtitle": "เข้าสู่ระบบด้วยบัญชีอื่น", + "subtitle": "ลงชื่อเข้าใช้บัญชีของคุณ", + "version": { + "details": "รายละเอียดเวอร์ชัน", + "version_line": "เวอร์ชัน: {value}", + "build_line": "บิลด์: {value}", + "latest_line": "ล่าสุด: {value}", + "copy": "คัดลอกข้อมูลเวอร์ชัน", + "security_update": "มีอัปเดตความปลอดภัย", + "unsupported": "เวอร์ชันนี้ไม่ได้รับการสนับสนุนแล้ว", + "new_version": "มีเวอร์ชันใหม่", + "release_notes": "บันทึกประจำรุ่น" + }, "cancel": "ยกเลิก", "website": "เว็บไซต์", "imprint": "ข้อมูลทางกฎหมาย", diff --git a/locales/tr/common.json b/locales/tr/common.json index 1f1fe35bc..4139e1bb0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "SSO ile giriş yap", "add_account_title": "Hesap Ekle", "add_account_subtitle": "Başka bir hesapla giriş yap", + "subtitle": "Hesabınızda oturum açın", + "version": { + "details": "Sürüm ayrıntıları", + "version_line": "Sürüm: {value}", + "build_line": "Derleme: {value}", + "latest_line": "En yeni: {value}", + "copy": "Sürüm bilgisini kopyala", + "security_update": "Güvenlik güncellemesi var", + "unsupported": "Bu sürüm artık desteklenmiyor", + "new_version": "Yeni sürüm var", + "release_notes": "Sürüm notları" + }, "cancel": "İptal", "website": "Web Sitesi", "imprint": "Yasal Bilgiler", diff --git a/locales/uk/common.json b/locales/uk/common.json index f27652d00..f179ca05f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "Увійдіть за допомогою SSO", "add_account_title": "Додати обліковий запис", "add_account_subtitle": "Увійдіть за допомогою іншого облікового запису", + "subtitle": "Увійдіть у свій обліковий запис", + "version": { + "details": "Відомості про версію", + "version_line": "Версія: {value}", + "build_line": "Збірка: {value}", + "latest_line": "Найновіша: {value}", + "copy": "Скопіювати відомості про версію", + "security_update": "Доступне оновлення безпеки", + "unsupported": "Ця версія більше не підтримується", + "new_version": "Доступна нова версія", + "release_notes": "Примітки до випуску" + }, "cancel": "Скасувати", "website": "Веб-сайт", "imprint": "Вихідні дані", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b81169291..160ad6140 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "使用 SSO 登入", "add_account_title": "新增帳號", "add_account_subtitle": "使用其他帳號登入", + "subtitle": "登入您的帳戶", + "version": { + "details": "版本詳細資料", + "version_line": "版本:{value}", + "build_line": "組建:{value}", + "latest_line": "最新:{value}", + "copy": "複製版本資訊", + "security_update": "有可用的安全性更新", + "unsupported": "此版本已不再支援", + "new_version": "有可用的新版本", + "release_notes": "版本資訊" + }, "cancel": "取消", "website": "網站", "imprint": "法律聲明", diff --git a/locales/zh/common.json b/locales/zh/common.json index c1f8ef6ea..e01ceb86d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -50,6 +50,18 @@ "sign_in_sso": "使用 SSO 登录", "add_account_title": "添加账户", "add_account_subtitle": "使用其他账户登录", + "subtitle": "登录您的账户", + "version": { + "details": "版本详情", + "version_line": "版本:{value}", + "build_line": "构建:{value}", + "latest_line": "最新:{value}", + "copy": "复制版本信息", + "security_update": "有可用的安全更新", + "unsupported": "此版本已不再受支持", + "new_version": "有可用的新版本", + "release_notes": "发行说明" + }, "cancel": "取消", "website": "网站", "imprint": "法律信息", From b5dcb04c534a67eaa0ba8ca8e257de3f94e5caa9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:36:01 +0100 Subject: [PATCH 11/29] fix: mirror the login page's theme button and password toggle, and the rail badges, in right-to-left languages --- app/(main)/[locale]/login/page.tsx | 10 +++++----- components/layout/account-switcher.tsx | 2 +- components/layout/navigation-rail.tsx | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 76cb20e1e..d5b972efa 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1017,7 +1017,7 @@ function LoginPageContent() { return (
    {/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} - ); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 20195bdaf..61545ae85 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { X, Download } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { useConfig } from "@/hooks/use-config"; import { withBasePath } from "@/lib/browser-navigation"; @@ -88,7 +90,7 @@ export function PWAInstallPrompt() { const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl); return ( -
    +
    {logoSrc ? ( @@ -98,7 +100,7 @@ export function PWAInstallPrompt() { className="w-8 h-8 shrink-0 object-contain dark:hidden" /> ) : ( - + )} {logoSrc && ( )}
    -

    +

    {t("title", { appName })}

    -

    +

    {t("description")}

    - + + +
    - - + +
    - +
    ); From cca8b617e6ea5559561cbe546720c7ff967e67ac Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:41:24 +0100 Subject: [PATCH 14/29] fix: confirm account removal in the app's dialog, show its remove button on touch screens, and draw every sign-out row the same --- .../account-switcher-remove.test.tsx | 59 +++++++++++++++++++ components/layout/account-switcher.tsx | 46 ++++++++++----- components/layout/navigation-rail.tsx | 2 +- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 32 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 components/layout/__tests__/account-switcher-remove.test.tsx diff --git a/components/layout/__tests__/account-switcher-remove.test.tsx b/components/layout/__tests__/account-switcher-remove.test.tsx new file mode 100644 index 000000000..f287edb60 --- /dev/null +++ b/components/layout/__tests__/account-switcher-remove.test.tsx @@ -0,0 +1,59 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Removing an account from the popover: the app's confirm dialog instead of +// the browser's, a control a touch screen can reach, and sign-out rows in +// the menu's plain text colour. + +const { AccountSwitcher } = await import('../account-switcher'); +const { useAccountStore } = await import('@/stores/account-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +function account(id: string) { + return { + id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`, + displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', + cookieSlot: 0, isConnected: true, isDefault: id === 'a', + }; +} + +const removeAccount = vi.fn(); + +beforeEach(() => { + removeAccount.mockClear(); + useAccountStore.setState({ accounts: [account('a'), account('b')] } as never); + useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never); + useSettingsStore.setState({ interfaceLayout: 'default' } as never); +}); + +describe('removing an account from the popover', () => { + it('asks with the app dialog, not the browser, and removes on confirm', async () => { + const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' })); + + expect(browserConfirm).not.toHaveBeenCalled(); + const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' }); + expect(removeAccount).not.toHaveBeenCalled(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); }); + expect(removeAccount).toHaveBeenCalledWith('b'); + expect(dialog).not.toBeInTheDocument(); + browserConfirm.mockRestore(); + }); + + it('keeps the remove control visible on a touch screen', () => { + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100'); + }); + + it('draws "sign out of all accounts" like the other rows, start-aligned', () => { + render(); + fireEvent.click(screen.getByTestId('account-switcher')); + const all = screen.getByRole('menuitem', { name: 'sign_out_all' }); + expect(all.className).not.toContain('text-destructive'); + expect(all.className).toContain('text-start'); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 756fc7e37..d771a2110 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -16,6 +16,9 @@ import { cn, formatFileSize } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; import { Avatar } from "@/components/ui/avatar"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { IconButton } from "@/components/ui/icon-button"; import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn"; interface AccountSwitcherProps { @@ -103,6 +106,7 @@ export function AccountSwitcher({ const logout = useAuthStore((s) => s.logout); const removeAccount = useAuthStore((s) => s.removeAccount); const logoutAll = useAuthStore((s) => s.logoutAll); + const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); const updatePosition = useCallback(() => { if (!buttonRef.current) return; @@ -190,11 +194,18 @@ export function AccountSwitcher({ router.push(`/login?mode=add-account` as never); }; - const handleRemove = (e: React.MouseEvent, account: AccountEntry) => { + const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => { e.stopPropagation(); const label = toUnicodeEmail(account.email || account.username); - if (!window.confirm(t("remove_account_confirm", { account: label }))) return; - removeAccount(account.id); + // The popover closes first: the dialog is the only thing asking now. + setOpen(false); + const ok = await confirm({ + title: t("remove_account_title"), + message: t("remove_account_confirm", { account: label }), + confirmText: t("remove_account"), + variant: "destructive", + }); + if (ok) removeAccount(account.id); }; const handleLogout = () => { @@ -403,7 +414,7 @@ export function AccountSwitcher({ className={cn( "w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors", isActive ? "bg-accent/50" : "hover:bg-muted", - (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7") + (!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7") )} role="menuitem" disabled={isActive} @@ -449,22 +460,24 @@ export function AccountSwitcher({ {isDraggable && ( )} {!isActive && !account.isDefault && ( - + + )}
    ); @@ -544,7 +557,7 @@ export function AccountSwitcher({
    , document.body )} + ); } diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 0ecc78380..89971cfee 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -869,7 +869,7 @@ export function NavigationRail({ {accounts.length > 1 && ( - -
    -
    -
+ ); } diff --git a/app/(main)/[locale]/files/[[...segments]]/page.tsx b/app/(main)/[locale]/files/[[...segments]]/page.tsx index a9782d6f6..a7a885744 100644 --- a/app/(main)/[locale]/files/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/files/[[...segments]]/page.tsx @@ -1,5 +1,6 @@ import { FilesApp } from "@/components/files/files-app"; import { generateLiteSegmentParams } from "@/lib/lite-static-params"; +import { AppErrorBoundary } from "@/components/error/app-error-boundary"; // Files permalinks (#733): /files//, one path segment per // level, with ?preview= to open a file straight into the preview modal. @@ -14,5 +15,9 @@ export default async function FilesRoute({ params: Promise<{ segments?: string[] }>; }) { const { segments } = await params; - return ; + return ( + + + + ); } diff --git a/app/(main)/[locale]/settings/[[...segments]]/page.tsx b/app/(main)/[locale]/settings/[[...segments]]/page.tsx index 8c9b582d5..e4dbc45e8 100644 --- a/app/(main)/[locale]/settings/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/settings/[[...segments]]/page.tsx @@ -1,5 +1,6 @@ import { SettingsApp } from "@/components/settings/settings-app"; import { generateLiteSegmentParams } from "@/lib/lite-static-params"; +import { AppErrorBoundary } from "@/components/error/app-error-boundary"; // Settings permalinks (#733): /settings/. Without a tab the page falls // back to the last tab the user had open, as before. @@ -14,5 +15,9 @@ export default async function SettingsRoute({ params: Promise<{ segments?: string[] }>; }) { const { segments } = await params; - return ; + return ( + + + + ); } diff --git a/components/error/__tests__/error-fallbacks.test.tsx b/components/error/__tests__/error-fallbacks.test.tsx new file mode 100644 index 000000000..da84607e8 --- /dev/null +++ b/components/error/__tests__/error-fallbacks.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// One error fallback everywhere: the same "Try again", a way back to the +// inbox outside mail, and each app has a boundary of its own that keeps the +// navigation on screen. + +vi.mock('@/components/layout/navigation-rail', () => ({ + NavigationRail: ({ orientation }: { orientation?: string }) =>