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:
@@ -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