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(
|
pub async fn start_bridge(
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
totp: Option<String>,
|
||||||
state: State<'_, BridgeState>,
|
state: State<'_, BridgeState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cfg = match config::load_config() {
|
let mut cfg = match config::load_config() {
|
||||||
@@ -67,8 +68,14 @@ pub async fn start_bridge(
|
|||||||
config::ensure_bridge_password(&mut cfg)
|
config::ensure_bridge_password(&mut cfg)
|
||||||
.map_err(|e| format!("Bridge password setup failed: {e}"))?;
|
.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;
|
let mut handle = state.lock().await;
|
||||||
handle.start(cfg, password, None).await
|
handle.start(cfg, password, totp_cb).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function App() {
|
|||||||
hasSavedSession={bridge.hasSavedSession}
|
hasSavedSession={bridge.hasSavedSession}
|
||||||
loading={bridge.loading}
|
loading={bridge.loading}
|
||||||
logs={bridge.logs}
|
logs={bridge.logs}
|
||||||
|
needsTotp={bridge.needsTotp}
|
||||||
onStart={bridge.startBridge}
|
onStart={bridge.startBridge}
|
||||||
onStop={bridge.stopBridge}
|
onStop={bridge.stopBridge}
|
||||||
onClearLogs={bridge.clearLogs}
|
onClearLogs={bridge.clearLogs}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ interface Props {
|
|||||||
hasSavedSession: boolean;
|
hasSavedSession: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
onStart: (password?: string, email?: string) => Promise<void>;
|
needsTotp: boolean;
|
||||||
|
onStart: (password?: string, email?: string, totp?: string) => Promise<void>;
|
||||||
onStop: () => Promise<void>;
|
onStop: () => Promise<void>;
|
||||||
onClearLogs: () => void;
|
onClearLogs: () => void;
|
||||||
}
|
}
|
||||||
@@ -29,12 +30,14 @@ export function Dashboard({
|
|||||||
hasSavedSession,
|
hasSavedSession,
|
||||||
loading,
|
loading,
|
||||||
logs,
|
logs,
|
||||||
|
needsTotp,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onClearLogs,
|
onClearLogs,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [totp, setTotp] = useState("");
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,16 +84,20 @@ export function Dashboard({
|
|||||||
? "Signing in and syncing your mailbox"
|
? "Signing in and syncing your mailbox"
|
||||||
: errored
|
: errored
|
||||||
? "See the activity log below"
|
? "See the activity log below"
|
||||||
: needsEmail
|
: needsTotp
|
||||||
? "Sign in with your Tuta account to get started"
|
? "Enter your two-factor code to finish signing in"
|
||||||
: "Start the bridge to connect your mail client";
|
: needsEmail
|
||||||
|
? "Sign in with your Tuta account to get started"
|
||||||
|
: "Start the bridge to connect your mail client";
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
await onStart(
|
await onStart(
|
||||||
needsPassword ? password : undefined,
|
needsPassword ? password : undefined,
|
||||||
needsEmail ? email.trim() : 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 (
|
return (
|
||||||
@@ -125,29 +132,48 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{needsPassword && (
|
{needsPassword && (
|
||||||
<div className="form-group">
|
<>
|
||||||
<label>Tuta Password</label>
|
<div className="form-group">
|
||||||
<input
|
<label>Tuta Password</label>
|
||||||
type="password"
|
<input
|
||||||
value={password}
|
type="password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
placeholder="Enter your Tuta password"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={(e) =>
|
placeholder="Enter your Tuta password"
|
||||||
e.key === "Enter" &&
|
/>
|
||||||
password &&
|
</div>
|
||||||
(!needsEmail || email.trim()) &&
|
<div className="form-group">
|
||||||
handleStart()
|
<label>Two-factor code</label>
|
||||||
}
|
<input
|
||||||
/>
|
type="text"
|
||||||
</div>
|
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
|
<button
|
||||||
className="primary start-btn"
|
className="primary start-btn"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={
|
disabled={
|
||||||
loading ||
|
loading ||
|
||||||
|
(needsEmail && !email.trim()) ||
|
||||||
(needsPassword && !password) ||
|
(needsPassword && !password) ||
|
||||||
(needsEmail && !email.trim())
|
(needsTotp && totp.trim().length < 6)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? "Connecting…" : "Start Bridge"}
|
{loading ? "Connecting…" : "Start Bridge"}
|
||||||
|
|||||||
+29
-12
@@ -34,6 +34,7 @@ export function useBridge() {
|
|||||||
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
|
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [needsTotp, setNeedsTotp] = useState(false);
|
||||||
|
|
||||||
// Backup state lives here (not in BackupPanel) so it survives tab
|
// Backup state lives here (not in BackupPanel) so it survives tab
|
||||||
// switches — the panel is conditionally rendered and would otherwise
|
// switches — the panel is conditionally rendered and would otherwise
|
||||||
@@ -104,18 +105,33 @@ export function useBridge() {
|
|||||||
setConfig(cfg);
|
setConfig(cfg);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startBridge = useCallback(async (password?: string, email?: string) => {
|
const startBridge = useCallback(
|
||||||
setLoading(true);
|
async (password?: string, email?: string, totp?: string) => {
|
||||||
try {
|
setLoading(true);
|
||||||
await invoke("start_bridge", { password: password || null, email: email || null });
|
setNeedsTotp(false);
|
||||||
refresh();
|
try {
|
||||||
invoke<string | null>("get_bridge_password").then(setBridgePassword);
|
await invoke("start_bridge", {
|
||||||
} catch (e) {
|
password: password || null,
|
||||||
setStatus({ Error: String(e) });
|
email: email || null,
|
||||||
} finally {
|
totp: totp || null,
|
||||||
setLoading(false);
|
});
|
||||||
}
|
refresh();
|
||||||
}, [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 () => {
|
const stopBridge = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -185,6 +201,7 @@ export function useBridge() {
|
|||||||
bridgePassword,
|
bridgePassword,
|
||||||
logs,
|
logs,
|
||||||
loading,
|
loading,
|
||||||
|
needsTotp,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
startBridge,
|
startBridge,
|
||||||
stopBridge,
|
stopBridge,
|
||||||
|
|||||||
Reference in New Issue
Block a user