From b52768e6fbce0c755cec4aab9d2cb10e5821d504 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:57:32 +0200 Subject: [PATCH] fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900) --- src/components/QrScanModal.tsx | 2 +- src/lib/login-errors.ts | 39 +++++++ src/screens/LoginScreen.tsx | 29 ++++- src/screens/login/PasswordStep.tsx | 27 ++++- src/stores/__tests__/auth-store.test.ts | 6 + src/stores/auth-store.ts | 149 ++++++++++++++++++++---- 6 files changed, 223 insertions(+), 29 deletions(-) diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index d50d08e0..c7f052a7 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -63,7 +63,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { Open Bulwark on your computer - Settings → Devices → Add phone shows a code. Point the camera at it. + Settings → Security → Link device shows a code. Point the camera at it. diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index 880b8889..595da995 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -36,6 +36,38 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} const lower = message.toLowerCase(); const host = hostLabel(context.serverUrl); + if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) { + return { + title: 'Enter your two-factor code', + detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.', + }; + } + + if (name === 'TotpLoginError' && lower.includes('invalid')) { + return { + title: "That didn't work", + detail: 'Check your password and the current code from your authenticator app, then try again.', + }; + } + + if (name === 'RateLimitError' || /\b429\b/.test(message) || lower.includes('rate limited')) { + const retryMs = (err as { retryAfterMs?: number } | null)?.retryAfterMs; + const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null; + return { + title: 'Too many attempts', + detail: seconds + ? `The server asked us to wait. Try again in about ${seconds} seconds.` + : 'The server asked us to wait a moment. Try again shortly.', + }; + } + + if (lower.includes('session discovery failed') && /\b402\b/.test(message)) { + return { + title: 'Enter your two-factor code', + detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.', + }; + } + if (name === 'AuthenticationError' || lower.includes('invalid username or password')) { return { title: "That didn't work", @@ -76,6 +108,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} }; } + if (lower.includes('session discovery failed') && /\b5\d\d\b/.test(message)) { + return { + title: `${host} is having trouble`, + detail: 'The server answered with an error. Try again in a few minutes.', + }; + } + if (lower.includes('pairing code')) { return { title: 'That code has expired', diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index cf998cc4..e36a0d26 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -55,6 +55,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // when discovery actually found one. const [serverDiscovered, setServerDiscovered] = React.useState(false); const [password, setPassword] = React.useState(''); + // Second factor. Shown only after the server asked for it (402), so the + // common no-2FA sign-in stays a two-field form. + const [totp, setTotp] = React.useState(''); + const [totpRequired, setTotpRequired] = React.useState(false); const [failedDomain, setFailedDomain] = React.useState(null); const [notice, setNotice] = React.useState(null); const [searching, setSearching] = React.useState(false); @@ -198,18 +202,31 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo return; } + if (totpRequired && !/^\d{6,8}$/.test(totp.trim())) { + setNotice({ title: 'Enter the 6-digit code from your authenticator app' }); + return; + } + setNotice(null); setBusy('connecting'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await login(target, email.trim(), password, { addAccount: isAddMode }); + await login(target, email.trim(), password, { + addAccount: isAddMode, + totp: totpRequired ? totp.trim() : undefined, + }); finishIfSignedIn(wasAuthenticated); } catch (err) { + if (err instanceof Error && err.name === 'TotpRequiredError') { + setTotpRequired(true); + setNotice(describeLoginError(err, { serverUrl: target })); + return; + } setNotice(describeLoginError(err, { serverUrl: target })); } finally { setBusy(null); } - }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]); + }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired]); const handleScanned = React.useCallback( async (data: string) => { @@ -218,7 +235,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo if (!payload) { setNotice({ title: "That code isn't a Bulwark sign-in code", - detail: 'Open Bulwark on the web, then Settings → Devices to show one.', + detail: 'Open Bulwark on the web, then Settings → Security → Link device to show one.', }); return; } @@ -364,6 +381,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo setNotice(null); setPassword(value); }} + totp={totp} + totpRequired={totpRequired} + onChangeTotp={(value) => { + setNotice(null); + setTotp(value.replace(/\s+/g, '')); + }} onSubmit={() => void handlePasswordSubmit()} notice={notice} /> diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx index e334da36..f454b3f2 100644 --- a/src/screens/login/PasswordStep.tsx +++ b/src/screens/login/PasswordStep.tsx @@ -12,6 +12,10 @@ interface PasswordStepProps { password: string; onChangeEmail: (value: string) => void; onChangePassword: (value: string) => void; + /** Second factor, shown once the server answered 402 "MFA code required". */ + totp?: string; + totpRequired?: boolean; + onChangeTotp?: (value: string) => void; onSubmit: () => void; notice?: { title: string; detail?: string } | null; } @@ -28,6 +32,9 @@ export default function PasswordStep({ password, onChangeEmail, onChangePassword, + totp = '', + totpRequired = false, + onChangeTotp, onSubmit, notice, }: PasswordStepProps) { @@ -65,8 +72,8 @@ export default function PasswordStep({ secureTextEntry={!showPassword} autoComplete="current-password" textContentType="password" - returnKeyType="go" - onSubmitEditing={onSubmit} + returnKeyType={totpRequired ? 'next' : 'go'} + onSubmitEditing={totpRequired ? undefined : onSubmit} rightIcon={ setShowPassword((v) => !v)} @@ -79,6 +86,22 @@ export default function PasswordStep({ } /> + {totpRequired ? ( + onChangeTotp?.(value)} + autoFocus + keyboardType="number-pad" + autoComplete="one-time-code" + textContentType="oneTimeCode" + maxLength={8} + returnKeyType="go" + onSubmitEditing={onSubmit} + /> + ) : null} + {notice ? : null}