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:
Anthony
2026-06-13 15:35:20 +02:00
parent 836ca6f345
commit 99e511cbbf
5 changed files with 161 additions and 84 deletions
+1
View File
@@ -76,6 +76,7 @@ function App() {
logs={bridge.logs}
needsTotp={bridge.needsTotp}
onStart={bridge.startBridge}
onSubmitTotp={bridge.submitTotp}
onStop={bridge.stopBridge}
onClearLogs={bridge.clearLogs}
/>
+76 -61
View File
@@ -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 &amp; 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
View File
@@ -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,