From 526b79aaaf38f772091e42c3164dfab1309b5ecd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:18:56 +0100 Subject: [PATCH] feat: ask for the email address first and find the mail server on sign-in Where people may sign in to a server of their own (the custom endpoint setting and the desktop app's standalone mode), the sign-in form asked for a "JMAP Server" first, refused a host without https:// with the browser's own bubble and talked about CORS. Now the email address comes first and the server is looked up from its domain (JMAP well-known, SRV, then the admin's default) through a new budgeted route. The "Mail server" field opens only when nothing is found, takes a bare host, and keeps the CORS hint behind "Having trouble?" (not in the app). Gmail, Outlook.com and iCloud addresses get a plain note that these offer no JMAP. The app's browser button stays the main one and asks for what it needs under the field it needs. Also on the page: the theme button is an icon named "Light or dark", the heading is the app's name only when the admin gave one (else "Sign in", without the subtitle repeating it), "I have a code from my authenticator app", "Add account", and no 11 px text. login-page-i18n.test.tsx now names the app for the subtitle test and waits for the heading, since the default name no longer shows a subtitle, and finds the theme button by its new name. --- .../login-page-desktop-standalone.test.tsx | 117 +++++++++++ .../login/__tests__/login-page-i18n.test.tsx | 19 +- .../login-page-server-lookup.test.tsx | 174 ++++++++++++++++ app/(main)/[locale]/login/page.tsx | 193 ++++++++++++++---- app/api/auth/discover-server/route.ts | 50 +++++ components/auth/login-server-field.tsx | 139 +++++++++++++ lib/__tests__/auth-routes-same-origin.test.ts | 1 + lib/admin/outgoing-hosts-registry.ts | 2 + .../__tests__/discover-server-route.test.ts | 71 +++++++ .../__tests__/mail-server-discovery.test.ts | 125 ++++++++++++ lib/auth/discover-mail-server.ts | 103 ++++++++++ lib/auth/mail-server-address.ts | 76 +++++++ lib/shared-store/server-state-allowlist.ts | 1 + locales/ar/common.json | 20 +- locales/ca/common.json | 20 +- locales/cs/common.json | 20 +- locales/da/common.json | 20 +- locales/de/common.json | 20 +- locales/de/plain.json | 2 - locales/en/common.json | 22 +- locales/en/plain.json | 2 - locales/es/common.json | 20 +- locales/et/common.json | 20 +- locales/fa/common.json | 20 +- locales/fr/common.json | 20 +- locales/he/common.json | 20 +- locales/hu/common.json | 20 +- locales/it/common.json | 20 +- locales/ja/common.json | 20 +- locales/ko/common.json | 20 +- locales/lv/common.json | 20 +- locales/mn/common.json | 20 +- locales/nb/common.json | 20 +- locales/nl/common.json | 20 +- locales/pl/common.json | 20 +- locales/pt/common.json | 20 +- locales/ro/common.json | 20 +- locales/ru/common.json | 20 +- locales/sk/common.json | 20 +- locales/th/common.json | 20 +- locales/tr/common.json | 22 +- locales/uk/common.json | 20 +- locales/vi/common.json | 20 +- locales/vi/plain.json | 2 - locales/zh-TW/common.json | 20 +- locales/zh/common.json | 20 +- scripts/lite/lib.mjs | 2 + 47 files changed, 1509 insertions(+), 174 deletions(-) create mode 100644 app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx create mode 100644 app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx create mode 100644 app/api/auth/discover-server/route.ts create mode 100644 components/auth/login-server-field.tsx create mode 100644 lib/auth/__tests__/discover-server-route.test.ts create mode 100644 lib/auth/__tests__/mail-server-discovery.test.ts create mode 100644 lib/auth/discover-mail-server.ts create mode 100644 lib/auth/mail-server-address.ts diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx new file mode 100644 index 000000000..068ad4e40 --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The desktop app with a mail server of one's own: email first, the server found +// from it, and "Sign in with your browser" as the main button. When the button +// needs something, it says so where that goes. + +// Stubbed, so the flag doesn't stay set for the next file in this worker. +vi.hoisted(() => { vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); }); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + 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: 'Bulwark', + isLoading: false, + error: null, + jmapServerUrl: '', + jmapServers: [], + oauthEnabled: false, + oauthOnly: false, + allowCustomJmapEndpoint: true, + desktopBrowserSignIn: true, + loginShowHeading: true, + }), +})); +const runDesktopBrowserSignIn = vi.hoisted(() => vi.fn(async (_options: { serverUrl: string }) => ({ kind: 'cancelled' as const }))); +vi.mock('@/lib/desktop/browser-sign-in', () => ({ + browserSignInAvailable: () => true, + cancelBrowserSignIn: () => {}, + runDesktopBrowserSignIn, + runInstanceBrowserSignIn: vi.fn(), +})); + +const { default: LoginPage } = await import('../page'); + +let found: Record; + +beforeEach(() => { + found = {}; + runDesktopBrowserSignIn.mockClear(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/auth/discover-server')) { + const domain = (JSON.parse(String(init?.body)).email as string).split('@')[1]; + return new Response(JSON.stringify({ serverUrl: found[domain] ?? null }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +const browserButton = () => screen.findByTestId('desktop-browser-sign-in', {}, { timeout: 10000 }); +const email = () => document.getElementById('username') as HTMLInputElement; +const serverField = () => document.getElementById('jmap-endpoint') as HTMLInputElement | null; + +describe('the desktop app, with a server of one\'s own', () => { + it('puts the email address before the browser button', async () => { + render(); + const button = await browserButton(); + expect(email().compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(serverField()).toBeNull(); + // No website to allow in the app: no CORS hint at all. + expect(screen.queryByTestId('login-server-trouble')).toBeNull(); + }, 30_000); + + it('asks for the address under its field when the browser button is pressed first', async () => { + render(); + fireEvent.click(await browserButton()); + expect(await screen.findByTestId('login-email-needed')).toHaveTextContent('email_needed'); + expect(document.activeElement).toBe(email()); + expect(runDesktopBrowserSignIn).not.toHaveBeenCalled(); + }, 30_000); + + it('sends the browser to the server found from the address', async () => { + found['example.org'] = 'https://mail.example.org'; + render(); + const button = await browserButton(); + fireEvent.change(email(), { target: { value: 'sam@example.org' } }); + fireEvent.click(button); + await waitFor(() => expect(runDesktopBrowserSignIn).toHaveBeenCalled()); + expect(runDesktopBrowserSignIn.mock.calls[0][0].serverUrl).toBe('https://mail.example.org'); + }, 30_000); + + it('opens, marks and focuses the server field when nothing was found', async () => { + render(); + const button = await browserButton(); + fireEvent.change(email(), { target: { value: 'sam@example.net' } }); + fireEvent.click(button); + expect(await screen.findByTestId('login-server-error')).toHaveTextContent('server_needed'); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + expect(serverField()).toHaveAttribute('aria-invalid', 'true'); + expect(screen.queryByTestId('desktop-notice')).toBeNull(); + expect(runDesktopBrowserSignIn).not.toHaveBeenCalled(); + fireEvent.change(serverField()!, { target: { value: 'mail.example.net:8443' } }); + fireEvent.click(button); + await waitFor(() => expect(runDesktopBrowserSignIn).toHaveBeenCalled()); + expect(runDesktopBrowserSignIn.mock.calls[0][0].serverUrl).toBe('https://mail.example.net:8443'); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx index ec200e05b..21a3aebfa 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx @@ -41,14 +41,21 @@ const { useAuthStore } = await import('@/stores/auth-store'); 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(); + // Under an app name the admin gave: with the default name the heading is + // "Sign in" itself, and the subtitle would repeat it. + configOverrides.value = { appName: 'Acme Mail' }; + try { + render(); + expect(await screen.findByText('subtitle', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(screen.queryByText('Sign in to your account')).toBeNull(); + } finally { + configOverrides.value = {}; + } }, 30_000); it('labels the theme menu from the translations', async () => { render(); - const button = await screen.findByRole('button', { name: 'label: system' }, { timeout: 10000 }); + const button = await screen.findByRole('button', { name: 'theme_button: system' }, { timeout: 10000 }); fireEvent.click(button); expect(screen.getByRole('menu', { name: 'label' })).toBeInTheDocument(); expect(screen.getByRole('menuitemradio', { name: 'dark' })).toBeInTheDocument(); @@ -87,7 +94,7 @@ describe('login page translations', () => { describe('login page notices', () => { it('asks for the second factor as the next step, not as a failure (#1422)', async () => { render(); - await screen.findByText('subtitle', {}, { timeout: 10000 }); + await screen.findByRole('heading', { level: 1 }, { timeout: 10000 }); // The page clears the error as it mounts; a sign-in attempt sets it after. act(() => { useAuthStore.setState({ error: 'totp_required' }); }); const notice = await screen.findByTestId('login-error', {}, { timeout: 10000 }); @@ -99,7 +106,7 @@ describe('login page notices', () => { it('keeps a wrong password an error', async () => { render(); - await screen.findByText('subtitle', {}, { timeout: 10000 }); + await screen.findByRole('heading', { level: 1 }, { timeout: 10000 }); // The page clears the error as it mounts; a sign-in attempt sets it after. act(() => { useAuthStore.setState({ error: 'invalid_credentials' }); }); const notice = await screen.findByTestId('login-error', {}, { timeout: 10000 }); diff --git a/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx new file mode 100644 index 000000000..326e9f2f2 --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// A server of one's own (ALLOW_CUSTOM_JMAP_ENDPOINT): the email address comes first, +// the server is looked up from it, and the field for it only opens when nothing is +// found. A host typed without https:// works. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + 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, +})); + +const configOverrides = vi.hoisted(() => ({ value: {} as Record })); +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: '', + jmapServers: [], + oauthEnabled: false, + oauthOnly: false, + allowCustomJmapEndpoint: true, + loginShowHeading: true, + loginShowSubtitle: true, + loginShowTotp: true, + ...configOverrides.value, + }), +})); + +const { default: LoginPage } = await import('../page'); +const { useAuthStore } = await import('@/stores/auth-store'); + +let found: Record; +let lookups: string[]; +const login = vi.fn(async (..._args: unknown[]) => false); + +beforeEach(() => { + configOverrides.value = {}; + found = {}; + lookups = []; + login.mockClear(); + useAuthStore.setState({ login: login as never, error: null, isLoading: false }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/auth/discover-server')) { + const email = JSON.parse(String(init?.body)).email as string; + lookups.push(email); + const domain = email.split('@')[1]; + return new Response(JSON.stringify({ serverUrl: found[domain] ?? null }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +const email = () => document.getElementById('username') as HTMLInputElement; +const serverField = () => document.getElementById('jmap-endpoint') as HTMLInputElement | null; +const form = () => email().closest('form')!; + +async function typeAddress(address: string) { + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + fireEvent.change(email(), { target: { value: address } }); + fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } }); +} + +describe('sign-in to a server of one\'s own', () => { + it('asks for the email address first, with no server field and no CORS line', async () => { + render(); + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + expect(serverField()).toBeNull(); + expect(screen.queryByText('jmap_endpoint_cors_hint')).toBeNull(); + expect(document.activeElement).toBe(email()); + }, 30_000); + + it('finds the server from the address and signs in there', async () => { + found['example.org'] = 'https://mail.example.org'; + render(); + await typeAddress('sam@example.org'); + expect(await screen.findByTestId('login-server-found', {}, { timeout: 5000 })).toHaveTextContent('server_found'); + expect(serverField()).toBeNull(); + fireEvent.submit(form()); + await waitFor(() => expect(login).toHaveBeenCalled()); + expect(login.mock.calls[0][0]).toBe('https://mail.example.org'); + expect(lookups).toEqual(['sam@example.org']); + }, 30_000); + + it('opens the field when nothing is found, and takes a host without https://', async () => { + render(); + await typeAddress('sam@example.net'); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + expect(serverField()!.type).toBe('text'); + expect(screen.getByTestId('login-server-not-found')).toBeInTheDocument(); + // The CORS hint waits behind "Having trouble?". + expect(screen.getByTestId('login-server-trouble').tagName).toBe('DETAILS'); + fireEvent.change(serverField()!, { target: { value: 'mail.example.net' } }); + fireEvent.submit(form()); + await waitFor(() => expect(login).toHaveBeenCalled()); + expect(login.mock.calls[0][0]).toBe('https://mail.example.net'); + }, 30_000); + + it('signing in before the lookup ends waits for it', async () => { + found['example.org'] = 'https://mail.example.org'; + render(); + await typeAddress('sam@example.org'); + fireEvent.submit(form()); + await waitFor(() => expect(login).toHaveBeenCalled()); + expect(login.mock.calls[0][0]).toBe('https://mail.example.org'); + }, 30_000); + + it('uses the admin\'s server when the lookup finds nothing', async () => { + configOverrides.value = { jmapServerUrl: 'https://default.example.com' }; + render(); + await typeAddress('sam@example.net'); + expect(await screen.findByTestId('login-server-found', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(serverField()).toBeNull(); + fireEvent.submit(form()); + await waitFor(() => expect(login).toHaveBeenCalled()); + expect(login.mock.calls[0][0]).toBe('https://default.example.com'); + }, 30_000); + + it('marks the field and signs in nowhere when the server address is no address', async () => { + render(); + await typeAddress('sam@example.net'); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + fireEvent.change(serverField()!, { target: { value: 'mail example' } }); + fireEvent.submit(form()); + expect(await screen.findByTestId('login-server-error')).toHaveTextContent('server_invalid'); + expect(serverField()).toHaveAttribute('aria-invalid', 'true'); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + expect(login).not.toHaveBeenCalled(); + }, 30_000); + + it('says plainly that Gmail can\'t be reached, and asks nothing', async () => { + render(); + await typeAddress('sam@gmail.com'); + expect(await screen.findByTestId('login-provider-unsupported')).toHaveTextContent('provider_unsupported'); + await act(async () => { await new Promise((r) => setTimeout(r, 800)); }); + expect(lookups).toEqual([]); + }, 30_000); +}); + +describe('the page around the form', () => { + it('the theme button is an icon named "Light or dark", with a tooltip', async () => { + render(); + const button = await screen.findByTestId('login-theme-button', {}, { timeout: 10000 }); + expect(button).toHaveAttribute('title', 'theme_button'); + expect(button.getAttribute('aria-label')).toMatch(/^theme_button: /); + expect(button.textContent).toBe(''); + }, 30_000); + + it('the heading says "Sign in" unless the admin named the app', async () => { + render(); + expect(await screen.findByRole('heading', { level: 1 }, { timeout: 10000 })).toHaveTextContent('sign_in'); + }, 30_000); + + it('the heading is the app\'s name when the admin gave one', async () => { + configOverrides.value = { appName: 'Example Mail' }; + render(); + expect(await screen.findByRole('heading', { level: 1 }, { timeout: 10000 })).toHaveTextContent('Example Mail'); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 9e1ecbd53..29633c489 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -46,6 +46,8 @@ import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; import { buildMobileHandoffCodeUrl, buildMobileHandoffUrl, desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; import { checkDesktopSignIn, parkDesktopSignIn, requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; +import { LoginServerField, requestServerDiscovery, type ServerFieldError } from "@/components/auth/login-server-field"; +import { emailDomain, normalizeServerInput, serverHostLabel, unsupportedProvider } from "@/lib/auth/mail-server-address"; import { HelpLinks } from "@/components/layout/help-links"; import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton"; import { Block } from "@/components/boot-skeleton/kit"; @@ -206,6 +208,7 @@ function LoginPageContent() { const tOffline = useTranslations("offline"); const tOfflineWipe = useTranslations("offlineWipe"); const tTheme = useTranslations("settings.appearance.theme"); + const tLogin = useTranslations("uxs_login"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; @@ -299,6 +302,29 @@ function LoginPageContent() { // with OAuth even while OAuth is off globally: a Gmail bridge next to a // password server. const serverOauthEnabled = oauthEnabled || offersOwnOAuth(selectedServer); + // A server of one's own (custom endpoint, the desktop app's standalone mode): the + // email address comes first and the server is looked up from its domain (JMAP + // well-known, SRV, then the admin's default). The field for it only opens when + // nothing was found or the user wants another one. Lite has no server to ask + // and keeps the field open. + const customServerMode = !hasServerList && allowCustomJmapEndpoint; + const canLookUpServer = customServerMode && !IS_LITE; + const [serverEditing, setServerEditing] = useState(false); + const [serverLookup, setServerLookup] = useState<{ domain: string; status: "looking" | "found" | "failed"; url: string | null } | null>(null); + const [serverError, setServerError] = useState(null); + const [emailNeeded, setEmailNeeded] = useState(false); + const serverInputRef = useRef(null); + const serverLookups = useRef(new Map>()); + const typedDomain = canLookUpServer ? emailDomain(formData.username) : ""; + const unsupportedMailProvider = typedDomain ? unsupportedProvider(typedDomain) : null; + const lookupForAddress = serverLookup && serverLookup.domain === typedDomain ? serverLookup : null; + // What the lookup found, else the admin's default once it found nothing (or for + // a user name with no domain to look up). + const autoServer = lookupForAddress?.status === "found" + ? lookupForAddress.url + : configuredServerUrl && (lookupForAddress?.status === "failed" || (!typedDomain && formData.username.trim() !== "")) + ? configuredServerUrl + : null; const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); // Access-token sign-in (Bearer auth) in place of username and password. @@ -306,6 +332,15 @@ function LoginPageContent() { const [tokenMode, setTokenMode] = useState(false); const [accessToken, setAccessToken] = useState(""); const signInWithToken = tokenMode && loginShowTokenLogin && !isMobileHandoff; + const serverFieldOpen = customServerMode && ( + !canLookUpServer + || serverEditing + || signInWithToken + || unsupportedMailProvider !== null + || (lookupForAddress?.status === "failed" && !configuredServerUrl) + ); + /** The server a sign-in goes to right now, in custom-server mode; null when there is none yet. */ + const customServer = serverFieldOpen ? normalizeServerInput(jmapEndpoint) : autoServer; const [rememberMe, setRememberMe] = useState(false); // Lite (no server session): "remember me" needs Stalwart's token login on the // target server. Probe it once per URL and hide the box when it is missing; @@ -384,7 +419,7 @@ function LoginPageContent() { const probeTarget = hasServerList ? selectedServer?.url ?? "" - : (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl) || ""; + : (allowCustomJmapEndpoint ? normalizeServerInput(jmapEndpoint) : serverUrl) || ""; useEffect(() => { if (!IS_LITE || !probeTarget) return; let cancelled = false; @@ -446,10 +481,57 @@ function LoginPageContent() { }, [pageTitle, serverUrl]); useEffect(() => { + if (customServerMode) return; if (serverUrl && !jmapEndpoint) { setJmapEndpoint(serverUrl); } - }, [serverUrl, jmapEndpoint]); + }, [serverUrl, jmapEndpoint, customServerMode]); + + /** One lookup per domain while the page is open. */ + const lookUpServer = useCallback((domain: string, address: string): Promise => { + let pending = serverLookups.current.get(domain); + if (!pending) { + pending = requestServerDiscovery(address); + serverLookups.current.set(domain, pending); + } + return pending; + }, []); + + // Look the server up once the address is complete, after a pause in typing. + const typedAddress = formData.username.trim(); + useEffect(() => { + if (!typedDomain || unsupportedMailProvider || serverLookup?.domain === typedDomain) return; + const timer = setTimeout(() => { + setServerLookup({ domain: typedDomain, status: "looking", url: null }); + lookUpServer(typedDomain, typedAddress).then((url) => { + setServerLookup((current) => (current?.domain === typedDomain ? { domain: typedDomain, status: url ? "found" : "failed", url } : current)); + }); + }, 600); + return () => clearTimeout(timer); + }, [typedDomain, typedAddress, unsupportedMailProvider, serverLookup?.domain, lookUpServer]); + + /** + * The server to sign in to in custom-server mode, looked up now if need be. + * Null after it opened the field and said what is missing there. + */ + const resolveCustomServer = async (): Promise => { + setServerError(null); + if (!serverFieldOpen) { + if (typedDomain) { + const url = await lookUpServer(typedDomain, typedAddress); + setServerLookup({ domain: typedDomain, status: url ? "found" : "failed", url }); + if (url) return url; + } + if (configuredServerUrl && (typedDomain || typedAddress)) return configuredServerUrl; + } else { + const typed = normalizeServerInput(jmapEndpoint); + if (typed) return typed; + } + setServerEditing(true); + setServerError(serverFieldOpen && jmapEndpoint.trim() ? "invalid" : "needed"); + setTimeout(() => serverInputRef.current?.focus(), 0); + return null; + }; // Initialize selected server when the server list arrives. Picks the first // entry; the auto-pick effect below may override based on the email domain. @@ -840,6 +922,7 @@ function LoginPageContent() { const handleUsernameChange = (e: React.ChangeEvent) => { setFormData({ ...formData, username: e.target.value }); + setEmailNeeded(false); }; const handleUsernameFocus = () => { @@ -962,7 +1045,7 @@ function LoginPageContent() { * whichever server is selected so that "Sign in with Google" is one tap. */ const handleOAuthLogin = async (target?: PublicJmapServerEntry) => { - const oauthTarget = (target ?? selectedServer)?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + const oauthTarget = (target ?? selectedServer)?.url || (allowCustomJmapEndpoint ? customServer || configuredServerUrl : configuredServerUrl); if (oauthTarget && !jmapServerAllowedByDesktopPolicy(oauthTarget, desktopPolicy)) { setDesktopPolicyNotice(tDesktopPolicy("server_refused")); return; @@ -1011,7 +1094,7 @@ function LoginPageContent() { // Resolve the JMAP URL to send to the callback. Server-list entries win // over the custom-endpoint input, which wins over the global server URL. const oauthServerUrl = server?.url - || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + || (allowCustomJmapEndpoint ? customServer || configuredServerUrl : configuredServerUrl); sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); @@ -1074,8 +1157,18 @@ function LoginPageContent() { // Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other // builds' bundles, which Lite's check on API strings relies on. if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') { - const target = jmapEndpoint.trim(); - if (!target) { + let target = jmapEndpoint.trim(); + if (customServerMode) { + // The address first: the server is found from it. A server typed by hand needs none. + if (!typedAddress && !serverFieldOpen) { + setEmailNeeded(true); + inputRef.current?.focus(); + return; + } + const resolved = await resolveCustomServer(); + if (!resolved) return; + target = resolved; + } else if (!target) { setDesktopNotice(tDesktop("server_needed")); return; } @@ -1145,8 +1238,13 @@ function LoginPageContent() { // Server-list entries always win - `allowCustomJmapEndpoint` is only honored // when the admin hasn't configured a server list. - const effectiveServerUrl = selectedServer?.url + let effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + if (customServerMode) { + const resolved = await resolveCustomServer(); + if (!resolved) return; + effectiveServerUrl = resolved; + } // The desktop app opened this page in the user's browser. Nothing leaves this page until // the user says yes: the password is only checked, and a two-factor code can't be, as it // works once. This browser gets no session of its own out of any of it. @@ -1301,6 +1399,8 @@ function LoginPageContent() { }; const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; + // "Webmail" is the default name, not a brand: the heading then says what the page is for. + const brandedAppName = appName && appName !== "Webmail" ? appName : ""; const CurrentThemeIcon = currentThemeOption.icon; // Server picker (when admin has configured a server list). Rendered by both @@ -1378,7 +1478,7 @@ function LoginPageContent() { ))} {domainAutoLocked && ( -

+

{t("jmap_server_auto_picked")}

)} @@ -1438,17 +1538,18 @@ function LoginPageContent() { ref={themeButtonRef} onClick={() => setShowThemeMenu(!showThemeMenu)} className={cn( - "flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200", + "flex items-center justify-center w-9 h-9 rounded-xl border text-sm transition-all duration-200", showThemeMenu ? "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={`${tTheme("label")}: ${tTheme(currentThemeOption.label)}`} + aria-label={`${tLogin("theme_button")}: ${tTheme(currentThemeOption.label)}`} + title={tLogin("theme_button")} aria-expanded={showThemeMenu} aria-haspopup="menu" + data-testid="login-theme-button" > - - {tTheme(currentThemeOption.label)} +