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:
@@ -13,9 +13,12 @@ export function BackfillBanner() {
|
|||||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
|
|
||||||
const importing = (accounts.data ?? []).filter(
|
const importing = (accounts.data ?? []).filter(
|
||||||
// Also require done < total so a completed account whose state event was missed
|
// Show while backfilling — including the brief start window before the worker has
|
||||||
// (e.g. a dropped WS) never leaves a stuck "X of X" banner.
|
// counted the total (total === 0). Hide only once done, so a completed account
|
||||||
(a) => a.state === "backfilling" && a.backfill_total > 0 && a.backfill_done < a.backfill_total,
|
// whose state event was missed (e.g. a dropped WS) never leaves a stuck banner.
|
||||||
|
(a) =>
|
||||||
|
a.state === "backfilling" &&
|
||||||
|
(a.backfill_total === 0 || a.backfill_done < a.backfill_total),
|
||||||
);
|
);
|
||||||
const active = importing.length > 0;
|
const active = importing.length > 0;
|
||||||
|
|
||||||
@@ -40,8 +43,10 @@ export function BackfillBanner() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{importing.map((a) => {
|
{importing.map((a) => {
|
||||||
|
// total === 0: the worker hasn't counted yet — show an indeterminate bar.
|
||||||
|
const preparing = a.backfill_total === 0;
|
||||||
const done = Math.min(a.backfill_done, a.backfill_total);
|
const done = Math.min(a.backfill_done, a.backfill_total);
|
||||||
const pct = Math.round((done / a.backfill_total) * 100);
|
const pct = preparing ? 0 : Math.round((done / a.backfill_total) * 100);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -52,16 +57,22 @@ export function BackfillBanner() {
|
|||||||
<span className="truncate text-xs font-medium text-app-fg">{a.email}</span>
|
<span className="truncate text-xs font-medium text-app-fg">{a.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1.5 text-xs text-app-muted">
|
<div className="mb-1.5 text-xs text-app-muted">
|
||||||
{t("backfill.importing", {
|
{preparing
|
||||||
|
? t("backfill.preparing")
|
||||||
|
: t("backfill.importing", {
|
||||||
done: done.toLocaleString(),
|
done: done.toLocaleString(),
|
||||||
total: a.backfill_total.toLocaleString(),
|
total: a.backfill_total.toLocaleString(),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-app-hover">
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-app-hover">
|
||||||
|
{preparing ? (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-full bg-app-accent/60" />
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-app-accent transition-all duration-500"
|
className="h-full rounded-full bg-app-accent transition-all duration-500"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+11
-1
@@ -236,7 +236,15 @@ export const en = {
|
|||||||
"wizard.port": "Port",
|
"wizard.port": "Port",
|
||||||
"wizard.security": "Security",
|
"wizard.security": "Security",
|
||||||
"wizard.accountPassword": "Account password",
|
"wizard.accountPassword": "Account password",
|
||||||
"wizard.appPasswordHint": "For Gmail/Yahoo/iCloud use an app password.",
|
"wizard.appPasswordHint": "For Gmail, Yahoo, iCloud, or Outlook use an app password.",
|
||||||
|
"wizard.help": "Help",
|
||||||
|
"wizard.appPasswordHelp.title": "Set up an app password",
|
||||||
|
"wizard.appPasswordHelp.intro":
|
||||||
|
"These providers need a one-time app password instead of your normal password:",
|
||||||
|
"wizard.appPasswordHelp.prefix": "Make sure to",
|
||||||
|
"wizard.appPasswordHelp.twoStep": "turn on 2-step verification",
|
||||||
|
"wizard.appPasswordHelp.middle": "if you haven't done it yet, and then",
|
||||||
|
"wizard.appPasswordHelp.generate": "generate the app password",
|
||||||
"wizard.test": "Test & continue",
|
"wizard.test": "Test & continue",
|
||||||
"wizard.testing": "Testing connection…",
|
"wizard.testing": "Testing connection…",
|
||||||
"wizard.testOk": "Connection works.",
|
"wizard.testOk": "Connection works.",
|
||||||
@@ -303,6 +311,7 @@ export const en = {
|
|||||||
"settings.downloadAllMail": "Sync remaining mail",
|
"settings.downloadAllMail": "Sync remaining mail",
|
||||||
"settings.syncedPartially": "Synced partially",
|
"settings.syncedPartially": "Synced partially",
|
||||||
"backfill.importing": "Importing messages… {done} of {total}",
|
"backfill.importing": "Importing messages… {done} of {total}",
|
||||||
|
"backfill.preparing": "Preparing to import messages…",
|
||||||
"settings.appearance": "Appearance",
|
"settings.appearance": "Appearance",
|
||||||
"settings.handle": "Handle",
|
"settings.handle": "Handle",
|
||||||
"settings.recoveryEmail": "Recovery email",
|
"settings.recoveryEmail": "Recovery email",
|
||||||
@@ -376,6 +385,7 @@ export const en = {
|
|||||||
"settings.addAccount": "Add account",
|
"settings.addAccount": "Add account",
|
||||||
"settings.syncState": "State: {state}",
|
"settings.syncState": "State: {state}",
|
||||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||||
|
"settings.removingAccount": "Removing account…",
|
||||||
"settings.editAccount": "Edit account",
|
"settings.editAccount": "Edit account",
|
||||||
"settings.color": "Color",
|
"settings.color": "Color",
|
||||||
"settings.serverSettings": "Change server settings / password",
|
"settings.serverSettings": "Change server settings / password",
|
||||||
|
|||||||
@@ -583,8 +583,7 @@ export function MailLayout() {
|
|||||||
color={a.color}
|
color={a.color}
|
||||||
syncing={
|
syncing={
|
||||||
a.state === "backfilling" &&
|
a.state === "backfilling" &&
|
||||||
a.backfill_total > 0 &&
|
(a.backfill_total === 0 || a.backfill_done < a.backfill_total)
|
||||||
a.backfill_done < a.backfill_total
|
|
||||||
}
|
}
|
||||||
total={a.backfill_total}
|
total={a.backfill_total}
|
||||||
done={a.backfill_done}
|
done={a.backfill_done}
|
||||||
|
|||||||
@@ -251,9 +251,15 @@ function AccountsSection() {
|
|||||||
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
|
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
|
||||||
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({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteAccount(id),
|
mutationFn: (id: string) => api.deleteAccount(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
|
setDeletingId(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const downloadAll = useMutation({
|
const downloadAll = useMutation({
|
||||||
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
|
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
|
||||||
@@ -325,7 +331,10 @@ function AccountsSection() {
|
|||||||
<button
|
<button
|
||||||
className="text-xs text-red-500"
|
className="text-xs text-red-500"
|
||||||
onClick={() => {
|
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")}
|
{t("common.remove")}
|
||||||
@@ -336,6 +345,18 @@ function AccountsSection() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ import { ProviderCredentialPanel } from "./ProviderCredentialPanel";
|
|||||||
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
|
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
|
||||||
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
|
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.
|
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
|
||||||
type Step = 1 | 15 | 2 | 3;
|
type Step = 1 | 15 | 2 | 3;
|
||||||
|
|
||||||
@@ -38,6 +63,7 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
|
|||||||
const [usedPanel, setUsedPanel] = useState(false);
|
const [usedPanel, setUsedPanel] = useState(false);
|
||||||
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
|
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
|
||||||
const [syncSpam, setSyncSpam] = useState(false);
|
const [syncSpam, setSyncSpam] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [tested, setTested] = useState(false);
|
const [tested, setTested] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -303,7 +329,16 @@ export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
|
|||||||
setTested(false);
|
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}
|
{detailsBlock}
|
||||||
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
||||||
<div className="flex gap-2">
|
<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>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user