Gmail (app-password) onboarding: auth_strategy routing + credential-helper panel
Implements SPEC v1.5 §6.2.1–6.2.3 so Gmail (and Yahoo/iCloud/Outlook-personal) onboard via a provider app password on the existing plain-password sync path — no OAuth, no new sync code. Gmail was oauth2-only with OAuth unimplemented, so it was previously un-addable. Backend: - presets.yaml: each preset declares an ordered `auth_strategy`; Gmail becomes `auth: password` + `[app_password, oauth2]`. App-password providers ship a data-driven `help:` block (deep_link, app_name_hint, i18n step keys, docs_url). - discovery/types: CandidateConfig carries `auth_strategy` + `HelpBlock`; presets load them and autoconfig candidates derive a default from the detected auth. - schemas/api: CandidateOut exposes auth_strategy + help; DiscoverResponse adds `google_oauth_enabled`. - config: GOOGLE_OAUTH_ENABLED flag (default false) gates the OAuth fallback. Frontend: - ProviderCredentialPanel (wizard step 1.5): deep-link popup (user gesture), rendered steps, single paste field that strips whitespace, instant verify (auto POST /accounts/test at 16 chars → Connected ✓ / mapped error), and the auth_failed recovery branch with a gated "Sign in with Google instead" button. - AccountWizard routes app-password providers to the panel, then a details step. - i18n keys (en) for the panel + provider steps. Honors the §6.2.2 prohibitions: no page automation, no real-password capture, no scraping — deep-link + guide + paste + test only. Adds the v1.5 spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -4,17 +4,21 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { ServerConfig, SyncWindow } from "../../lib/types";
|
||||
import type { CandidateConfig, ServerConfig, SyncWindow } from "../../lib/types";
|
||||
import { ProviderCredentialPanel } from "./ProviderCredentialPanel";
|
||||
|
||||
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
|
||||
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
|
||||
|
||||
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
|
||||
type Step = 1 | 15 | 2 | 3;
|
||||
|
||||
export function AccountWizard() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [email, setEmail] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -22,6 +26,9 @@ export function AccountWizard() {
|
||||
const [imap, setImap] = useState<ServerConfig>(EMPTY);
|
||||
const [smtp, setSmtp] = useState<ServerConfig>({ ...EMPTY, port: 465 });
|
||||
const [source, setSource] = useState<string>("");
|
||||
const [candidate, setCandidate] = useState<CandidateConfig | null>(null);
|
||||
const [googleOauthEnabled, setGoogleOauthEnabled] = useState(false);
|
||||
const [usedPanel, setUsedPanel] = useState(false);
|
||||
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tested, setTested] = useState(false);
|
||||
@@ -32,14 +39,22 @@ export function AccountWizard() {
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.discover(email);
|
||||
const cand = res.candidates[0];
|
||||
setGoogleOauthEnabled(res.google_oauth_enabled);
|
||||
const cand = res.candidates[0] ?? null;
|
||||
setCandidate(cand);
|
||||
if (cand) {
|
||||
setImap({ ...cand.imap });
|
||||
setSmtp({ ...cand.smtp });
|
||||
setSource(cand.source);
|
||||
// Route app-password providers to the credential-helper panel (§6.2.2).
|
||||
if (cand.help && cand.auth_strategy[0] === "app_password") {
|
||||
setStep(15);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setSource("manual");
|
||||
}
|
||||
setUsedPanel(false);
|
||||
setStep(2);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
@@ -116,6 +131,52 @@ export function AccountWizard() {
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
// Shared "account details + confirm" block (color, sync window, Add button).
|
||||
const detailsBlock = (
|
||||
<>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={t("wizard.displayName")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-app-muted">Color</span>
|
||||
{PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-offset-1" : ""}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="rounded-lg border border-app-border p-3">
|
||||
<legend className="px-1 text-xs text-app-muted">{t("wizard.syncWindow")}</legend>
|
||||
<div className="space-y-1">
|
||||
{(["all", "3months", "new"] as const).map((w) => (
|
||||
<label key={w} className="flex cursor-pointer items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="sync-window"
|
||||
className="mt-1"
|
||||
checked={syncWindow === w}
|
||||
onChange={() => setSyncWindow(w)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t(`wizard.syncWindow.${w}`)}</span>
|
||||
<span className="block text-xs text-app-faint">
|
||||
{t(`wizard.syncWindow.${w}.hint`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-lg px-4">
|
||||
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
|
||||
@@ -146,87 +207,104 @@ export function AccountWizard() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 15 && candidate?.help && (
|
||||
<div className="space-y-4">
|
||||
<ProviderCredentialPanel
|
||||
help={candidate.help}
|
||||
imap={imap}
|
||||
smtp={smtp}
|
||||
email={email}
|
||||
candidate={candidate}
|
||||
googleOauthEnabled={googleOauthEnabled}
|
||||
onVerified={(pw) => {
|
||||
setPassword(pw);
|
||||
setTested(true);
|
||||
setUsedPanel(true);
|
||||
setStep(2);
|
||||
}}
|
||||
onManual={() => {
|
||||
setUsedPanel(false);
|
||||
setStep(2);
|
||||
}}
|
||||
onOauth={() => setError("OAuth sign-in is not configured on this server.")}
|
||||
/>
|
||||
<button className="text-sm text-app-muted" onClick={() => setStep(1)}>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{source && source !== "manual" && (
|
||||
<p className="text-sm text-emerald-500">{t("wizard.foundVia", { source })}</p>
|
||||
)}
|
||||
{serverFields(imap, setImap, t("wizard.imapServer"))}
|
||||
{serverFields(smtp, setSmtp, t("wizard.smtpServer"))}
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={t("wizard.displayName")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
type="password"
|
||||
placeholder={t("wizard.accountPassword")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setTested(false);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-app-muted">Color</span>
|
||||
{PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-offset-1" : ""}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
|
||||
{/* App-password providers arrive here already verified via the panel. */}
|
||||
{usedPanel && tested ? (
|
||||
<>
|
||||
<p className="text-sm text-emerald-500">{t("wizard.appPassword.connected")}</p>
|
||||
{detailsBlock}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={create}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("wizard.creating") : t("wizard.create")}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 text-sm text-app-muted"
|
||||
onClick={() => setStep(15)}
|
||||
>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{serverFields(imap, setImap, t("wizard.imapServer"))}
|
||||
{serverFields(smtp, setSmtp, t("wizard.smtpServer"))}
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
type="password"
|
||||
placeholder={t("wizard.accountPassword")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setTested(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="rounded-lg border border-app-border p-3">
|
||||
<legend className="px-1 text-xs text-app-muted">{t("wizard.syncWindow")}</legend>
|
||||
<div className="space-y-1">
|
||||
{(["all", "3months", "new"] as const).map((w) => (
|
||||
<label key={w} className="flex cursor-pointer items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="sync-window"
|
||||
className="mt-1"
|
||||
checked={syncWindow === w}
|
||||
onChange={() => setSyncWindow(w)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t(`wizard.syncWindow.${w}`)}</span>
|
||||
<span className="block text-xs text-app-faint">
|
||||
{t(`wizard.syncWindow.${w}.hint`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
||||
<div className="flex gap-2">
|
||||
{!tested ? (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={test}
|
||||
disabled={busy || !password || !imap.host}
|
||||
>
|
||||
{busy ? t("wizard.testing") : t("wizard.test")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={create}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("wizard.creating") : t("wizard.create")}
|
||||
</button>
|
||||
)}
|
||||
<button className="px-3 py-2 text-sm text-app-muted" onClick={() => setStep(1)}>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
|
||||
{detailsBlock}
|
||||
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
||||
<div className="flex gap-2">
|
||||
{!tested ? (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={test}
|
||||
disabled={busy || !password || !imap.host}
|
||||
>
|
||||
{busy ? t("wizard.testing") : t("wizard.test")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={create}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("wizard.creating") : t("wizard.create")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="px-3 py-2 text-sm text-app-muted"
|
||||
onClick={() => setStep(candidate?.help ? 15 : 1)}
|
||||
>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Provider credential-helper panel (SPEC §6.2.2). For providers whose first
|
||||
// auth_strategy is `app_password`, the wizard shows this instead of the generic
|
||||
// password box: a deep-link to the provider's app-password page, data-driven
|
||||
// steps, a single paste field that strips whitespace, and an instant verify that
|
||||
// fires POST /accounts/test as soon as a complete-looking value is pasted.
|
||||
//
|
||||
// The panel's ceiling is deep-link + guide + paste + test — it never drives the
|
||||
// provider's pages, never asks for the real account password, and never reads or
|
||||
// scrapes the generated app password (§6.2.2 hard prohibitions).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { MessageKey } from "../../i18n/en";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CandidateConfig, HelpBlock, ServerConfig } from "../../lib/types";
|
||||
|
||||
const APP_PASSWORD_LEN = 16; // Google/Yahoo/iCloud all issue 16-character passwords.
|
||||
|
||||
interface Props {
|
||||
help: HelpBlock;
|
||||
imap: ServerConfig;
|
||||
smtp: ServerConfig;
|
||||
email: string;
|
||||
candidate: CandidateConfig;
|
||||
googleOauthEnabled: boolean;
|
||||
// Called with the verified app password once POST /accounts/test succeeds.
|
||||
onVerified: (password: string) => void;
|
||||
onManual: () => void;
|
||||
onOauth: () => void;
|
||||
}
|
||||
|
||||
export function ProviderCredentialPanel({
|
||||
help,
|
||||
imap,
|
||||
smtp,
|
||||
email,
|
||||
candidate,
|
||||
googleOauthEnabled,
|
||||
onVerified,
|
||||
onManual,
|
||||
onOauth,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [value, setValue] = useState("");
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [errorCode, setErrorCode] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const lastTried = useRef("");
|
||||
|
||||
const providerLabel = t(`wizard.appPassword.provider.${help.help_id}` as MessageKey);
|
||||
const canOauth =
|
||||
candidate.auth_strategy.includes("oauth2") &&
|
||||
candidate.oauth_provider === "google" &&
|
||||
googleOauthEnabled;
|
||||
|
||||
const verify = async (password: string) => {
|
||||
lastTried.current = password;
|
||||
setVerifying(true);
|
||||
setErrorCode("");
|
||||
setErrorMsg("");
|
||||
setVerified(false);
|
||||
try {
|
||||
await api.testAccount({ imap, smtp, email, password });
|
||||
setVerified(true);
|
||||
onVerified(password);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setErrorCode(e.code);
|
||||
setErrorMsg(e.message);
|
||||
} else {
|
||||
setErrorMsg(String(e));
|
||||
}
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Instant verify: strip whitespace (Google shows four groups of four) and, once
|
||||
// the value looks complete, auto-fire the test without another click.
|
||||
const onChange = (raw: string) => {
|
||||
const cleaned = raw.replace(/\s+/g, "");
|
||||
setValue(cleaned);
|
||||
setVerified(false);
|
||||
setErrorCode("");
|
||||
setErrorMsg("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (value.length === APP_PASSWORD_LEN && value !== lastTried.current && !verifying) {
|
||||
verify(value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const openDeepLink = () => {
|
||||
// Must fire from this click (a direct user gesture) so popup blockers allow it.
|
||||
// Sized features open a popup window rather than a new tab.
|
||||
const w = 720;
|
||||
const h = 680;
|
||||
// Position the popup at the right (3/3) of the screen, pulled 200px back from
|
||||
// the edge so it doesn't touch it; clamped to stay on-screen.
|
||||
const left = Math.max(0, window.screen.width - w - 200);
|
||||
const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2);
|
||||
window.open(
|
||||
help.deep_link,
|
||||
`${help.help_id}-app-passwords`,
|
||||
`popup,noopener,noreferrer,width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("wizard.appPassword.title")}</h2>
|
||||
<p className="mt-1 text-sm text-app-muted">{t("wizard.appPassword.intro")}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDeepLink}
|
||||
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg"
|
||||
>
|
||||
{t("wizard.appPassword.open", { provider: providerLabel })}
|
||||
</button>
|
||||
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-app-muted">
|
||||
{help.steps.map((key) => (
|
||||
<li key={key}>{t(key as MessageKey)}</li>
|
||||
))}
|
||||
</ol>
|
||||
{help.app_name_hint && (
|
||||
<p className="text-xs text-app-faint">
|
||||
{t("wizard.appPassword.appNameHint", { hint: help.app_name_hint })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 font-mono tracking-wider"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t("wizard.appPassword.pastePlaceholder")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-1 h-5 text-sm">
|
||||
{verifying && <span className="text-app-muted">{t("wizard.appPassword.verifying")}</span>}
|
||||
{verified && <span className="text-emerald-500">{t("wizard.appPassword.connected")}</span>}
|
||||
{!verifying && !verified && errorCode && errorCode !== "auth_failed" && (
|
||||
<span className="text-red-500">{errorMsg}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failure recovery — the two real dead-ends (SPEC §6.2.2). */}
|
||||
{!verifying && errorCode === "auth_failed" && (
|
||||
<div className="space-y-2 rounded-lg border border-app-border bg-app-surface p-3">
|
||||
<p className="text-sm font-medium text-red-500">{t("wizard.appPassword.failTitle")}</p>
|
||||
<p className="text-sm text-app-muted">{t("wizard.appPassword.failCauses")}</p>
|
||||
{canOauth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOauth}
|
||||
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg"
|
||||
>
|
||||
{t("wizard.appPassword.oauthInstead")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{help.docs_url && (
|
||||
<a
|
||||
href={help.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-app-accent hover:underline"
|
||||
>
|
||||
{t("wizard.appPassword.docs")}
|
||||
</a>
|
||||
)}
|
||||
<button type="button" onClick={onManual} className="text-app-muted hover:underline">
|
||||
{t("wizard.appPassword.useManual")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user