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}