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:
2026-08-04 19:59:52 +02:00
co-authored by Claude Opus 4.8
parent 0f686bbd5c
commit e5a87b37b1
11 changed files with 1380 additions and 84 deletions
+37
View File
@@ -250,6 +250,43 @@ export const en = {
"wizard.syncWindow.new.hint":
"Nothing from before now — download older mail anytime from account settings.",
// Provider credential-helper panel (app passwords, SPEC §6.2.2).
"wizard.appPassword.title": "Create an app password",
"wizard.appPassword.intro":
"This provider needs a one-time app password instead of your normal password. Open the page below, generate one, and paste it here.",
"wizard.appPassword.open": "Open {provider} app passwords",
"wizard.appPassword.appNameHint": "Suggested name: {hint}",
"wizard.appPassword.pastePlaceholder": "Paste the app password",
"wizard.appPassword.verifying": "Verifying…",
"wizard.appPassword.connected": "Connected ✓",
"wizard.appPassword.docs": "Provider help",
"wizard.appPassword.useManual": "Enter server settings manually",
"wizard.appPassword.provider.gmail": "Google",
"wizard.appPassword.provider.yahoo": "Yahoo",
"wizard.appPassword.provider.icloud": "iCloud",
"wizard.appPassword.provider.outlook": "Microsoft",
// auth_failed recovery branch (the two real dead-ends).
"wizard.appPassword.failTitle": "That password didn't work",
"wizard.appPassword.failCauses":
"Common causes: 2-Step Verification isn't fully enabled yet; a brand-new account for which the provider withholds app passwords until it ages; or a work/school account where an admin disabled the feature.",
"wizard.appPassword.oauthInstead": "Sign in with Google instead",
// Gmail steps.
"wizard.help.gmail.step1": "Turn on 2-Step Verification for your Google account if you haven't.",
"wizard.help.gmail.step2": "On the App passwords page, name the password (e.g. \"ultimo.email\") and create it.",
"wizard.help.gmail.step3": "Copy the 16-character password and paste it below.",
// Outlook / Microsoft personal steps.
"wizard.help.outlook.step1": "Turn on two-step verification in your Microsoft account security settings.",
"wizard.help.outlook.step2": "Under Advanced security options, create a new app password.",
"wizard.help.outlook.step3": "Copy the generated password and paste it below.",
// Yahoo steps.
"wizard.help.yahoo.step1": "Open Account Security and turn on two-step verification.",
"wizard.help.yahoo.step2": "Choose \"Generate app password\" and name it.",
"wizard.help.yahoo.step3": "Copy the password and paste it below.",
// iCloud steps.
"wizard.help.icloud.step1": "Turn on two-factor authentication for your Apple Account.",
"wizard.help.icloud.step2": "Under Sign-In and Security, generate an app-specific password.",
"wizard.help.icloud.step3": "Copy the password and paste it below.",
"settings.title": "Settings",
"settings.profile": "Profile",
"settings.security": "Security",
+11
View File
@@ -110,11 +110,21 @@ export interface ServerConfig {
auth: string; // password | oauth2
}
export interface HelpBlock {
help_id: string; // gmail | yahoo | icloud | outlook
deep_link: string;
app_name_hint: string;
steps: string[]; // i18n message keys
docs_url: string;
}
export interface CandidateConfig {
imap: ServerConfig;
smtp: ServerConfig;
source: string;
oauth_provider: string | null;
auth_strategy: string[]; // app_password | oauth2 | password (first = default)
help: HelpBlock | null;
}
export interface DiscoveryStep {
@@ -126,6 +136,7 @@ export interface DiscoveryStep {
export interface DiscoverResponse {
candidates: CandidateConfig[];
steps: DiscoveryStep[];
google_oauth_enabled: boolean;
}
export interface AccountDetail extends AccountOut {
+156 -78
View File
@@ -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>
);
}