Backfill start-window indicator, account-delete spinner, app-password Help popup

- Backfill: show the sidebar spinner + "Preparing…" banner during the brief window
  after account creation when state=backfilling but total is not yet counted
  (total===0), so a freshly-added account shows progress immediately instead of
  looking done until a refresh. The stuck-at-100% guard is preserved.
- Settings: a centered spinner overlay ("Removing account…") covers the screen from
  confirm until the (slow) server cascade + accounts refetch complete, so the row
  no longer appears frozen before it disappears.
- Add-account wizard: the manual password hint now includes Outlook and gains a
  "Help" link opening a popup with per-provider direct links to enable 2-step
  verification and generate the app password (Gmail, Outlook, Yahoo, iCloud).

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-07 07:59:26 +02:00
co-authored by Claude Opus 4.8
parent 12a67825cc
commit 719eeef378
5 changed files with 144 additions and 18 deletions
+1 -2
View File
@@ -583,8 +583,7 @@ export function MailLayout() {
color={a.color}
syncing={
a.state === "backfilling" &&
a.backfill_total > 0 &&
a.backfill_done < a.backfill_total
(a.backfill_total === 0 || a.backfill_done < a.backfill_total)
}
total={a.backfill_total}
done={a.backfill_done}
+23 -2
View File
@@ -251,9 +251,15 @@ function AccountsSection() {
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
// Which account is being deleted — drives the blocking spinner overlay until the
// (slow) server cascade finishes and the refetched list no longer includes it.
const [deletingId, setDeletingId] = useState<string | null>(null);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setDeletingId(null);
},
});
const downloadAll = useMutation({
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
@@ -325,7 +331,10 @@ function AccountsSection() {
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
if (confirm(t("settings.confirmRemoveAccount"))) {
setDeletingId(a.id);
remove.mutate(a.id);
}
}}
>
{t("common.remove")}
@@ -336,6 +345,18 @@ function AccountsSection() {
</li>
))}
</ul>
{deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl">
<span
className="h-8 w-8 animate-spin rounded-full border-2 border-app-border border-t-app-accent"
role="status"
aria-label={t("settings.removingAccount")}
/>
<span className="text-sm text-app-muted">{t("settings.removingAccount")}</span>
</div>
</div>
)}
</section>
);
}
+86 -1
View File
@@ -10,6 +10,31 @@ import { ProviderCredentialPanel } from "./ProviderCredentialPanel";
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
// Providers that need an app password, with direct links to enable 2-step
// verification and to generate the app password (shown in the Help popup).
const APP_PASSWORD_HELP: { name: string; twoStep: string; appPassword: string }[] = [
{
name: "Gmail",
twoStep: "https://myaccount.google.com/signinoptions/two-step-verification",
appPassword: "https://myaccount.google.com/apppasswords",
},
{
name: "Outlook",
twoStep: "https://account.microsoft.com/security",
appPassword: "https://account.live.com/proofs/AppPassword",
},
{
name: "Yahoo",
twoStep: "https://login.yahoo.com/account/security",
appPassword: "https://login.yahoo.com/account/security/app-passwords",
},
{
name: "iCloud",
twoStep: "https://account.apple.com/account/manage",
appPassword: "https://account.apple.com/account/manage",
},
];
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
type Step = 1 | 15 | 2 | 3;
@@ -38,6 +63,7 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
const [usedPanel, setUsedPanel] = useState(false);
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
const [syncSpam, setSyncSpam] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [tested, setTested] = useState(false);
const [error, setError] = useState("");
@@ -303,7 +329,16 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
setTested(false);
}}
/>
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
<p className="text-xs text-app-faint">
{t("wizard.appPasswordHint")}{" "}
<button
type="button"
className="text-app-accent hover:underline"
onClick={() => setHelpOpen(true)}
>
{t("wizard.help")}
</button>
</p>
{detailsBlock}
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
<div className="flex gap-2">
@@ -356,6 +391,56 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
)}
{error && <p className="mt-3 text-sm text-red-500">{error}</p>}
{helpOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setHelpOpen(false)}
>
<div
className="w-full max-w-4xl rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold">{t("wizard.appPasswordHelp.title")}</h2>
<button
type="button"
className="text-app-muted hover:text-app-fg"
onClick={() => setHelpOpen(false)}
aria-label={t("common.close")}
>
✕
</button>
</div>
<p className="mb-3 text-sm text-app-muted">{t("wizard.appPasswordHelp.intro")}</p>
<ul className="space-y-3 text-sm text-app-fg">
{APP_PASSWORD_HELP.map((p) => (
<li key={p.name}>
<span className="font-bold">{p.name}:</span> {t("wizard.appPasswordHelp.prefix")}{" "}
<a
href={p.twoStep}
target="_blank"
rel="noopener noreferrer"
className="text-app-accent hover:underline"
>
{t("wizard.appPasswordHelp.twoStep")}
</a>{" "}
{t("wizard.appPasswordHelp.middle")}{" "}
<a
href={p.appPassword}
target="_blank"
rel="noopener noreferrer"
className="text-app-accent hover:underline"
>
{t("wizard.appPasswordHelp.generate")}
</a>
.
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}