From 4f1b628f044cf7b395a14e2acc8bcbf8d0acb60d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:40:59 +0100 Subject: [PATCH] fix: keep the found server on Change, focus the field that needs input, and make Sign in the main button once a password is typed --- .../login-page-desktop-standalone.test.tsx | 11 +++ .../login-page-server-lookup.test.tsx | 66 +++++++++++++ app/(main)/[locale]/login/page.tsx | 97 +++++++++++++++---- components/auth/login-server-field.tsx | 17 +++- locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/et/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/th/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/vi/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + 34 files changed, 227 insertions(+), 24 deletions(-) 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 index 068ad4e40..997c37f43 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx @@ -81,6 +81,17 @@ describe('the desktop app, with a server of one\'s own', () => { expect(screen.queryByTestId('login-server-trouble')).toBeNull(); }, 30_000); + it('makes "Sign in" the main button once a password is typed', async () => { + render(); + const button = await browserButton(); + const submit = email().closest('form')!.querySelector('button[type=submit]')!; + expect(button.className).toContain('bg-primary'); + expect(submit.className).not.toContain('bg-primary'); + fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } }); + await waitFor(() => expect(submit.className).toContain('bg-primary')); + expect(button.className).not.toContain('bg-primary'); + }, 30_000); + it('asks for the address under its field when the browser button is pressed first', async () => { render(); fireEvent.click(await browserButton()); 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 index 326e9f2f2..1fcd9f2ea 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx @@ -139,10 +139,76 @@ describe('sign-in to a server of one\'s own', () => { fireEvent.submit(form()); expect(await screen.findByTestId('login-server-error')).toHaveTextContent('server_invalid'); expect(serverField()).toHaveAttribute('aria-invalid', 'true'); + // Marked red, not only for screen readers. + expect(serverField()!.className).toContain('aria-invalid:border-destructive'); await waitFor(() => expect(document.activeElement).toBe(serverField())); expect(login).not.toHaveBeenCalled(); }, 30_000); + it('Change keeps the server in use and doesn\'t say it wasn\'t found', async () => { + configOverrides.value = { jmapServerUrl: 'https://default.example.com' }; + render(); + await typeAddress('sam@example.net'); + fireEvent.click(await screen.findByTestId('login-server-change', {}, { timeout: 5000 })); + await waitFor(() => expect(serverField()).not.toBeNull()); + expect(serverField()!.value).toBe('default.example.com'); + expect(screen.queryByTestId('login-server-not-found')).toBeNull(); + expect(screen.getByTestId('login-server-change-hint')).toHaveTextContent('server_change_hint'); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + }, 30_000); + + it('focuses the server field when it opens after the user left the address field', async () => { + render(); + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + fireEvent.change(email(), { target: { value: 'sam@example.net' } }); + // Tab went on to the (still empty) password field before the lookup ended. + (document.getElementById('password') as HTMLInputElement).focus(); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + }, 30_000); + + it('leaves focus in the address field while the user is still typing there', async () => { + render(); + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + email().focus(); + fireEvent.change(email(), { target: { value: 'sam@example.net' } }); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + expect(document.activeElement).toBe(email()); + }, 30_000); + + it('a wrong password focuses the password field with its text selected', async () => { + found['example.org'] = 'https://mail.example.org'; + login.mockImplementation(async () => { + useAuthStore.setState({ error: 'invalid_credentials', isLoading: false }); + return false; + }); + render(); + await typeAddress('sam@example.org'); + const button = form().querySelector('button[type=submit]')!; + button.focus(); + fireEvent.submit(form()); + const password = document.getElementById('password') as HTMLInputElement; + await waitFor(() => expect(document.activeElement).toBe(password)); + expect(password.selectionStart).toBe(0); + expect(password.selectionEnd).toBe('secret'.length); + expect(password.className).toContain('aria-invalid:border-destructive'); + login.mockImplementation(async () => false); + }, 30_000); + + it('keeps the access token behind "Other ways to sign in"', async () => { + configOverrides.value = { loginShowTokenLogin: true }; + render(); + const toggle = await screen.findByTestId('login-token-toggle', {}, { timeout: 10000 }); + const other = screen.getByTestId('login-other-ways'); + expect(other.tagName).toBe('DETAILS'); + expect(other).toContainElement(toggle); + expect(other.querySelector('summary')).toHaveTextContent('other_ways'); + fireEvent.click(toggle); + // The way back to the password is in sight, not folded away. + await waitFor(() => expect(screen.queryByTestId('login-other-ways')).toBeNull()); + expect(screen.getByTestId('login-token-toggle')).toHaveTextContent('password_toggle'); + }, 30_000); + it('says plainly that Gmail can\'t be reached, and asks nothing', async () => { render(); await typeAddress('sam@gmail.com'); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 29633c489..34ac9f470 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -310,6 +310,10 @@ function LoginPageContent() { const customServerMode = !hasServerList && allowCustomJmapEndpoint; const canLookUpServer = customServerMode && !IS_LITE; const [serverEditing, setServerEditing] = useState(false); + // Change on a found server: another one is wanted, nothing failed. + const [serverChanging, setServerChanging] = useState(false); + // Asks for focus in the server field once it is on screen (the field mounts in the next render). + const [serverFocusRequest, setServerFocusRequest] = useState(0); 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); @@ -400,6 +404,7 @@ function LoginPageContent() { const justRemovedSuggestion = useRef(false); const totpInputRef = useRef(null); const tokenInputRef = useRef(null); + const passwordInputRef = useRef(null); const prevError = useRef(null); const themeMenuRef = useRef(null); const themeButtonRef = useRef(null); @@ -510,6 +515,23 @@ function LoginPageContent() { return () => clearTimeout(timer); }, [typedDomain, typedAddress, unsupportedMailProvider, serverLookup?.domain, lookUpServer]); + useEffect(() => { + if (serverFocusRequest) serverInputRef.current?.focus(); + }, [serverFocusRequest]); + + // The field opens by itself when the lookup found nothing, usually after the user + // already left the address field. Focus goes there, unless they are typing elsewhere. + const serverFieldOpenedByLookup = canLookUpServer && lookupForAddress?.status === "failed" && !configuredServerUrl && !unsupportedMailProvider; + useEffect(() => { + if (!serverFieldOpenedByLookup) return; + const active = document.activeElement; + if (active && active.id === "username") return; + if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { + if (!(active.id === "password" && !active.value)) return; + } + serverInputRef.current?.focus(); + }, [serverFieldOpenedByLookup, typedDomain]); + /** * 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. @@ -529,7 +551,7 @@ function LoginPageContent() { } setServerEditing(true); setServerError(serverFieldOpen && jmapEndpoint.trim() ? "invalid" : "needed"); - setTimeout(() => serverInputRef.current?.focus(), 0); + setServerFocusRequest((n) => n + 1); return null; }; @@ -582,6 +604,19 @@ function LoginPageContent() { prevError.current = error; }, [error]); + // A wrong password or token: focus that field with its text selected, ready to type again. + useEffect(() => { + if (error === "invalid_credentials" && !totpBlamed) { + passwordInputRef.current?.focus(); + passwordInputRef.current?.select(); + } else if (error === "invalid_token") { + tokenInputRef.current?.focus(); + tokenInputRef.current?.select(); + } + // Only on a new error, not when the code field changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); + // Auto-show and focus TOTP field when server requires it useEffect(() => { if (error === 'totp_required') { @@ -1232,6 +1267,10 @@ function LoginPageContent() { }; const browserSignInShown = browserSignInAvailable() && desktopSignInEnabled; + // Once a password (or token) is typed, "Sign in" is what the user means: it becomes the + // main button and "Sign in with your browser" the other way. + const formSignInPreferred = signInWithToken ? accessToken.trim() !== "" : formData.password !== ""; + const browserSignInPrimary = browserSignInShown && !formSignInPreferred; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -1413,7 +1452,10 @@ function LoginPageContent() {
- )} + {loginShowTokenLogin && !isMobileHandoff && !liteSsoRequired && (() => { + const tokenToggle = ( + + ); + // Few people have a token: the way back to the password stays in sight, + // the way to a token waits behind "Other ways to sign in". + return signInWithToken ? tokenToggle : ( +
+ {tLogin("other_ways")} +
{tokenToggle}
+
+ ); + })()} {/* Remember me, for the password and the SSO buttons below */} {showRememberMe && rememberMeBox} @@ -2189,8 +2245,9 @@ function LoginPageContent() {