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)} +