GUI: support 2FA (TOTP) on first-run login

The GUI login path passed no TOTP callback, so a fresh sign-in on a 2FA
account failed with "2FA required but no TOTP callback provided". The
dashboard login form now has an optional two-factor code field next to the
password, and start_bridge forwards it as the TOTP callback, so a 2FA
account signs in on a single attempt. If 2FA is needed but no code was
entered, the form surfaces a hint instead of a raw error.
This commit is contained in:
Anthony
2026-06-12 21:17:29 +02:00
parent 389a46d91c
commit 836ca6f345
4 changed files with 85 additions and 34 deletions
+8 -1
View File
@@ -44,6 +44,7 @@ pub async fn has_saved_session() -> Result<bool, String> {
pub async fn start_bridge(
email: Option<String>,
password: Option<String>,
totp: Option<String>,
state: State<'_, BridgeState>,
) -> Result<(), String> {
let mut cfg = match config::load_config() {
@@ -67,8 +68,14 @@ pub async fn start_bridge(
config::ensure_bridge_password(&mut cfg)
.map_err(|e| format!("Bridge password setup failed: {e}"))?;
// If the user supplied a 2FA code, hand the login a callback that returns
// it. Without 2FA on the account this is simply never invoked.
let totp_cb = totp
.and_then(|c| c.trim().parse::<u32>().ok())
.map(|code| tuta::TwoFactorCallback::Totp(Box::new(move || Ok(code))));
let mut handle = state.lock().await;
handle.start(cfg, password, None).await
handle.start(cfg, password, totp_cb).await
}
#[tauri::command]
+1
View File
@@ -74,6 +74,7 @@ function App() {
hasSavedSession={bridge.hasSavedSession}
loading={bridge.loading}
logs={bridge.logs}
needsTotp={bridge.needsTotp}
onStart={bridge.startBridge}
onStop={bridge.stopBridge}
onClearLogs={bridge.clearLogs}
+47 -21
View File
@@ -9,7 +9,8 @@ interface Props {
hasSavedSession: boolean;
loading: boolean;
logs: string[];
onStart: (password?: string, email?: string) => Promise<void>;
needsTotp: boolean;
onStart: (password?: string, email?: string, totp?: string) => Promise<void>;
onStop: () => Promise<void>;
onClearLogs: () => void;
}
@@ -29,12 +30,14 @@ export function Dashboard({
hasSavedSession,
loading,
logs,
needsTotp,
onStart,
onStop,
onClearLogs,
}: Props) {
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [totp, setTotp] = useState("");
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -81,16 +84,20 @@ export function Dashboard({
? "Signing in and syncing your mailbox"
: errored
? "See the activity log below"
: needsEmail
? "Sign in with your Tuta account to get started"
: "Start the bridge to connect your mail client";
: needsTotp
? "Enter your two-factor code to finish signing in"
: needsEmail
? "Sign in with your Tuta account to get started"
: "Start the bridge to connect your mail client";
const handleStart = async () => {
await onStart(
needsPassword ? password : undefined,
needsEmail ? email.trim() : undefined,
// Always pass the code if entered, so an account with 2FA logs in on a
// single attempt instead of failing first and prompting.
totp.trim() || undefined,
);
setPassword("");
};
return (
@@ -125,29 +132,48 @@ export function Dashboard({
</div>
)}
{needsPassword && (
<div className="form-group">
<label>Tuta Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your Tuta password"
onKeyDown={(e) =>
e.key === "Enter" &&
password &&
(!needsEmail || email.trim()) &&
handleStart()
}
/>
</div>
<>
<div className="form-group">
<label>Tuta Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your Tuta password"
/>
</div>
<div className="form-group">
<label>Two-factor code</label>
<input
type="text"
inputMode="numeric"
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ""))}
placeholder="6-digit code, only if 2FA is enabled"
onKeyDown={(e) =>
e.key === "Enter" &&
password &&
(!needsEmail || email.trim()) &&
(!needsTotp || totp.trim().length >= 6) &&
handleStart()
}
/>
{needsTotp && (
<small className="field-hint">
Your account has 2FA. Enter the current code to sign in.
</small>
)}
</div>
</>
)}
<button
className="primary start-btn"
onClick={handleStart}
disabled={
loading ||
(needsEmail && !email.trim()) ||
(needsPassword && !password) ||
(needsEmail && !email.trim())
(needsTotp && totp.trim().length < 6)
}
>
{loading ? "Connecting…" : "Start Bridge"}
+29 -12
View File
@@ -34,6 +34,7 @@ export function useBridge() {
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [needsTotp, setNeedsTotp] = useState(false);
// Backup state lives here (not in BackupPanel) so it survives tab
// switches — the panel is conditionally rendered and would otherwise
@@ -104,18 +105,33 @@ export function useBridge() {
setConfig(cfg);
}, []);
const startBridge = useCallback(async (password?: string, email?: string) => {
setLoading(true);
try {
await invoke("start_bridge", { password: password || null, email: email || null });
refresh();
invoke<string | null>("get_bridge_password").then(setBridgePassword);
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const startBridge = useCallback(
async (password?: string, email?: string, totp?: string) => {
setLoading(true);
setNeedsTotp(false);
try {
await invoke("start_bridge", {
password: password || null,
email: email || null,
totp: totp || null,
});
refresh();
invoke<string | null>("get_bridge_password").then(setBridgePassword);
} catch (e) {
const msg = String(e);
// Not a real error: the account has 2FA and we need the code. Surface a
// TOTP prompt instead of a scary error banner.
if (/2fa|totp/i.test(msg)) {
setNeedsTotp(true);
} else {
setStatus({ Error: msg });
}
} finally {
setLoading(false);
}
},
[refresh],
);
const stopBridge = useCallback(async () => {
setLoading(true);
@@ -185,6 +201,7 @@ export function useBridge() {
bridgePassword,
logs,
loading,
needsTotp,
saveConfig,
startBridge,
stopBridge,