fix: finish a webmail hand-off with a TOTP code instead of failing

This commit is contained in:
Linus Rath
2026-08-29 18:06:48 +02:00
parent 99eb525d22
commit 0d94d7643c
2 changed files with 29 additions and 1 deletions
+16 -1
View File
@@ -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. // that's a cancellation, not a failure, so leave them where they were.
finishIfSignedIn(wasAuthenticated); finishIfSignedIn(wasAuthenticated);
} catch (err) { } 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 })); setNotice(describeLoginError(err, { serverUrl: target }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
}, },
[finishIfSignedIn, isAddMode, loginViaWebmail], [finishIfSignedIn, isAddMode, loginViaWebmail, step],
); );
const handleEmailContinue = React.useCallback(async () => { const handleEmailContinue = React.useCallback(async () => {
+13
View File
@@ -41,6 +41,12 @@ export interface AuthState {
accountId: string | null; accountId: string | null;
activeAccountId: string | null; activeAccountId: string | null;
client: typeof jmapClient | 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: ( login: (
serverUrl: string, serverUrl: string,
@@ -232,6 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accountId: null, accountId: null,
activeAccountId: null, activeAccountId: null,
client: null, client: null,
pendingTotpLogin: null,
login: async (serverUrl, username, password, opts) => { login: async (serverUrl, username, password, opts) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
@@ -270,8 +277,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useEmailStore.getState().setActiveAccount(accountId); useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId); applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
set({ pendingTotpLogin: null });
void syncAccountDisplayName(accountId); void syncAccountDisplayName(accountId);
} catch (err) { } 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' const message = err instanceof Error && err.name === 'TotpRequiredError'
? 'Two-factor code required' ? 'Two-factor code required'
: err instanceof AuthenticationError : err instanceof AuthenticationError