From 836ca6f3452ac0b73811a4b8226ab5b6bddb361f Mon Sep 17 00:00:00 2001 From: Anthony Date: Fri, 12 Jun 2026 21:17:29 +0200 Subject: [PATCH] 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. --- src-tauri/src/commands.rs | 9 ++++- ui/src/App.tsx | 1 + ui/src/components/Dashboard.tsx | 68 +++++++++++++++++++++++---------- ui/src/hooks/useBridge.ts | 41 ++++++++++++++------ 4 files changed, 85 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c66eb6d..b9e00a0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -44,6 +44,7 @@ pub async fn has_saved_session() -> Result { pub async fn start_bridge( email: Option, password: Option, + totp: Option, 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::().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] diff --git a/ui/src/App.tsx b/ui/src/App.tsx index eb80760..8674dea 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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} diff --git a/ui/src/components/Dashboard.tsx b/ui/src/components/Dashboard.tsx index b1870de..e6d77f7 100644 --- a/ui/src/components/Dashboard.tsx +++ b/ui/src/components/Dashboard.tsx @@ -9,7 +9,8 @@ interface Props { hasSavedSession: boolean; loading: boolean; logs: string[]; - onStart: (password?: string, email?: string) => Promise; + needsTotp: boolean; + onStart: (password?: string, email?: string, totp?: string) => Promise; onStop: () => Promise; 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(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({ )} {needsPassword && ( -
- - setPassword(e.target.value)} - placeholder="Enter your Tuta password" - onKeyDown={(e) => - e.key === "Enter" && - password && - (!needsEmail || email.trim()) && - handleStart() - } - /> -
+ <> +
+ + setPassword(e.target.value)} + placeholder="Enter your Tuta password" + /> +
+
+ + 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 && ( + + Your account has 2FA. Enter the current code to sign in. + + )} +
+ )}