From 55d008a2e8a555b31371f8b765d5e992b2534999 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:39:39 +0100 Subject: [PATCH] fix: let the user cancel a sign-in that hangs --- src/api/jmap-client.ts | 7 +- src/lib/__tests__/server-discovery.test.ts | 12 ++ src/lib/__tests__/totp-login.test.ts | 17 +++ src/lib/server-discovery.ts | 44 +++--- src/lib/sign-in-abort.ts | 73 ++++++++++ src/lib/totp-login.ts | 47 ++++--- src/screens/LoginScreen.tsx | 126 +++++++++++++----- src/screens/login/SigningInStep.tsx | 28 +++- .../login/__tests__/login-back.test.ts | 32 +++++ src/screens/login/login-back.ts | 26 ++++ .../__tests__/auth-store-cancel.test.ts | 101 ++++++++++++++ .../__tests__/auth-store-pairing.test.ts | 2 +- src/stores/auth-store.ts | 111 ++++++++++----- 13 files changed, 521 insertions(+), 105 deletions(-) create mode 100644 src/lib/sign-in-abort.ts create mode 100644 src/screens/login/__tests__/login-back.test.ts create mode 100644 src/screens/login/login-back.ts create mode 100644 src/stores/__tests__/auth-store-cancel.test.ts diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index bdf27a16..880f93d9 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -279,11 +279,13 @@ export class JMAPClient { username: string, password: string, totp?: string, + /** Cancels the token requests of a TOTP sign-in (the sign-in screen's Cancel). */ + signal?: AbortSignal, ): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); const epoch = this.detachSession(); if (totp) { - return this.connectWithTotp(baseUrl, username, password, totp, epoch); + return this.connectWithTotp(baseUrl, username, password, totp, epoch, signal); } const credentials: StoredCredentials = { serverUrl: baseUrl, username, password }; this.credentials = credentials; @@ -314,11 +316,12 @@ export class JMAPClient { password: string, totp: string, epoch: number, + signal?: AbortSignal, ): Promise { const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login'); let tokens: OAuthTokens; try { - tokens = await exchangePasswordForTokens(baseUrl, username, password, totp); + tokens = await exchangePasswordForTokens(baseUrl, username, password, totp, { signal }); } catch (err) { if (err instanceof LoginEndpointMissingError) { this.assertCurrentSession(epoch); diff --git a/src/lib/__tests__/server-discovery.test.ts b/src/lib/__tests__/server-discovery.test.ts index 72e78853..c2b99c59 100644 --- a/src/lib/__tests__/server-discovery.test.ts +++ b/src/lib/__tests__/server-discovery.test.ts @@ -196,4 +196,16 @@ describe('discoverServerForEmail', () => { await expect(discoverServerForEmail('ada')).resolves.toBeNull(); expect(mockSecureFetch).not.toHaveBeenCalled(); }); + + it('stops searching as soon as the sign-in is cancelled (U-133)', async () => { + // Every host hangs; only the cancel can end the search before the deadline. + mockSecureFetch.mockImplementation(() => new Promise(() => undefined)); + const controller = new AbortController(); + const started = Date.now(); + const search = discoverServerForEmail('ada@example.com', { timeoutMs: 60_000, signal: controller.signal }); + setTimeout(() => controller.abort(), 20); + await expect(search).resolves.toBeNull(); + expect(Date.now() - started).toBeLessThan(5_000); + expect(mockSecureFetch.mock.calls[0][1].signal.aborted).toBe(true); + }); }); diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts index 05147eb4..a1f4ff9a 100644 --- a/src/lib/__tests__/totp-login.test.ts +++ b/src/lib/__tests__/totp-login.test.ts @@ -101,6 +101,23 @@ describe('exchangePasswordForTokens', () => { expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); }); + it('cancels the client check and the login requests with the sign-in screen (U-133)', async () => { + const signals: Array = []; + fetchMock.mockImplementation((_url: string, init?: RequestInit) => { + signals.push(init?.signal ?? undefined); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new TypeError('Aborted'))); + }); + }); + const controller = new AbortController(); + const pending = exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456', { signal: controller.signal }); + await vi.waitFor(() => expect(signals.length).toBe(1)); + controller.abort(); + // A cancel, not "server unreachable". + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + expect(signals[0]?.aborted).toBe(true); + }); + it('registers its own client on Stalwart 1.0 and signs in with it', async () => { const calls = fakeServer('1.0'); const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); diff --git a/src/lib/server-discovery.ts b/src/lib/server-discovery.ts index e21da1a9..c38f31bf 100644 --- a/src/lib/server-discovery.ts +++ b/src/lib/server-discovery.ts @@ -8,6 +8,7 @@ // slow or hostile network must never be worse than typing it in. import { secureFetch } from './client-cert'; +import { signalWithTimeout } from './sign-in-abort'; export const DISCOVERY_TIMEOUT_MS = 2500; @@ -96,18 +97,21 @@ export function serverCandidates(domain: string): string[] { return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`]; } -function withTimeout(promise: Promise, ms: number, fallback: T): Promise { +function withTimeout(promise: Promise, ms: number, fallback: T, signal?: AbortSignal): Promise { return new Promise((resolve) => { - const timer = setTimeout(() => resolve(fallback), ms); - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch(() => { - clearTimeout(timer); - resolve(fallback); - }); + if (signal?.aborted) { + resolve(fallback); + return; + } + const finish = (value: T) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + resolve(value); + }; + const onAbort = () => finish(fallback); + const timer = setTimeout(() => finish(fallback), ms); + signal?.addEventListener('abort', onAbort, { once: true }); + promise.then(finish).catch(() => finish(fallback)); }); } @@ -128,15 +132,16 @@ function looksLikeSession(body: unknown): boolean { export async function probeJmapServer( baseUrl: string, timeoutMs: number = DISCOVERY_TIMEOUT_MS, + signal?: AbortSignal, ): Promise { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const abortTimer = setTimeout(() => controller?.abort(), timeoutMs); + // Gives up at the deadline, or when the user cancels the sign-in. + const deadline = signalWithTimeout(signal, timeoutMs); const request = (async () => { try { const response = await secureFetch(`${baseUrl}/.well-known/jmap`, { headers: { Accept: 'application/json' }, - signal: controller?.signal, + signal: deadline.signal, }); if (response.status === 401) return true; if (response.status !== 200) return false; @@ -149,9 +154,9 @@ export async function probeJmapServer( try { // The abort signal doesn't reach the native client-certificate path, so // race the whole thing rather than trusting the request to give up. - return await withTimeout(request, timeoutMs, false); + return await withTimeout(request, timeoutMs, false, signal); } finally { - clearTimeout(abortTimer); + deadline.done(); } } @@ -159,6 +164,8 @@ export interface DiscoveryOptions { /** Servers of already-registered accounts, trusted without a probe. */ knownServerUrls?: string[]; timeoutMs?: number; + /** The sign-in screen's Cancel: discovery stops and finds nothing. */ + signal?: AbortSignal; } /** @@ -170,7 +177,7 @@ export async function discoverServerForEmail( options: DiscoveryOptions = {}, ): Promise { const domain = emailDomain(email); - if (!domain) return null; + if (!domain || options.signal?.aborted) return null; // A server we're already signed in to for this domain needs no probe, and // covers the common case of adding a second account on the same host. @@ -186,8 +193,9 @@ export async function discoverServerForEmail( const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS; const candidates = serverCandidates(domain); const results = await Promise.all( - candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)), + candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs, options.signal)) ? candidate : null)), ); + if (options.signal?.aborted) return null; // `find` keeps candidate order, so a bare domain wins over `mail.` even when // both answer. diff --git a/src/lib/sign-in-abort.ts b/src/lib/sign-in-abort.ts new file mode 100644 index 00000000..5078c98b --- /dev/null +++ b/src/lib/sign-in-abort.ts @@ -0,0 +1,73 @@ +// Cancelling a sign-in (U-133). The sign-in screen used to show "Signing in" +// with no way out until every request gave up on its own, and Android Back +// was swallowed meanwhile. The screen now holds an AbortController per +// attempt; its signal goes into discovery, the client check, the /api/auth +// and token requests, and the auth store, which drops a sign-in whose signal +// fired before it registered the account. + +export class SignInCancelledError extends Error { + constructor() { + super('Sign-in cancelled'); + this.name = 'SignInCancelledError'; + } +} + +export function isSignInCancelled(err: unknown): boolean { + return err instanceof Error && err.name === 'SignInCancelledError'; +} + +export function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new SignInCancelledError(); +} + +/** + * `promise`, or a `SignInCancelledError` as soon as `signal` fires. The work + * behind the promise goes on; callers that need to undo what it does once it + * finishes keep their own reference to it. + */ +export function abortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) { + promise.catch(() => undefined); + return Promise.reject(new SignInCancelledError()); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new SignInCancelledError()); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (err) => { + signal.removeEventListener('abort', onAbort); + reject(signal.aborted ? new SignInCancelledError() : err); + }, + ); + }); +} + +/** + * One signal that fires when `signal` does or after `ms`, whichever comes + * first (AbortSignal.any and AbortSignal.timeout aren't in every engine the + * app runs on). Call `done` when the request has finished. + */ +export function signalWithTimeout( + signal: AbortSignal | undefined, + ms?: number, +): { signal?: AbortSignal; done: () => void } { + if (typeof AbortController === 'undefined') return { signal, done: () => undefined }; + if (!signal && ms === undefined) return { done: () => undefined }; + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = ms !== undefined ? setTimeout(abort, ms) : undefined; + if (signal?.aborted) controller.abort(); + else signal?.addEventListener('abort', abort, { once: true }); + return { + signal: controller.signal, + done: () => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener('abort', abort); + }, + }; +} diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index 2c00a39e..289d3a3e 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -29,6 +29,7 @@ import { sha256Hex } from './sha256'; import { base64UrlEncode, randomBytes, randomHex } from './random'; import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; import { NetworkError } from '../api/jmap-client'; +import { signalWithTimeout, throwIfCancelled } from './sign-in-abort'; // Stalwart 0.16's built-in OAuth accepts any client id; the webmail's public // one keeps refresh behaviour identical to the browser-handoff bundle. @@ -134,13 +135,6 @@ function rememberClient(base: string, client: LoginClient): void { loginClients.set(base, client); } -function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } { - if (typeof AbortController === 'undefined') return { done: () => undefined }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - return { signal: controller.signal, done: () => clearTimeout(timer) }; -} - // The password and the PKCE verifier must not follow a redirect to another // host. The requests go out with fetchWithoutForeignRedirect, which hands such // a redirect back unfollowed; where the platform followed it anyway, the @@ -163,8 +157,12 @@ function assertSameServer(requested: string, response: Response): void { * names, which may be on the public address it advertises rather than the one * the user typed; the id is sealed with the server's key, so it serves both. */ -async function registerPublicClient(base: string, url = `${base}/auth/register`): Promise { - const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); +async function registerPublicClient( + base: string, + url = `${base}/auth/register`, + signal?: AbortSignal, +): Promise { + const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS); let response: Response; try { response = await fetchWithoutForeignRedirect(url, { @@ -182,6 +180,7 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`) timeoutMs: CLIENT_CHECK_TIMEOUT_MS, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } finally { t.done(); @@ -207,6 +206,7 @@ async function checkClient( base: string, clientId: string, redirectUri: string, + signal?: AbortSignal, ): Promise<'known' | 'unknown' | 'none' | 'busy'> { const query = new URLSearchParams({ response_type: 'code', @@ -217,7 +217,7 @@ async function checkClient( code_challenge_method: 'S256', }); const url = `${base}/api/auth/client?${query.toString()}`; - const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); + const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS); let response: Response; try { response = await fetchWithoutForeignRedirect(url, { @@ -226,6 +226,7 @@ async function checkClient( timeoutMs: CLIENT_CHECK_TIMEOUT_MS, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } finally { t.done(); @@ -253,14 +254,15 @@ async function checkClient( */ export async function resolveLoginClient( serverUrl: string, - opts?: { registrationEndpoint?: string }, + opts?: { registrationEndpoint?: string; signal?: AbortSignal }, ): Promise { const base = trimBase(serverUrl); const cached = loginClients.get(base); if (cached) return cached; const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI }; + const signal = opts?.signal; - const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI); + const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, signal); if (own === 'busy') return fallback; if (own === 'known') { const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI }; @@ -268,11 +270,11 @@ export async function resolveLoginClient( return client; } if (own === 'unknown') { - const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI); + const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, signal); if (shared === 'busy') return fallback; if (shared === 'unknown') { const endpoint = opts?.registrationEndpoint; - return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined); + return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined, signal); } } rememberClient(base, fallback); @@ -386,6 +388,7 @@ async function requestLoginCode( totp: string | undefined, client: LoginClient, challenge: string, + signal?: AbortSignal, ): Promise { const state = randomHex(16); const url = `${base}/api/auth`; @@ -403,8 +406,10 @@ async function requestLoginCode( codeChallenge: challenge, state, })), + signal, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } assertSameServer(url, loginResponse); @@ -434,26 +439,28 @@ export async function exchangePasswordForTokens( username: string, password: string, totp?: string, - opts?: { clientId?: string; redirectUri?: string }, + opts?: { clientId?: string; redirectUri?: string; signal?: AbortSignal }, ): Promise { const base = trimBase(serverUrl); const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); + const signal = opts?.signal; + throwIfCancelled(signal); let client: LoginClient = opts?.clientId ? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI } - : await resolveLoginClient(base); - let outcome = await requestLoginCode(base, username, password, totp, client, challenge); + : await resolveLoginClient(base, { signal }); + let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal); // A remembered client the server no longer knows (another server now // answers at the address, or its key changed), or the default taken while // the client check failed: ask again, once. 1.0 checks the client before // the credentials, so this costs no failed sign-in. if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) { loginClients.delete(base); - const fresh = await resolveLoginClient(base); + const fresh = await resolveLoginClient(base, { signal }); if (fresh.clientId !== client.clientId) { client = fresh; - outcome = await requestLoginCode(base, username, password, totp, client, challenge); + outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal); } } @@ -486,8 +493,10 @@ export async function exchangePasswordForTokens( method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: tokenParams.toString(), + signal, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } assertSameServer(tokenUrl, tokenResponse); diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index edc927aa..822ca07e 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -33,6 +33,8 @@ import ServerStep from './login/ServerStep'; import ConfirmStep from './login/ConfirmStep'; import PasswordStep from './login/PasswordStep'; import SigningInStep, { type SigningInPhase } from './login/SigningInStep'; +import { loginBackAction } from './login/login-back'; +import { isSignInCancelled } from '../lib/sign-in-abort'; interface LoginScreenProps { onLogin?: () => void; @@ -117,6 +119,33 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // (in add mode the server fields still name the signed-in account's host). const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState(null); + // One controller per sign-in or server search (U-133). Cancel and Back + // abort it; a flow whose controller is no longer the current one drops + // whatever its awaits bring back, so a late answer neither signs in nor + // shows an error over the form the user went back to. + const attemptRef = React.useRef(null); + const beginAttempt = React.useCallback((): AbortController => { + attemptRef.current?.abort(); + const controller = new AbortController(); + attemptRef.current = controller; + return controller; + }, []); + const isLive = (attempt: AbortController) => attemptRef.current === attempt && !attempt.signal.aborted; + const endAttempt = React.useCallback((attempt: AbortController): boolean => { + if (attemptRef.current !== attempt) return false; + attemptRef.current = null; + return true; + }, []); + const cancelSignIn = React.useCallback(() => { + attemptRef.current?.abort(); + attemptRef.current = null; + setBusy(null); + setSearching(false); + setPairingWebmailUrl(null); + setNotice(null); + }, []); + React.useEffect(() => () => attemptRef.current?.abort(), []); + // Addresses typed before on this device (last 5) plus the registry's, so a // returning user taps instead of retyping. Never contains passwords. const [recentEmails, setRecentEmails] = React.useState([]); @@ -216,27 +245,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // out of the flow entirely from the first step. React.useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - if (busy || searching) return true; - if (scannerVisible) { - setScannerVisible(false); - return true; + const action = loginBackAction({ + busy: !!busy, + searching, + scannerVisible, + pasteVisible, + historyLength: history.length, + canLeave: isAddMode && !!onCancel, + }); + switch (action) { + case 'cancel': + cancelSignIn(); + return true; + case 'close-scanner': + setScannerVisible(false); + return true; + case 'close-paste': + setPasteVisible(false); + return true; + case 'step-back': + goBack(); + return true; + case 'leave': + onCancel?.(); + return true; + default: + return false; } - if (pasteVisible) { - setPasteVisible(false); - return true; - } - if (history.length > 0) { - goBack(); - return true; - } - if (isAddMode && onCancel) { - onCancel(); - return true; - } - return false; }); return () => subscription.remove(); - }, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]); + }, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel, cancelSignIn]); // ── flows ────────────────────────────────────────────── @@ -279,21 +317,24 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo const runHandoff = React.useCallback( async (target: string) => { setNotice(null); + const attempt = beginAttempt(); setBusy('browser'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginViaWebmail(target, { addAccount: isAddMode }); + await loginViaWebmail(target, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; // loginViaWebmail resolves quietly when the user closes the browser — // that's a cancellation, not a failure, so leave them where they were. finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (continueWithSecondFactor(err, target)) return; setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } }, - [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaWebmail, t], + [beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaWebmail, t], ); const handleEmailContinue = React.useCallback(async () => { @@ -307,10 +348,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(null); + const attempt = beginAttempt(); setSearching(true); rememberEmail(value); try { - const found = await discoverServerForEmail(value, { knownServerUrls }); + const found = await discoverServerForEmail(value, { knownServerUrls, signal: attempt.signal }); + if (!isLive(attempt)) return; if (found) { setServerUrl(found); setServerInput(found); @@ -322,9 +365,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo goTo('server'); } } finally { - setSearching(false); + if (endAttempt(attempt)) setSearching(false); } - }, [email, goTo, knownServerUrls, rememberEmail, t]); + }, [beginAttempt, email, endAttempt, goTo, knownServerUrls, rememberEmail, t]); const handleServerContinue = React.useCallback(() => { const normalized = normalizeServerUrl(serverInput); @@ -353,15 +396,18 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo return; } setNotice(null); + const attempt = beginAttempt(); setBusy('connecting'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginWithToken(target, accessToken, { addAccount: isAddMode }); + await loginWithToken(target, accessToken, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } return; } @@ -377,15 +423,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(null); + const attempt = beginAttempt(); setBusy('connecting'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { await login(target, email.trim(), password, { addAccount: isAddMode, totp: totpRequired ? totp.trim() : undefined, + signal: attempt.signal, }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (err instanceof Error && err.name === 'TotpRequiredError') { setTotpRequired(true); setNotice(describeLoginError(err, { serverUrl: target, t })); @@ -393,9 +443,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } - }, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]); + }, [accessToken, beginAttempt, email, endAttempt, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]); // A sign-in code, however it arrived: scanned, pasted, or a tapped // `bulwarkmail://` link. @@ -415,23 +465,28 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // Cross-device pairing code: redeem it, no browser needed. setNotice(null); + const attempt = beginAttempt(); setPairingWebmailUrl(payload.webmailUrl); setBusy('pairing'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); + await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (continueWithSecondFactor(err, payload.webmailUrl)) return; // The error names its own host: the webmail for a failed redeem, the // mail server when connecting afterwards failed. setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t })); } finally { - setBusy(null); - setPairingWebmailUrl(null); + if (endAttempt(attempt)) { + setBusy(null); + setPairingWebmailUrl(null); + } } }, - [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], + [beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], ); const handleScanned = React.useCallback( @@ -544,6 +599,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo phase={busy} serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl} email={pairing || tokenMode ? null : email.trim() || null} + onCancel={cancelSignIn} /> ); } @@ -565,7 +621,11 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo const canGoBack = history.length > 0; const shellProps = { - onBack: canGoBack ? goBack : undefined, + // Back during the server search stops it as well (U-133). + onBack: canGoBack ? () => { + if (searching) cancelSignIn(); + goBack(); + } : undefined, onClose: isAddMode && onCancel ? onCancel : undefined, }; diff --git a/src/screens/login/SigningInStep.tsx b/src/screens/login/SigningInStep.tsx index e7a68227..30e5e417 100644 --- a/src/screens/login/SigningInStep.tsx +++ b/src/screens/login/SigningInStep.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native'; +import { View, Text, StyleSheet, ActivityIndicator, Image, Pressable } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors, useResolvedTheme } from '../../theme/colors'; @@ -16,6 +16,8 @@ interface SigningInStepProps { phase: SigningInPhase; serverUrl: string | null; email: string | null; + /** Stops the sign-in and goes back to the step it started from (U-133). */ + onCancel?: () => void; } function hostOf(url: string | null): string { @@ -32,7 +34,7 @@ function initialsOf(email: string): string { * current stage makes a slow first connection legible instead of a bare * spinner that could mean anything. */ -export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) { +export default function SigningInStep({ phase, serverUrl, email, onCancel }: SigningInStepProps) { const c = useColors(); const theme = useResolvedTheme(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -68,6 +70,18 @@ export default function SigningInStep({ phase, serverUrl, email }: SigningInStep {copy[phase].detail} + + {onCancel ? ( + [styles.cancel, pressed && styles.cancelPressed]} + accessibilityRole="button" + accessibilityLabel={t('login.mobile.cancel_sign_in', 'Cancel sign-in')} + > + {t('common.cancel', 'Cancel')} + + ) : null} ); @@ -98,5 +112,15 @@ function makeStyles(c: ThemePalette) { title: { ...typography.h2, color: c.text, textAlign: 'center' }, detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' }, spinner: { marginTop: spacing.xl }, + cancel: { + marginTop: spacing.lg, + minHeight: componentSizes.buttonLg, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.md, + }, + cancelPressed: { opacity: 0.6 }, + cancelText: { ...typography.bodyMedium, color: c.primary }, }); } diff --git a/src/screens/login/__tests__/login-back.test.ts b/src/screens/login/__tests__/login-back.test.ts new file mode 100644 index 00000000..a65a890b --- /dev/null +++ b/src/screens/login/__tests__/login-back.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { loginBackAction, type LoginBackState } from '../login-back'; + +const idle: LoginBackState = { + busy: false, + searching: false, + scannerVisible: false, + pasteVisible: false, + historyLength: 2, + canLeave: true, +}; + +describe('Back on the sign-in screen', () => { + it('cancels a sign-in under way instead of swallowing the key (U-133)', () => { + expect(loginBackAction({ ...idle, busy: true })).toBe('cancel'); + }); + + it('cancels the server search', () => { + expect(loginBackAction({ ...idle, searching: true })).toBe('cancel'); + }); + + it('closes the scanner, then the paste dialog, then goes a step back', () => { + expect(loginBackAction({ ...idle, scannerVisible: true })).toBe('close-scanner'); + expect(loginBackAction({ ...idle, pasteVisible: true })).toBe('close-paste'); + expect(loginBackAction(idle)).toBe('step-back'); + }); + + it('leaves add-account from the first step, and lets the system have the key otherwise', () => { + expect(loginBackAction({ ...idle, historyLength: 0 })).toBe('leave'); + expect(loginBackAction({ ...idle, historyLength: 0, canLeave: false })).toBe('exit'); + }); +}); diff --git a/src/screens/login/login-back.ts b/src/screens/login/login-back.ts new file mode 100644 index 00000000..3c32b76d --- /dev/null +++ b/src/screens/login/login-back.ts @@ -0,0 +1,26 @@ +// What Android Back (and the on-screen back) does on the sign-in screen. +// While a sign-in or the server search runs, Back cancels it and returns to +// the step it started from (U-133); it used to be swallowed, which left a +// hung "Signing in" with no way out but killing the app. + +export type LoginBackAction = 'cancel' | 'close-scanner' | 'close-paste' | 'step-back' | 'leave' | 'exit'; + +export interface LoginBackState { + busy: boolean; + searching: boolean; + scannerVisible: boolean; + pasteVisible: boolean; + /** Steps behind the current one. */ + historyLength: number; + /** Add-account mode with a way to close the screen. */ + canLeave: boolean; +} + +export function loginBackAction(state: LoginBackState): LoginBackAction { + if (state.busy || state.searching) return 'cancel'; + if (state.scannerVisible) return 'close-scanner'; + if (state.pasteVisible) return 'close-paste'; + if (state.historyLength > 0) return 'step-back'; + if (state.canLeave) return 'leave'; + return 'exit'; +} diff --git a/src/stores/__tests__/auth-store-cancel.test.ts b/src/stores/__tests__/auth-store-cancel.test.ts new file mode 100644 index 00000000..c391a872 --- /dev/null +++ b/src/stores/__tests__/auth-store-cancel.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// U-133: a sign-in that hangs had no way out. The sign-in screen now hands +// the store an AbortSignal; once it fires, the sign-in rejects at once, the +// client goes back to what it served before, and nothing of the account the +// sign-in was adding stays behind, even when the connect finishes later. + +let finishConnect: ((value: unknown) => void) | null = null; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + connect: vi.fn(() => new Promise((resolve) => { finishConnect = resolve; })), + snapshot: vi.fn(() => ({ session: 'previous', credentials: null, accountId: 'prev' })), + restoreSnapshot: vi.fn(), + reset: vi.fn(), + clearAccountCredentials: vi.fn(async () => undefined), + onAuthFailure: vi.fn(() => () => undefined), + onTokenRefresh: vi.fn(() => () => undefined), + }, + AuthenticationError: class AuthenticationError extends Error {}, + InvalidTokenError: class InvalidTokenError extends Error {}, + NetworkError: class NetworkError extends Error {}, + SessionSupersededError: class SessionSupersededError extends Error {}, +})); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), +})); + +import { jmapClient } from '../../api/jmap-client'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +const SERVER = 'https://mail.example.com'; + +beforeEach(() => { + vi.clearAllMocks(); + finishConnect = null; + useAccountStore.setState({ accounts: [], activeAccountId: null }); + useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null }); +}); + +async function connectStarted(): Promise { + await vi.waitFor(() => expect(finishConnect).not.toBeNull()); +} + +describe('cancelling a sign-in', () => { + it('rejects at once while the server has not answered, and keeps no error', async () => { + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal }); + await connectStarted(); + + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + expect(useAuthStore.getState()).toMatchObject({ isLoading: false, error: null, isAuthenticated: false }); + // The client must not go on serving the half-connected account. + expect(jmapClient.reset).toHaveBeenCalled(); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + it('drops the credentials a connect stores after the user cancelled', async () => { + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal }); + await connectStarted(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + finishConnect!({ apiUrl: `${SERVER}/jmap/` }); + await vi.waitFor(() => { + expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith('ada@example.com@mail.example.com'); + }); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('puts the account in use back when adding one is cancelled', async () => { + useAuthStore.setState({ isAuthenticated: true }); + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'bob@example.com', 'pw', { + addAccount: true, + signal: controller.signal, + }); + await connectStarted(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith({ session: 'previous', credentials: null, accountId: 'prev' }); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + + it('does not start when the signal fired before', async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + useAuthStore.getState().loginWithToken(SERVER, 'tok', { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'SignInCancelledError' }); + expect(useAuthStore.getState().error).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/auth-store-pairing.test.ts b/src/stores/__tests__/auth-store-pairing.test.ts index f0dfa343..c0b6f7cb 100644 --- a/src/stores/__tests__/auth-store-pairing.test.ts +++ b/src/stores/__tests__/auth-store-pairing.test.ts @@ -132,7 +132,7 @@ describe('loginViaPairing with an app password', () => { await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode()); - expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined); + expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined, undefined); expect(connectWithOAuth).not.toHaveBeenCalled(); expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER }); }); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 7e683bbe..31717643 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -41,6 +41,7 @@ import { import { reconcileServerFingerprint } from '../lib/server-change'; import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle'; import { perfMark } from '../lib/perf-marks'; +import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort'; // Persist middleware hydrates asynchronously on cold start. Without this // guard, restoreSession() can read the account-store before AsyncStorage has @@ -97,14 +98,14 @@ export interface AuthState { serverUrl: string, username: string, password: string, - opts?: { addAccount?: boolean; totp?: string }, + opts?: SignInOptions & { totp?: string }, ) => Promise; /** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */ - loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise; - loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; + loginWithToken: (serverUrl: string, token: string, opts?: SignInOptions) => Promise; + loginViaWebmail: (webmailUrl: string, opts?: SignInOptions) => Promise; /** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */ - loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise; - loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise; + loginViaOAuth: (serverUrl: string, opts?: SignInOptions) => Promise; + loginViaPairing: (webmailUrl: string, code: string, opts?: SignInOptions) => Promise; logout: () => Promise; logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; @@ -259,6 +260,16 @@ async function switchesSettled(): Promise { while (pendingSwitch) await pendingSwitch.settled; } +/** + * `signal` is the sign-in screen's Cancel (U-133): once it fires, the sign-in + * rejects with SignInCancelledError, puts the connection in use back and + * keeps nothing of the account it was adding. + */ +export interface SignInOptions { + addAccount?: boolean; + signal?: AbortSignal; +} + interface SignedIn { session: JMAPSession; serverUrl: string; @@ -276,12 +287,14 @@ interface SignedIn { async function signIn( set: (partial: Partial) => void, get: () => AuthState, - opts: { addAccount?: boolean } | undefined, + opts: SignInOptions | undefined, connect: () => Promise, ): Promise { signInsUnderWay += 1; let saved: typeof signInSaved = null; + const signal = opts?.signal; try { + throwIfCancelled(signal); // A switch under way lands first, so there is one account to put back. await switchesSettled(); const isCurrent = beginAccountChange(); @@ -304,7 +317,23 @@ async function signIn( let wasRegistered = true; const accountStore = useAccountStore.getState(); try { - signedIn = await connect(); + const connecting = connect(); + if (signal) { + // Cancelled while connecting: the connect goes on in the background + // until its next session check (putting the connection back below + // supersedes it). Should it get as far as storing credentials for + // the account anyway, they go too. + connecting.then( + (late) => { + if (!signal.aborted || signedIn) return; + if (!useAccountStore.getState().getAccountById(late.accountId)) { + void jmapClient.clearAccountCredentials(late.accountId).catch(() => undefined); + } + }, + () => undefined, + ); + } + signedIn = await abortable(connecting, signal); if (!isCurrent()) throw new SessionSupersededError(); wasRegistered = !!accountStore.getAccountById(signedIn.accountId); // Contacts/calendar are still single-bucket, so wipe those now that @@ -315,6 +344,7 @@ async function signIn( } await checkServerChange(signedIn.accountId, signedIn.session); if (!isCurrent()) throw new SessionSupersededError(); + throwIfCancelled(signal); accountStore.addAccount({ serverUrl: signedIn.serverUrl, username: signedIn.username, @@ -325,7 +355,8 @@ async function signIn( hasError: false, }); } catch (err) { - putBack(!!signedIn); + // A cancelled connect may already hold the client: take it back. + putBack(!!signedIn || isSignInCancelled(err)); // connect() stored credentials for an account the registry never took // (the account limit, or a newer account change): none stay behind. if (signedIn && !wasRegistered && !useAccountStore.getState().getAccountById(signedIn.accountId)) { @@ -394,7 +425,7 @@ function assertRoomForAccount(accountId?: string): void { // Adding an account to a full registry: refuse before a sign-in code is // spent or a browser sign-in started, since what they buy can't be kept. -function refuseAddWhenFull(set: (partial: Partial) => void, opts?: { addAccount?: boolean }): void { +function refuseAddWhenFull(set: (partial: Partial) => void, opts?: SignInOptions): void { if (!opts?.addAccount) return; try { assertRoomForAccount(); @@ -463,7 +494,7 @@ async function completeOAuthHandoff( set: (partial: Partial) => void, get: () => AuthState, result: Extract, - opts?: { addAccount?: boolean }, + opts?: SignInOptions, ): Promise { await signIn(set, get, opts, async () => { const { session, username, accountId } = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens); @@ -471,6 +502,23 @@ async function completeOAuthHandoff( }); } +// After an await of a sign-in step that takes no signal: stop when the user +// cancelled meanwhile. +function cancelledSignIn(set: (partial: Partial) => void, opts?: SignInOptions): void { + if (!opts?.signal?.aborted) return; + set({ isLoading: false, error: null }); + throwIfCancelled(opts.signal); +} + +function oauthSignInMessage(err: unknown): string | null { + if (isSignInCancelled(err)) return null; + return err instanceof AuthenticationError + ? 'Authentication rejected by server' + : err instanceof Error + ? err.message + : 'OAuth sign-in failed'; +} + function applyConnectedState( set: (partial: Partial) => void, session: JMAPSession, @@ -514,12 +562,16 @@ export const useAuthStore = create((set, get) => ({ // Adding an additional account keeps the live connection until the // new sign-in succeeded, so a typo doesn't kill the current session. await signIn(set, get, opts, async () => { - const session = await jmapClient.connect(serverUrl, username, password, opts?.totp); + const session = await jmapClient.connect(serverUrl, username, password, opts?.totp, opts?.signal); const base = serverUrl.replace(/\/+$/, ''); return { session, serverUrl: base, username, accountId: generateAccountId(username, base) }; }); set({ pendingTotpLogin: null }); } catch (err) { + if (isSignInCancelled(err)) { + set({ isLoading: false, error: null }); + throw err; + } if (err instanceof Error && err.name === 'TotpRequiredError') { // Keep what the user (or the webmail hand-off) supplied so the code // step doesn't make them retype the password. @@ -550,7 +602,7 @@ export const useAuthStore = create((set, get) => ({ }); set({ pendingTotpLogin: null }); } catch (err) { - set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' }); + set({ isLoading: false, error: isSignInCancelled(err) ? null : err instanceof Error ? err.message : 'Connection failed' }); throw err; } }, @@ -562,8 +614,11 @@ export const useAuthStore = create((set, get) => ({ // served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart // lands on a 404 or the admin page, so check first and fall back to the // server's own OAuth (PKCE) when the webmail is missing. - if (!(await probeWebmail(webmailUrl))) { + const hasWebmail = await probeWebmail(webmailUrl); + cancelledSignIn(set, opts); + if (!hasWebmail) { const metadata = await discoverOAuthMetadata(webmailUrl); + cancelledSignIn(set, opts); if (metadata) { await get().loginViaOAuth(webmailUrl, opts); return; @@ -575,12 +630,14 @@ export const useAuthStore = create((set, get) => ({ let result; try { result = await runWebmailHandoff(webmailUrl, { addAccount: opts?.addAccount }); + cancelledSignIn(set, opts); } catch (err) { if (err instanceof HandoffCancelledError) { // User closed the browser tab — quiet exit, no error banner. set({ isLoading: false, error: null }); return; } + if (isSignInCancelled(err)) throw err; const message = err instanceof Error ? err.message : 'Sign-in failed'; set({ isLoading: false, error: message }); throw err; @@ -600,13 +657,7 @@ export const useAuthStore = create((set, get) => ({ try { await completeOAuthHandoff(set, get, result, opts); } catch (err) { - const message = - err instanceof AuthenticationError - ? 'Authentication rejected by server' - : err instanceof Error - ? err.message - : 'OAuth sign-in failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: oauthSignInMessage(err) }); throw err; } }, @@ -620,11 +671,13 @@ export const useAuthStore = create((set, get) => ({ const metadata = await discoverOAuthMetadata(base); if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in'); tokens = await loginWithPkce(base, metadata, { addAccount: opts?.addAccount }); + cancelledSignIn(set, opts); } catch (err) { if (err instanceof HandoffCancelledError) { set({ isLoading: false, error: null }); return; } + if (isSignInCancelled(err)) throw err; const message = err instanceof Error ? err.message : 'Sign-in failed'; set({ isLoading: false, error: message }); throw err; @@ -632,13 +685,7 @@ export const useAuthStore = create((set, get) => ({ try { await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts); } catch (err) { - const message = - err instanceof AuthenticationError - ? 'Authentication rejected by server' - : err instanceof Error - ? err.message - : 'OAuth sign-in failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: oauthSignInMessage(err) }); throw err; } }, @@ -660,13 +707,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }); let result: HandoffResult; try { - result = await redeemPairingCode(webmailUrl, code); + result = await abortable(redeemPairingCode(webmailUrl, code), opts?.signal); } catch (err) { // Not redeemed here: the server is the judge of a retry (it answers // "used" if the request did get through). pairingCodesSeen.delete(code); const message = err instanceof Error ? err.message : 'Pairing failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: isSignInCancelled(err) ? null : message }); throw err; } @@ -677,6 +724,10 @@ export const useAuthStore = create((set, get) => ({ // keeps for that step), and the account limit, which a new code won't fix. const serverHost = hostOfUrl(result.serverUrl); const connectFailed = (err: unknown): unknown => { + if (isSignInCancelled(err)) { + set({ isLoading: false, error: null }); + return err; + } if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) { // `login` has settled the store already; the OAuth path has not. if (get().isLoading) set({ isLoading: false, error: err.message }); @@ -700,7 +751,7 @@ export const useAuthStore = create((set, get) => ({ // password): the normal password sign-in, which also keeps the live // account when adding another one fails. try { - await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount }); + await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount, signal: opts?.signal }); } catch (err) { throw connectFailed(err); }