mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: let the user cancel a sign-in that hangs
This commit is contained in:
@@ -279,11 +279,13 @@ export class JMAPClient {
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
totp?: string,
|
totp?: string,
|
||||||
|
/** Cancels the token requests of a TOTP sign-in (the sign-in screen's Cancel). */
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<JMAPSession> {
|
): Promise<JMAPSession> {
|
||||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||||
const epoch = this.detachSession();
|
const epoch = this.detachSession();
|
||||||
if (totp) {
|
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 };
|
const credentials: StoredCredentials = { serverUrl: baseUrl, username, password };
|
||||||
this.credentials = credentials;
|
this.credentials = credentials;
|
||||||
@@ -314,11 +316,12 @@ export class JMAPClient {
|
|||||||
password: string,
|
password: string,
|
||||||
totp: string,
|
totp: string,
|
||||||
epoch: number,
|
epoch: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<JMAPSession> {
|
): Promise<JMAPSession> {
|
||||||
const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login');
|
const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login');
|
||||||
let tokens: OAuthTokens;
|
let tokens: OAuthTokens;
|
||||||
try {
|
try {
|
||||||
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp);
|
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp, { signal });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof LoginEndpointMissingError) {
|
if (err instanceof LoginEndpointMissingError) {
|
||||||
this.assertCurrentSession(epoch);
|
this.assertCurrentSession(epoch);
|
||||||
|
|||||||
@@ -196,4 +196,16 @@ describe('discoverServerForEmail', () => {
|
|||||||
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
|
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
|
||||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ describe('exchangePasswordForTokens', () => {
|
|||||||
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
|
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<AbortSignal | undefined> = [];
|
||||||
|
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 () => {
|
it('registers its own client on Stalwart 1.0 and signs in with it', async () => {
|
||||||
const calls = fakeServer('1.0');
|
const calls = fakeServer('1.0');
|
||||||
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|||||||
+26
-18
@@ -8,6 +8,7 @@
|
|||||||
// slow or hostile network must never be worse than typing it in.
|
// slow or hostile network must never be worse than typing it in.
|
||||||
|
|
||||||
import { secureFetch } from './client-cert';
|
import { secureFetch } from './client-cert';
|
||||||
|
import { signalWithTimeout } from './sign-in-abort';
|
||||||
|
|
||||||
export const DISCOVERY_TIMEOUT_MS = 2500;
|
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}`];
|
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T, signal?: AbortSignal): Promise<T> {
|
||||||
return new Promise<T>((resolve) => {
|
return new Promise<T>((resolve) => {
|
||||||
const timer = setTimeout(() => resolve(fallback), ms);
|
if (signal?.aborted) {
|
||||||
promise
|
resolve(fallback);
|
||||||
.then((value) => {
|
return;
|
||||||
clearTimeout(timer);
|
}
|
||||||
resolve(value);
|
const finish = (value: T) => {
|
||||||
})
|
clearTimeout(timer);
|
||||||
.catch(() => {
|
signal?.removeEventListener('abort', onAbort);
|
||||||
clearTimeout(timer);
|
resolve(value);
|
||||||
resolve(fallback);
|
};
|
||||||
});
|
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(
|
export async function probeJmapServer(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
// Gives up at the deadline, or when the user cancels the sign-in.
|
||||||
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
|
const deadline = signalWithTimeout(signal, timeoutMs);
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
signal: controller?.signal,
|
signal: deadline.signal,
|
||||||
});
|
});
|
||||||
if (response.status === 401) return true;
|
if (response.status === 401) return true;
|
||||||
if (response.status !== 200) return false;
|
if (response.status !== 200) return false;
|
||||||
@@ -149,9 +154,9 @@ export async function probeJmapServer(
|
|||||||
try {
|
try {
|
||||||
// The abort signal doesn't reach the native client-certificate path, so
|
// The abort signal doesn't reach the native client-certificate path, so
|
||||||
// race the whole thing rather than trusting the request to give up.
|
// 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 {
|
} finally {
|
||||||
clearTimeout(abortTimer);
|
deadline.done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +164,8 @@ export interface DiscoveryOptions {
|
|||||||
/** Servers of already-registered accounts, trusted without a probe. */
|
/** Servers of already-registered accounts, trusted without a probe. */
|
||||||
knownServerUrls?: string[];
|
knownServerUrls?: string[];
|
||||||
timeoutMs?: number;
|
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 = {},
|
options: DiscoveryOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const domain = emailDomain(email);
|
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
|
// 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.
|
// 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 timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
|
||||||
const candidates = serverCandidates(domain);
|
const candidates = serverCandidates(domain);
|
||||||
const results = await Promise.all(
|
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
|
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
|
||||||
// both answer.
|
// both answer.
|
||||||
|
|||||||
@@ -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<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||||
|
if (!signal) return promise;
|
||||||
|
if (signal.aborted) {
|
||||||
|
promise.catch(() => undefined);
|
||||||
|
return Promise.reject(new SignInCancelledError());
|
||||||
|
}
|
||||||
|
return new Promise<T>((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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+28
-19
@@ -29,6 +29,7 @@ import { sha256Hex } from './sha256';
|
|||||||
import { base64UrlEncode, randomBytes, randomHex } from './random';
|
import { base64UrlEncode, randomBytes, randomHex } from './random';
|
||||||
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
|
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
|
||||||
import { NetworkError } from '../api/jmap-client';
|
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
|
// 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.
|
// 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);
|
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
|
// The password and the PKCE verifier must not follow a redirect to another
|
||||||
// host. The requests go out with fetchWithoutForeignRedirect, which hands such
|
// host. The requests go out with fetchWithoutForeignRedirect, which hands such
|
||||||
// a redirect back unfollowed; where the platform followed it anyway, the
|
// 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
|
* 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.
|
* 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<LoginClient> {
|
async function registerPublicClient(
|
||||||
const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS);
|
base: string,
|
||||||
|
url = `${base}/auth/register`,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<LoginClient> {
|
||||||
|
const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetchWithoutForeignRedirect(url, {
|
response = await fetchWithoutForeignRedirect(url, {
|
||||||
@@ -182,6 +180,7 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`)
|
|||||||
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
throwIfCancelled(signal);
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
} finally {
|
} finally {
|
||||||
t.done();
|
t.done();
|
||||||
@@ -207,6 +206,7 @@ async function checkClient(
|
|||||||
base: string,
|
base: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -217,7 +217,7 @@ async function checkClient(
|
|||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
const url = `${base}/api/auth/client?${query.toString()}`;
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetchWithoutForeignRedirect(url, {
|
response = await fetchWithoutForeignRedirect(url, {
|
||||||
@@ -226,6 +226,7 @@ async function checkClient(
|
|||||||
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
throwIfCancelled(signal);
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
} finally {
|
} finally {
|
||||||
t.done();
|
t.done();
|
||||||
@@ -253,14 +254,15 @@ async function checkClient(
|
|||||||
*/
|
*/
|
||||||
export async function resolveLoginClient(
|
export async function resolveLoginClient(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
opts?: { registrationEndpoint?: string },
|
opts?: { registrationEndpoint?: string; signal?: AbortSignal },
|
||||||
): Promise<LoginClient> {
|
): Promise<LoginClient> {
|
||||||
const base = trimBase(serverUrl);
|
const base = trimBase(serverUrl);
|
||||||
const cached = loginClients.get(base);
|
const cached = loginClients.get(base);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
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 === 'busy') return fallback;
|
||||||
if (own === 'known') {
|
if (own === 'known') {
|
||||||
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
|
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
|
||||||
@@ -268,11 +270,11 @@ export async function resolveLoginClient(
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
if (own === 'unknown') {
|
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 === 'busy') return fallback;
|
||||||
if (shared === 'unknown') {
|
if (shared === 'unknown') {
|
||||||
const endpoint = opts?.registrationEndpoint;
|
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);
|
rememberClient(base, fallback);
|
||||||
@@ -386,6 +388,7 @@ async function requestLoginCode(
|
|||||||
totp: string | undefined,
|
totp: string | undefined,
|
||||||
client: LoginClient,
|
client: LoginClient,
|
||||||
challenge: string,
|
challenge: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<StalwartLoginOutcome> {
|
): Promise<StalwartLoginOutcome> {
|
||||||
const state = randomHex(16);
|
const state = randomHex(16);
|
||||||
const url = `${base}/api/auth`;
|
const url = `${base}/api/auth`;
|
||||||
@@ -403,8 +406,10 @@ async function requestLoginCode(
|
|||||||
codeChallenge: challenge,
|
codeChallenge: challenge,
|
||||||
state,
|
state,
|
||||||
})),
|
})),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
throwIfCancelled(signal);
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
}
|
}
|
||||||
assertSameServer(url, loginResponse);
|
assertSameServer(url, loginResponse);
|
||||||
@@ -434,26 +439,28 @@ export async function exchangePasswordForTokens(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
totp?: string,
|
totp?: string,
|
||||||
opts?: { clientId?: string; redirectUri?: string },
|
opts?: { clientId?: string; redirectUri?: string; signal?: AbortSignal },
|
||||||
): Promise<OAuthTokens> {
|
): Promise<OAuthTokens> {
|
||||||
const base = trimBase(serverUrl);
|
const base = trimBase(serverUrl);
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = generateCodeChallenge(verifier);
|
const challenge = generateCodeChallenge(verifier);
|
||||||
|
const signal = opts?.signal;
|
||||||
|
throwIfCancelled(signal);
|
||||||
|
|
||||||
let client: LoginClient = opts?.clientId
|
let client: LoginClient = opts?.clientId
|
||||||
? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI }
|
? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI }
|
||||||
: await resolveLoginClient(base);
|
: await resolveLoginClient(base, { signal });
|
||||||
let outcome = await requestLoginCode(base, username, password, totp, client, challenge);
|
let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
|
||||||
// A remembered client the server no longer knows (another server now
|
// A remembered client the server no longer knows (another server now
|
||||||
// answers at the address, or its key changed), or the default taken while
|
// 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 client check failed: ask again, once. 1.0 checks the client before
|
||||||
// the credentials, so this costs no failed sign-in.
|
// the credentials, so this costs no failed sign-in.
|
||||||
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) {
|
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) {
|
||||||
loginClients.delete(base);
|
loginClients.delete(base);
|
||||||
const fresh = await resolveLoginClient(base);
|
const fresh = await resolveLoginClient(base, { signal });
|
||||||
if (fresh.clientId !== client.clientId) {
|
if (fresh.clientId !== client.clientId) {
|
||||||
client = fresh;
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
body: tokenParams.toString(),
|
body: tokenParams.toString(),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
throwIfCancelled(signal);
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
}
|
}
|
||||||
assertSameServer(tokenUrl, tokenResponse);
|
assertSameServer(tokenUrl, tokenResponse);
|
||||||
|
|||||||
+93
-33
@@ -33,6 +33,8 @@ import ServerStep from './login/ServerStep';
|
|||||||
import ConfirmStep from './login/ConfirmStep';
|
import ConfirmStep from './login/ConfirmStep';
|
||||||
import PasswordStep from './login/PasswordStep';
|
import PasswordStep from './login/PasswordStep';
|
||||||
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
|
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
|
||||||
|
import { loginBackAction } from './login/login-back';
|
||||||
|
import { isSignInCancelled } from '../lib/sign-in-abort';
|
||||||
|
|
||||||
interface LoginScreenProps {
|
interface LoginScreenProps {
|
||||||
onLogin?: () => void;
|
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).
|
// (in add mode the server fields still name the signed-in account's host).
|
||||||
const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState<string | null>(null);
|
const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState<string | null>(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<AbortController | null>(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
|
// Addresses typed before on this device (last 5) plus the registry's, so a
|
||||||
// returning user taps instead of retyping. Never contains passwords.
|
// returning user taps instead of retyping. Never contains passwords.
|
||||||
const [recentEmails, setRecentEmails] = React.useState<string[]>([]);
|
const [recentEmails, setRecentEmails] = React.useState<string[]>([]);
|
||||||
@@ -216,27 +245,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
// out of the flow entirely from the first step.
|
// out of the flow entirely from the first step.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
if (busy || searching) return true;
|
const action = loginBackAction({
|
||||||
if (scannerVisible) {
|
busy: !!busy,
|
||||||
setScannerVisible(false);
|
searching,
|
||||||
return true;
|
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();
|
return () => subscription.remove();
|
||||||
}, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]);
|
}, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel, cancelSignIn]);
|
||||||
|
|
||||||
// ── flows ──────────────────────────────────────────────
|
// ── flows ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -279,21 +317,24 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
const runHandoff = React.useCallback(
|
const runHandoff = React.useCallback(
|
||||||
async (target: string) => {
|
async (target: string) => {
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
const attempt = beginAttempt();
|
||||||
setBusy('browser');
|
setBusy('browser');
|
||||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
try {
|
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 —
|
// loginViaWebmail resolves quietly when the user closes the browser —
|
||||||
// that's a cancellation, not a failure, so leave them where they were.
|
// that's a cancellation, not a failure, so leave them where they were.
|
||||||
finishIfSignedIn(wasAuthenticated);
|
finishIfSignedIn(wasAuthenticated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
||||||
if (continueWithSecondFactor(err, target)) return;
|
if (continueWithSecondFactor(err, target)) return;
|
||||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||||
} finally {
|
} 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 () => {
|
const handleEmailContinue = React.useCallback(async () => {
|
||||||
@@ -307,10 +348,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
const attempt = beginAttempt();
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
rememberEmail(value);
|
rememberEmail(value);
|
||||||
try {
|
try {
|
||||||
const found = await discoverServerForEmail(value, { knownServerUrls });
|
const found = await discoverServerForEmail(value, { knownServerUrls, signal: attempt.signal });
|
||||||
|
if (!isLive(attempt)) return;
|
||||||
if (found) {
|
if (found) {
|
||||||
setServerUrl(found);
|
setServerUrl(found);
|
||||||
setServerInput(found);
|
setServerInput(found);
|
||||||
@@ -322,9 +365,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
goTo('server');
|
goTo('server');
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 handleServerContinue = React.useCallback(() => {
|
||||||
const normalized = normalizeServerUrl(serverInput);
|
const normalized = normalizeServerUrl(serverInput);
|
||||||
@@ -353,15 +396,18 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
const attempt = beginAttempt();
|
||||||
setBusy('connecting');
|
setBusy('connecting');
|
||||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
try {
|
try {
|
||||||
await loginWithToken(target, accessToken, { addAccount: isAddMode });
|
await loginWithToken(target, accessToken, { addAccount: isAddMode, signal: attempt.signal });
|
||||||
|
if (!isLive(attempt)) return;
|
||||||
finishIfSignedIn(wasAuthenticated);
|
finishIfSignedIn(wasAuthenticated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
||||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
if (endAttempt(attempt)) setBusy(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -377,15 +423,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
const attempt = beginAttempt();
|
||||||
setBusy('connecting');
|
setBusy('connecting');
|
||||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
try {
|
try {
|
||||||
await login(target, email.trim(), password, {
|
await login(target, email.trim(), password, {
|
||||||
addAccount: isAddMode,
|
addAccount: isAddMode,
|
||||||
totp: totpRequired ? totp.trim() : undefined,
|
totp: totpRequired ? totp.trim() : undefined,
|
||||||
|
signal: attempt.signal,
|
||||||
});
|
});
|
||||||
|
if (!isLive(attempt)) return;
|
||||||
finishIfSignedIn(wasAuthenticated);
|
finishIfSignedIn(wasAuthenticated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
||||||
if (err instanceof Error && err.name === 'TotpRequiredError') {
|
if (err instanceof Error && err.name === 'TotpRequiredError') {
|
||||||
setTotpRequired(true);
|
setTotpRequired(true);
|
||||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
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 }));
|
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||||
} finally {
|
} 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
|
// A sign-in code, however it arrived: scanned, pasted, or a tapped
|
||||||
// `bulwarkmail://` link.
|
// `bulwarkmail://` link.
|
||||||
@@ -415,23 +465,28 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
|
|
||||||
// Cross-device pairing code: redeem it, no browser needed.
|
// Cross-device pairing code: redeem it, no browser needed.
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
const attempt = beginAttempt();
|
||||||
setPairingWebmailUrl(payload.webmailUrl);
|
setPairingWebmailUrl(payload.webmailUrl);
|
||||||
setBusy('pairing');
|
setBusy('pairing');
|
||||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
try {
|
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);
|
finishIfSignedIn(wasAuthenticated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
||||||
if (continueWithSecondFactor(err, payload.webmailUrl)) return;
|
if (continueWithSecondFactor(err, payload.webmailUrl)) return;
|
||||||
// The error names its own host: the webmail for a failed redeem, the
|
// The error names its own host: the webmail for a failed redeem, the
|
||||||
// mail server when connecting afterwards failed.
|
// mail server when connecting afterwards failed.
|
||||||
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
|
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
if (endAttempt(attempt)) {
|
||||||
setPairingWebmailUrl(null);
|
setBusy(null);
|
||||||
|
setPairingWebmailUrl(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
|
[beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScanned = React.useCallback(
|
const handleScanned = React.useCallback(
|
||||||
@@ -544,6 +599,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
phase={busy}
|
phase={busy}
|
||||||
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
||||||
email={pairing || tokenMode ? null : email.trim() || null}
|
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 canGoBack = history.length > 0;
|
||||||
const shellProps = {
|
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,
|
onClose: isAddMode && onCancel ? onCancel : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||||
@@ -16,6 +16,8 @@ interface SigningInStepProps {
|
|||||||
phase: SigningInPhase;
|
phase: SigningInPhase;
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
email: 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 {
|
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
|
* current stage makes a slow first connection legible instead of a bare
|
||||||
* spinner that could mean anything.
|
* 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 c = useColors();
|
||||||
const theme = useResolvedTheme();
|
const theme = useResolvedTheme();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -68,6 +70,18 @@ export default function SigningInStep({ phase, serverUrl, email }: SigningInStep
|
|||||||
<Text style={styles.detail}>{copy[phase].detail}</Text>
|
<Text style={styles.detail}>{copy[phase].detail}</Text>
|
||||||
|
|
||||||
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
|
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
|
||||||
|
|
||||||
|
{onCancel ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onCancel}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => [styles.cancel, pressed && styles.cancelPressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('login.mobile.cancel_sign_in', 'Cancel sign-in')}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -98,5 +112,15 @@ function makeStyles(c: ThemePalette) {
|
|||||||
title: { ...typography.h2, color: c.text, textAlign: 'center' },
|
title: { ...typography.h2, color: c.text, textAlign: 'center' },
|
||||||
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
|
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
|
||||||
spinner: { marginTop: spacing.xl },
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,7 +132,7 @@ describe('loginViaPairing with an app password', () => {
|
|||||||
|
|
||||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
|
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(connectWithOAuth).not.toHaveBeenCalled();
|
||||||
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER });
|
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER });
|
||||||
});
|
});
|
||||||
|
|||||||
+81
-30
@@ -41,6 +41,7 @@ import {
|
|||||||
import { reconcileServerFingerprint } from '../lib/server-change';
|
import { reconcileServerFingerprint } from '../lib/server-change';
|
||||||
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
||||||
import { perfMark } from '../lib/perf-marks';
|
import { perfMark } from '../lib/perf-marks';
|
||||||
|
import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort';
|
||||||
|
|
||||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||||
@@ -97,14 +98,14 @@ export interface AuthState {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
opts?: { addAccount?: boolean; totp?: string },
|
opts?: SignInOptions & { totp?: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
/** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */
|
/** 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<void>;
|
loginWithToken: (serverUrl: string, token: string, opts?: SignInOptions) => Promise<void>;
|
||||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaWebmail: (webmailUrl: string, opts?: SignInOptions) => Promise<void>;
|
||||||
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
||||||
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaOAuth: (serverUrl: string, opts?: SignInOptions) => Promise<void>;
|
||||||
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaPairing: (webmailUrl: string, code: string, opts?: SignInOptions) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => Promise<void>;
|
logoutAll: () => Promise<void>;
|
||||||
switchAccount: (accountId: string) => Promise<void>;
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
@@ -259,6 +260,16 @@ async function switchesSettled(): Promise<void> {
|
|||||||
while (pendingSwitch) await pendingSwitch.settled;
|
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 {
|
interface SignedIn {
|
||||||
session: JMAPSession;
|
session: JMAPSession;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -276,12 +287,14 @@ interface SignedIn {
|
|||||||
async function signIn(
|
async function signIn(
|
||||||
set: (partial: Partial<AuthState>) => void,
|
set: (partial: Partial<AuthState>) => void,
|
||||||
get: () => AuthState,
|
get: () => AuthState,
|
||||||
opts: { addAccount?: boolean } | undefined,
|
opts: SignInOptions | undefined,
|
||||||
connect: () => Promise<SignedIn>,
|
connect: () => Promise<SignedIn>,
|
||||||
): Promise<SignedIn> {
|
): Promise<SignedIn> {
|
||||||
signInsUnderWay += 1;
|
signInsUnderWay += 1;
|
||||||
let saved: typeof signInSaved = null;
|
let saved: typeof signInSaved = null;
|
||||||
|
const signal = opts?.signal;
|
||||||
try {
|
try {
|
||||||
|
throwIfCancelled(signal);
|
||||||
// A switch under way lands first, so there is one account to put back.
|
// A switch under way lands first, so there is one account to put back.
|
||||||
await switchesSettled();
|
await switchesSettled();
|
||||||
const isCurrent = beginAccountChange();
|
const isCurrent = beginAccountChange();
|
||||||
@@ -304,7 +317,23 @@ async function signIn(
|
|||||||
let wasRegistered = true;
|
let wasRegistered = true;
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
try {
|
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();
|
if (!isCurrent()) throw new SessionSupersededError();
|
||||||
wasRegistered = !!accountStore.getAccountById(signedIn.accountId);
|
wasRegistered = !!accountStore.getAccountById(signedIn.accountId);
|
||||||
// Contacts/calendar are still single-bucket, so wipe those now that
|
// Contacts/calendar are still single-bucket, so wipe those now that
|
||||||
@@ -315,6 +344,7 @@ async function signIn(
|
|||||||
}
|
}
|
||||||
await checkServerChange(signedIn.accountId, signedIn.session);
|
await checkServerChange(signedIn.accountId, signedIn.session);
|
||||||
if (!isCurrent()) throw new SessionSupersededError();
|
if (!isCurrent()) throw new SessionSupersededError();
|
||||||
|
throwIfCancelled(signal);
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
serverUrl: signedIn.serverUrl,
|
serverUrl: signedIn.serverUrl,
|
||||||
username: signedIn.username,
|
username: signedIn.username,
|
||||||
@@ -325,7 +355,8 @@ async function signIn(
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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
|
// connect() stored credentials for an account the registry never took
|
||||||
// (the account limit, or a newer account change): none stay behind.
|
// (the account limit, or a newer account change): none stay behind.
|
||||||
if (signedIn && !wasRegistered && !useAccountStore.getState().getAccountById(signedIn.accountId)) {
|
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
|
// 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.
|
// spent or a browser sign-in started, since what they buy can't be kept.
|
||||||
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: { addAccount?: boolean }): void {
|
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: SignInOptions): void {
|
||||||
if (!opts?.addAccount) return;
|
if (!opts?.addAccount) return;
|
||||||
try {
|
try {
|
||||||
assertRoomForAccount();
|
assertRoomForAccount();
|
||||||
@@ -463,7 +494,7 @@ async function completeOAuthHandoff(
|
|||||||
set: (partial: Partial<AuthState>) => void,
|
set: (partial: Partial<AuthState>) => void,
|
||||||
get: () => AuthState,
|
get: () => AuthState,
|
||||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||||
opts?: { addAccount?: boolean },
|
opts?: SignInOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await signIn(set, get, opts, async () => {
|
await signIn(set, get, opts, async () => {
|
||||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
|
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<AuthState>) => 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(
|
function applyConnectedState(
|
||||||
set: (partial: Partial<AuthState>) => void,
|
set: (partial: Partial<AuthState>) => void,
|
||||||
session: JMAPSession,
|
session: JMAPSession,
|
||||||
@@ -514,12 +562,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// Adding an additional account keeps the live connection until the
|
// Adding an additional account keeps the live connection until the
|
||||||
// new sign-in succeeded, so a typo doesn't kill the current session.
|
// new sign-in succeeded, so a typo doesn't kill the current session.
|
||||||
await signIn(set, get, opts, async () => {
|
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(/\/+$/, '');
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
return { session, serverUrl: base, username, accountId: generateAccountId(username, base) };
|
return { session, serverUrl: base, username, accountId: generateAccountId(username, base) };
|
||||||
});
|
});
|
||||||
set({ pendingTotpLogin: null });
|
set({ pendingTotpLogin: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isSignInCancelled(err)) {
|
||||||
|
set({ isLoading: false, error: null });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
if (err instanceof Error && err.name === 'TotpRequiredError') {
|
if (err instanceof Error && err.name === 'TotpRequiredError') {
|
||||||
// Keep what the user (or the webmail hand-off) supplied so the code
|
// Keep what the user (or the webmail hand-off) supplied so the code
|
||||||
// step doesn't make them retype the password.
|
// step doesn't make them retype the password.
|
||||||
@@ -550,7 +602,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
set({ pendingTotpLogin: null });
|
set({ pendingTotpLogin: null });
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -562,8 +614,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart
|
// 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
|
// 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.
|
// 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);
|
const metadata = await discoverOAuthMetadata(webmailUrl);
|
||||||
|
cancelledSignIn(set, opts);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
await get().loginViaOAuth(webmailUrl, opts);
|
await get().loginViaOAuth(webmailUrl, opts);
|
||||||
return;
|
return;
|
||||||
@@ -575,12 +630,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await runWebmailHandoff(webmailUrl, { addAccount: opts?.addAccount });
|
result = await runWebmailHandoff(webmailUrl, { addAccount: opts?.addAccount });
|
||||||
|
cancelledSignIn(set, opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HandoffCancelledError) {
|
if (err instanceof HandoffCancelledError) {
|
||||||
// User closed the browser tab — quiet exit, no error banner.
|
// User closed the browser tab — quiet exit, no error banner.
|
||||||
set({ isLoading: false, error: null });
|
set({ isLoading: false, error: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSignInCancelled(err)) throw err;
|
||||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: message });
|
||||||
throw err;
|
throw err;
|
||||||
@@ -600,13 +657,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await completeOAuthHandoff(set, get, result, opts);
|
await completeOAuthHandoff(set, get, result, opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
set({ isLoading: false, error: oauthSignInMessage(err) });
|
||||||
err instanceof AuthenticationError
|
|
||||||
? 'Authentication rejected by server'
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: 'OAuth sign-in failed';
|
|
||||||
set({ isLoading: false, error: message });
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -620,11 +671,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const metadata = await discoverOAuthMetadata(base);
|
const metadata = await discoverOAuthMetadata(base);
|
||||||
if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in');
|
if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in');
|
||||||
tokens = await loginWithPkce(base, metadata, { addAccount: opts?.addAccount });
|
tokens = await loginWithPkce(base, metadata, { addAccount: opts?.addAccount });
|
||||||
|
cancelledSignIn(set, opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HandoffCancelledError) {
|
if (err instanceof HandoffCancelledError) {
|
||||||
set({ isLoading: false, error: null });
|
set({ isLoading: false, error: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSignInCancelled(err)) throw err;
|
||||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: message });
|
||||||
throw err;
|
throw err;
|
||||||
@@ -632,13 +685,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts);
|
await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
set({ isLoading: false, error: oauthSignInMessage(err) });
|
||||||
err instanceof AuthenticationError
|
|
||||||
? 'Authentication rejected by server'
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: 'OAuth sign-in failed';
|
|
||||||
set({ isLoading: false, error: message });
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -660,13 +707,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
let result: HandoffResult;
|
let result: HandoffResult;
|
||||||
try {
|
try {
|
||||||
result = await redeemPairingCode(webmailUrl, code);
|
result = await abortable(redeemPairingCode(webmailUrl, code), opts?.signal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Not redeemed here: the server is the judge of a retry (it answers
|
// Not redeemed here: the server is the judge of a retry (it answers
|
||||||
// "used" if the request did get through).
|
// "used" if the request did get through).
|
||||||
pairingCodesSeen.delete(code);
|
pairingCodesSeen.delete(code);
|
||||||
const message = err instanceof Error ? err.message : 'Pairing failed';
|
const message = err instanceof Error ? err.message : 'Pairing failed';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: isSignInCancelled(err) ? null : message });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +724,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// keeps for that step), and the account limit, which a new code won't fix.
|
// keeps for that step), and the account limit, which a new code won't fix.
|
||||||
const serverHost = hostOfUrl(result.serverUrl);
|
const serverHost = hostOfUrl(result.serverUrl);
|
||||||
const connectFailed = (err: unknown): unknown => {
|
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)) {
|
if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) {
|
||||||
// `login` has settled the store already; the OAuth path has not.
|
// `login` has settled the store already; the OAuth path has not.
|
||||||
if (get().isLoading) set({ isLoading: false, error: err.message });
|
if (get().isLoading) set({ isLoading: false, error: err.message });
|
||||||
@@ -700,7 +751,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// password): the normal password sign-in, which also keeps the live
|
// password): the normal password sign-in, which also keeps the live
|
||||||
// account when adding another one fails.
|
// account when adding another one fails.
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
throw connectFailed(err);
|
throw connectFailed(err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user