mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user