The deep review of the branch raised 47 findings, 18 of which survived adversarial verification. Grouped by what they actually were: **The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one user over quota was the only one downloading with no ceiling at all. `check_enqueue` could not catch it: it ran while the job was queued and the user was still under the cap. The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision lives, and where it can be tested), and the worker now fails the job outright rather than proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help until space is freed. **Silent failures in the new account panel.** All three actions went through `quiet: true` endpoints, which suppress the global error dialog on the promise that the caller reports inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On the broken-SMTP instance this panel exists for, an admin would read silence as "the mail went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`, shared with the pending-access queue, so the two copies of these mutations cannot drift apart again — which is how half of this was lost in the first place. **A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps back one migration, but a release routinely ships several (0.80.0 shipped four): it drops the newest one's columns and leaves the schema at a revision the older image also cannot resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is now the recommended path, and the alembic route asks the target version for its own head revision instead of counting backwards. Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as a 500; the pull cooldown extended to the sibling route spending the same QuotaAction; restore stops the app before dropping its tables; the PowerShell cleanup can no longer decide the script's verdict on 5.1, nor report the wrong exit code; the documented one-liners hardened to match the scripts; the registry's retention window stated where pinning is advertised; the two banners made mutually exclusive so neither can shadow the other; `hover:bg-hover` (a utility this project never generated) replaced by the `selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to the two callers that discarded it. Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset banner UAT'd on localdev.
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
|
import { api, type Me } from "../lib/api";
|
|
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import Overlay from "./Overlay";
|
|
|
|
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
|
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
|
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
|
|
// always an explicit, informed opt-in here.
|
|
//
|
|
// The visible step is derived from what's already granted, so the wizard resumes correctly
|
|
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
|
|
// Right after read is granted, it auto-imports the user's subscriptions so they land on a
|
|
// populated feed (channels already in the shared catalog appear instantly).
|
|
|
|
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
|
function ConsentHeadsUp() {
|
|
return (
|
|
<div className="flex gap-2.5 rounded-xl border border-warn/30 bg-warn/5 p-3 text-left">
|
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-warn" />
|
|
<p className="text-xs leading-relaxed text-fg/80">
|
|
<Trans
|
|
i18nKey="onboarding.consent"
|
|
components={[
|
|
<span className="font-medium" />,
|
|
<span className="font-medium" />,
|
|
<span className="font-medium" />,
|
|
<a
|
|
href="https://myaccount.google.com/permissions"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent hover:underline"
|
|
/>,
|
|
]}
|
|
/>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dots({ index, total }: { index: number; total: number }) {
|
|
return (
|
|
<div className="flex justify-center gap-1.5">
|
|
{Array.from({ length: total }).map((_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const importTriggered = useRef(false);
|
|
|
|
// Once we have read access, find out whether any subscriptions are imported yet.
|
|
const myStatus = useQuery({
|
|
queryKey: qk.myStatus(),
|
|
queryFn: api.myStatus,
|
|
enabled: me.can_read,
|
|
});
|
|
|
|
const importSubs = useMutation({
|
|
mutationFn: () => api.syncSubscriptions(),
|
|
onSuccess: () => {
|
|
// Feed/channels/status now have content — refresh the app behind the wizard.
|
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
|
},
|
|
// Say something rather than nothing: this route is rate-limited (SEC-14) and can 403 without a
|
|
// read grant, and a swallowed rejection here leaves the wizard looking like it simply did not
|
|
// run. The server's own reason wins over the fallback.
|
|
onError: (e) => notifyActionError(e, t("onboarding.importFailed")),
|
|
});
|
|
|
|
// First time we see read access with zero imported channels, pull the user's
|
|
// subscriptions automatically so they don't have to hunt for a button.
|
|
useEffect(() => {
|
|
if (
|
|
me.can_read &&
|
|
myStatus.data &&
|
|
myStatus.data.channels_total === 0 &&
|
|
!importTriggered.current
|
|
) {
|
|
importTriggered.current = true;
|
|
importSubs.mutate();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [me.can_read, myStatus.data?.channels_total]);
|
|
|
|
// Closing without finishing the essential read step suppresses future auto-popups
|
|
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
|
const close = () => {
|
|
endOnboarding(!me.can_read);
|
|
onClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") close();
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [me.can_read]);
|
|
|
|
// Are we currently importing (or about to)? Show a progress screen instead of the
|
|
// "you're connected" step until the first import settles.
|
|
const importing =
|
|
me.can_read &&
|
|
!importSubs.isError &&
|
|
(importSubs.isPending ||
|
|
(importTriggered.current && !importSubs.isSuccess) ||
|
|
(myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
|
|
|
|
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
|
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
|
|
|
return (
|
|
<Overlay>
|
|
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
|
|
<div
|
|
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
|
onClick={close}
|
|
/>
|
|
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
|
<button
|
|
onClick={close}
|
|
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
|
aria-label={t("onboarding.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
|
|
{step === "read" && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Youtube className="w-6 h-6" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
|
<Trans
|
|
i18nKey="onboarding.read.body"
|
|
components={[<span className="text-fg/90" />]}
|
|
/>
|
|
</p>
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => beginGrant("read")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
|
>
|
|
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
|
</button>
|
|
<button
|
|
onClick={close}
|
|
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
|
>
|
|
{t("onboarding.read.skip")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Read granted: show import progress before the optional write step. */}
|
|
{me.can_read && importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
|
{t("onboarding.importing.body")}
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{step === "write" && !importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Check className="w-6 h-6" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
|
{importSubs.isError ? (
|
|
<>{t("onboarding.write.importFailed")}</>
|
|
) : (
|
|
<>
|
|
{t("onboarding.write.feedReady", {
|
|
channels: myStatus.data
|
|
? t("onboarding.write.feedReadyChannels", {
|
|
count: myStatus.data.channels_total,
|
|
})
|
|
: "",
|
|
})}
|
|
</>
|
|
)}
|
|
{!importSubs.isError && (
|
|
<Trans
|
|
i18nKey="onboarding.write.rationale"
|
|
components={[<span className="text-fg/90" />]}
|
|
/>
|
|
)}
|
|
</p>
|
|
{importSubs.isError && (
|
|
<button
|
|
onClick={() => importSubs.mutate()}
|
|
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
|
>
|
|
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
|
|
</button>
|
|
)}
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => beginGrant("write")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
|
>
|
|
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
|
>
|
|
{t("onboarding.write.keepReadOnly")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === "done" && !importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Check className="w-6 h-6" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
|
{t("onboarding.done.body")}
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition w-full"
|
|
>
|
|
{t("onboarding.done.done")}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
<Dots index={stepIndex} total={3} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Overlay>
|
|
);
|
|
}
|