diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index e36a0d26..42b2f980 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -140,12 +140,27 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // that's a cancellation, not a failure, so leave them where they were. finishIfSignedIn(wasAuthenticated); } catch (err) { + // The webmail hands back the password even when the account needs a + // second factor; finish the sign-in here with a code instead of + // failing (the user already proved the password once). + const pending = useAuthStore.getState().pendingTotpLogin; + if (err instanceof Error && err.name === 'TotpRequiredError' && pending) { + setEmail(pending.username); + setPassword(pending.password); + setServerUrl(pending.serverUrl); + setTotpRequired(true); + setBusy(null); + setNotice(describeLoginError(err, { serverUrl: target })); + setHistory((prev) => [...prev, step]); + setStep('password'); + return; + } setNotice(describeLoginError(err, { serverUrl: target })); } finally { setBusy(null); } }, - [finishIfSignedIn, isAddMode, loginViaWebmail], + [finishIfSignedIn, isAddMode, loginViaWebmail, step], ); const handleEmailContinue = React.useCallback(async () => { diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index d3ee7a0f..b9753590 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -41,6 +41,12 @@ export interface AuthState { accountId: string | null; activeAccountId: string | null; client: typeof jmapClient | null; + /** + * Set when a password sign-in was refused with "MFA code required"; the + * login screen re-runs it with a code via `login(..., { totp })`. Cleared + * on the next successful sign-in. + */ + pendingTotpLogin: { serverUrl: string; username: string; password: string } | null; login: ( serverUrl: string, @@ -232,6 +238,7 @@ export const useAuthStore = create((set, get) => ({ accountId: null, activeAccountId: null, client: null, + pendingTotpLogin: null, login: async (serverUrl, username, password, opts) => { set({ isLoading: true, error: null }); @@ -270,8 +277,14 @@ export const useAuthStore = create((set, get) => ({ useEmailStore.getState().setActiveAccount(accountId); applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId); + set({ pendingTotpLogin: null }); void syncAccountDisplayName(accountId); } catch (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. + set({ pendingTotpLogin: { serverUrl: serverUrl.replace(/\/+$/, ''), username, password } }); + } const message = err instanceof Error && err.name === 'TotpRequiredError' ? 'Two-factor code required' : err instanceof AuthenticationError