mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
gui: interactive 2FA onboarding and live stats tick
Onboarding now works on a fresh install with no saved session. The login does a single initiate_session like the CLI: the two factor callback fires only when the account actually needs a code, emits bridge://need-totp so the dashboard reveals the code field, and blocks until submit_totp delivers it. One auth either way, so it no longer trips Tuta's rate limit the way the old two step flow did. First run also gets an email field (the start command bootstraps a config from the address entered on the dashboard instead of erroring out). Fixes the dashboard showing zero mails and frozen uptime: stats were purely event driven, so once the store went quiet after the initial sync no further snapshot was pushed and uptime stopped climbing. stream_stats now also emits on a one second tick, which advances uptime and recovers any pulse the UI missed while the start lock was held through the 2FA wait.
This commit is contained in:
@@ -40,11 +40,17 @@ pub async fn has_saved_session() -> Result<bool, String> {
|
||||
Ok(tuta::has_saved_session(&cfg.email))
|
||||
}
|
||||
|
||||
/// Holds the sender side of the channel the 2FA callback blocks on. `submit_totp`
|
||||
/// pushes the code here while a login is waiting for it. Kept separate from
|
||||
/// `BridgeState` so submitting the code never contends with the start lock.
|
||||
pub struct TotpState(pub std::sync::Mutex<Option<std::sync::mpsc::Sender<u32>>>);
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bridge(
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
totp: Option<String>,
|
||||
app: AppHandle,
|
||||
totp_state: State<'_, TotpState>,
|
||||
state: State<'_, BridgeState>,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = match config::load_config() {
|
||||
@@ -68,14 +74,48 @@ 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))));
|
||||
// Interactive 2FA, like the CLI: a single login. The callback fires only if
|
||||
// the account actually needs a code; it tells the UI to show the field
|
||||
// (`bridge://need-totp`) and blocks until `submit_totp` delivers the code.
|
||||
// One `initiate_session` either way, so it never trips Tuta's auth rate limit.
|
||||
let (tx, rx) = std::sync::mpsc::channel::<u32>();
|
||||
*totp_state.0.lock().unwrap() = Some(tx);
|
||||
let rx = std::sync::Mutex::new(rx);
|
||||
let app_for_cb = app.clone();
|
||||
let totp_cb = tuta::TwoFactorCallback::Totp(Box::new(move || {
|
||||
let _ = app_for_cb.emit("bridge://need-totp", ());
|
||||
rx.lock()
|
||||
.unwrap()
|
||||
.recv_timeout(std::time::Duration::from_secs(120))
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from(
|
||||
"Two-factor code was not entered in time",
|
||||
)
|
||||
})
|
||||
}));
|
||||
|
||||
let mut handle = state.lock().await;
|
||||
handle.start(cfg, password, totp_cb).await
|
||||
let result = {
|
||||
let mut handle = state.lock().await;
|
||||
handle.start(cfg, password, Some(totp_cb)).await
|
||||
};
|
||||
*totp_state.0.lock().unwrap() = None;
|
||||
result
|
||||
}
|
||||
|
||||
/// Deliver the 2FA code to a login currently waiting on it (see `start_bridge`).
|
||||
#[tauri::command]
|
||||
pub async fn submit_totp(code: String, totp_state: State<'_, TotpState>) -> Result<(), String> {
|
||||
let parsed: u32 = code
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "Two-factor code must be digits".to_string())?;
|
||||
let tx = totp_state.0.lock().unwrap().clone();
|
||||
match tx {
|
||||
Some(tx) => tx
|
||||
.send(parsed)
|
||||
.map_err(|_| "No sign-in is waiting for a code".to_string()),
|
||||
None => Err("No sign-in is waiting for a code".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+17
-5
@@ -24,11 +24,13 @@ fn main() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(shared as BridgeState)
|
||||
.manage(commands::TotpState(std::sync::Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_config,
|
||||
commands::save_config,
|
||||
commands::has_saved_session,
|
||||
commands::start_bridge,
|
||||
commands::submit_totp,
|
||||
commands::stop_bridge,
|
||||
commands::get_status,
|
||||
commands::get_stats,
|
||||
@@ -104,12 +106,22 @@ async fn stream_stats(
|
||||
}
|
||||
|
||||
emit_snapshot(&app, &state).await;
|
||||
|
||||
// A 1s tick alongside the dirty pulses. Pulses give instant updates on
|
||||
// state changes (ws transitions, new mail); the tick keeps the time-based
|
||||
// uptime climbing and self-heals any pulse the UI missed during startup
|
||||
// (e.g. while the lock was held through an interactive 2FA wait).
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
emit_snapshot(&app, &state).await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
tokio::select! {
|
||||
r = rx.recv() => match r {
|
||||
Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
emit_snapshot(&app, &state).await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
},
|
||||
_ = tick.tick() => emit_snapshot(&app, &state).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ function App() {
|
||||
logs={bridge.logs}
|
||||
needsTotp={bridge.needsTotp}
|
||||
onStart={bridge.startBridge}
|
||||
onSubmitTotp={bridge.submitTotp}
|
||||
onStop={bridge.stopBridge}
|
||||
onClearLogs={bridge.clearLogs}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,8 @@ interface Props {
|
||||
loading: boolean;
|
||||
logs: string[];
|
||||
needsTotp: boolean;
|
||||
onStart: (password?: string, email?: string, totp?: string) => Promise<void>;
|
||||
onStart: (password?: string, email?: string) => Promise<void>;
|
||||
onSubmitTotp: (code: string) => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onClearLogs: () => void;
|
||||
}
|
||||
@@ -32,6 +33,7 @@ export function Dashboard({
|
||||
logs,
|
||||
needsTotp,
|
||||
onStart,
|
||||
onSubmitTotp,
|
||||
onStop,
|
||||
onClearLogs,
|
||||
}: Props) {
|
||||
@@ -75,17 +77,18 @@ export function Dashboard({
|
||||
: "Bridge stopped";
|
||||
|
||||
// Subtitle only carries information the rest of the screen doesn't already
|
||||
// show: nothing when everything is healthy.
|
||||
const subtitle = isRunning
|
||||
? wsConnected
|
||||
? ""
|
||||
: "Realtime reconnecting…"
|
||||
: isStarting
|
||||
? "Signing in and syncing your mailbox"
|
||||
: errored
|
||||
? "See the activity log below"
|
||||
: needsTotp
|
||||
? "Enter your two-factor code to finish signing in"
|
||||
// show: nothing when everything is healthy. The 2FA prompt takes priority
|
||||
// since the login is paused waiting on it.
|
||||
const subtitle = needsTotp
|
||||
? "Enter your two-factor code to finish signing in"
|
||||
: isRunning
|
||||
? wsConnected
|
||||
? ""
|
||||
: "Realtime reconnecting…"
|
||||
: isStarting
|
||||
? "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";
|
||||
@@ -94,12 +97,14 @@ export function Dashboard({
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
const handleVerifyTotp = async () => {
|
||||
await onSubmitTotp(totp.trim());
|
||||
setTotp("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className={`status-bar ${accent}`}>
|
||||
@@ -117,22 +122,49 @@ export function Dashboard({
|
||||
|
||||
{errored && <p className="error-text">{status.Error}</p>}
|
||||
|
||||
{isStopped && (
|
||||
{needsTotp ? (
|
||||
<div className="start-section">
|
||||
{needsEmail && (
|
||||
<div className="form-group">
|
||||
<label>Tuta Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@tuta.com"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{needsPassword && (
|
||||
<>
|
||||
<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 from your authenticator"
|
||||
autoFocus
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && totp.trim().length >= 6 && handleVerifyTotp()
|
||||
}
|
||||
/>
|
||||
<small className="field-hint">
|
||||
Your account has 2FA. Enter the current code to finish signing in.
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="primary start-btn"
|
||||
onClick={handleVerifyTotp}
|
||||
disabled={totp.trim().length < 6}
|
||||
>
|
||||
Verify & sign in
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
isStopped && (
|
||||
<div className="start-section">
|
||||
{needsEmail && (
|
||||
<div className="form-group">
|
||||
<label>Tuta Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@tuta.com"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{needsPassword && (
|
||||
<div className="form-group">
|
||||
<label>Tuta Password</label>
|
||||
<input
|
||||
@@ -140,45 +172,28 @@ export function Dashboard({
|
||||
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) ||
|
||||
(needsTotp && totp.trim().length < 6)
|
||||
}
|
||||
>
|
||||
{loading ? "Connecting…" : "Start Bridge"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="primary start-btn"
|
||||
onClick={handleStart}
|
||||
disabled={
|
||||
loading ||
|
||||
(needsEmail && !email.trim()) ||
|
||||
(needsPassword && !password)
|
||||
}
|
||||
>
|
||||
{loading ? "Connecting…" : "Start Bridge"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="stats-grid">
|
||||
|
||||
+19
-10
@@ -60,9 +60,12 @@ export function useBridge() {
|
||||
// anything changes (mail count, ws state, start/stop). No setInterval.
|
||||
const unlistenStats = listen<BridgeStats>("bridge://stats", (e) => setStats(e.payload));
|
||||
const unlistenStatus = listen<BridgeStatus>("bridge://status", (e) => setStatus(e.payload));
|
||||
// The login (still in progress) needs a 2FA code: show the field.
|
||||
const unlistenTotp = listen("bridge://need-totp", () => setNeedsTotp(true));
|
||||
return () => {
|
||||
unlistenStats.then((fn) => fn());
|
||||
unlistenStatus.then((fn) => fn());
|
||||
unlistenTotp.then((fn) => fn());
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
@@ -105,27 +108,23 @@ export function useBridge() {
|
||||
setConfig(cfg);
|
||||
}, []);
|
||||
|
||||
// One login. If the account has 2FA, the backend emits `bridge://need-totp`
|
||||
// mid-login and waits for `submitTotp`; this promise stays pending until then.
|
||||
const startBridge = useCallback(
|
||||
async (password?: string, email?: string, totp?: string) => {
|
||||
async (password?: string, email?: string) => {
|
||||
setLoading(true);
|
||||
setNeedsTotp(false);
|
||||
try {
|
||||
await invoke("start_bridge", {
|
||||
password: password || null,
|
||||
email: email || null,
|
||||
totp: totp || null,
|
||||
});
|
||||
setNeedsTotp(false);
|
||||
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 });
|
||||
}
|
||||
setNeedsTotp(false);
|
||||
setStatus({ Error: String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -133,6 +132,15 @@ export function useBridge() {
|
||||
[refresh],
|
||||
);
|
||||
|
||||
// Deliver the 2FA code to the login that's currently waiting for it.
|
||||
const submitTotp = useCallback(async (code: string) => {
|
||||
try {
|
||||
await invoke("submit_totp", { code });
|
||||
} catch (e) {
|
||||
setStatus({ Error: String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopBridge = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -204,6 +212,7 @@ export function useBridge() {
|
||||
needsTotp,
|
||||
saveConfig,
|
||||
startBridge,
|
||||
submitTotp,
|
||||
stopBridge,
|
||||
restartBridge,
|
||||
clearLogs,
|
||||
|
||||
Reference in New Issue
Block a user