fix(e12): close the eighteen findings the E12 review round left standing
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.
This commit is contained in:
@@ -7,6 +7,8 @@ import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
@@ -57,35 +59,47 @@ export default function AccountPanel({
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminUserAccess(user.id) });
|
||||
},
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
if (!link) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.url);
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard access can be refused (permissions, an insecure origin). The link is on screen and
|
||||
// selectable, so this is a convenience that failed — not the action failing.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
return;
|
||||
}
|
||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
||||
// action failing. Say so rather than leaving the button looking inert.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
@@ -215,7 +216,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
<button
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="min-w-0 flex-1 text-left rounded-lg -m-1 p-1 hover:bg-hover transition"
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
@@ -325,12 +326,9 @@ function PendingAccess() {
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
||||
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
||||
const refresh = () => invalidateUserAccess(qc);
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
@@ -353,7 +351,7 @@ function PendingAccess() {
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && isEmailOff(err)
|
||||
isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
@@ -390,11 +388,6 @@ function PendingAccess() {
|
||||
}
|
||||
|
||||
/** Did the resend fail because this instance has no working SMTP? */
|
||||
function isEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -78,6 +79,10 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -64,7 +65,10 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
|
||||
// Via notifyActionError so the server's own reason wins over the generic fallback: this route
|
||||
// is rate-limited (SEC-14), and "Sync failed" would report a pace limit as a fault while the
|
||||
// same button on the Channels page explains it. A zero-arg onError discards that detail.
|
||||
onError: (e) => notifyActionError(e, t("stats.sync.syncFailed")),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
||||
@@ -17,7 +17,12 @@ export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
// A reset link opened here can only be ACTED on signed out, so say that rather than nothing —
|
||||
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
|
||||
// signing out from this same tab picks it up.
|
||||
if (!entry.outcome && entry.resetPending) {
|
||||
//
|
||||
// Tested on `resetPending` ALONE, not `!outcome && resetPending`: the hook keeps the two states
|
||||
// mutually exclusive (each new link clears the other), so an extra `!outcome` here would only add
|
||||
// an ordering dependency on the early return below — tidy that return upwards one day and the
|
||||
// banner silently becomes unreachable, which is the bug this branch was added to fix.
|
||||
if (entry.resetPending) {
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
"no_host": "That link doesn't contain a website address.",
|
||||
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
|
||||
"private": "That address is on the server's own network, so it can't be downloaded from.",
|
||||
"generic": "That address can't be downloaded from."
|
||||
"generic": "That address can't be downloaded from.",
|
||||
"bad_port": "That link's port number isn't valid."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
@@ -280,6 +281,10 @@
|
||||
"unknown": {
|
||||
"short": "Download failed",
|
||||
"body": "Something went wrong while downloading this item. The technical details below may help."
|
||||
},
|
||||
"quota_exceeded": {
|
||||
"short": "Storage full",
|
||||
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"title": "All set",
|
||||
"body": "Read and unsubscribe access are both granted. You can review or revoke either one anytime in Settings → Account, or from your Google Account.",
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"importFailed": "Couldn't import your subscriptions. You can try again from Statistics."
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"resend": "Send a confirmation link",
|
||||
"resendSent": "Confirmation link sent.",
|
||||
"setPassword": "Create a set-password link",
|
||||
"setPasswordAgain": "Create another link"
|
||||
"setPasswordAgain": "Create another link",
|
||||
"linkFailed": "Couldn't create the link. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
"no_host": "Ez a hivatkozás nem tartalmaz webcímet.",
|
||||
"unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.",
|
||||
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.",
|
||||
"generic": "Erről a címről nem lehet letölteni."
|
||||
"generic": "Erről a címről nem lehet letölteni.",
|
||||
"bad_port": "Ennek a hivatkozásnak érvénytelen a portszáma."
|
||||
},
|
||||
"fail": {
|
||||
"title": "A letöltés nem sikerült",
|
||||
@@ -280,6 +281,10 @@
|
||||
"unknown": {
|
||||
"short": "A letöltés nem sikerült",
|
||||
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
||||
},
|
||||
"quota_exceeded": {
|
||||
"short": "Betelt a tárhely",
|
||||
"body": "Ez a letöltés leállt, mert a tárhelykereted már elfogyott. Törölj valamit a könyvtáradból, aztán próbáld újra."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"title": "Minden kész",
|
||||
"body": "Az olvasási és a leiratkozási hozzáférés is engedélyezve van. Bármelyiket bármikor áttekintheted vagy visszavonhatod a Beállítások → Fiók menüben, vagy a Google-fiókodban.",
|
||||
"done": "Kész"
|
||||
}
|
||||
},
|
||||
"importFailed": "Nem sikerült importálni a feliratkozásaidat. Újrapróbálhatod a Statisztika oldalon."
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"resend": "Megerősítő link küldése",
|
||||
"resendSent": "Megerősítő link elküldve.",
|
||||
"setPassword": "Jelszóbeállító link készítése",
|
||||
"setPasswordAgain": "Új link készítése"
|
||||
"setPasswordAgain": "Új link készítése",
|
||||
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpError } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// Shared bits of the admin's user-management surface, extracted because the SAME two mutations
|
||||
// (activate, resend-verification) are now offered from two places — the pending-access queue and
|
||||
// the account panel — and the second copy quietly dropped half of what the first one does.
|
||||
//
|
||||
// Grouped invalidation lives per domain rather than in `qk` (see the contract note there);
|
||||
// `invalidateDownloads` is the same pattern for the downloads domain.
|
||||
|
||||
/** Everything that goes stale when a user's ACCESS state changes (activate, approve, suspend).
|
||||
*
|
||||
* `me` is in the set because the admin badge counts pending activations, and `admin-pending-access`
|
||||
* because the queue is exactly the list of accounts this action removes one from — refreshing only
|
||||
* `admin-users` leaves the badge and the queue asserting a person is still stuck after they were
|
||||
* let in. One function so the two call sites cannot drift apart again. */
|
||||
export function invalidateUserAccess(qc: QueryClient, userId?: number) {
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
if (userId !== undefined) qc.invalidateQueries({ queryKey: qk.adminUserAccess(userId) });
|
||||
}
|
||||
|
||||
/** Did an admin resend fail because the instance has no working mail, rather than for a real
|
||||
* error? The admin-side resend deliberately reports failures (the public one is silent), so the
|
||||
* message must be able to say WHICH — a no-SMTP instance would otherwise read as a bug. */
|
||||
export function isEmailOff(err: unknown): boolean {
|
||||
if (!(err instanceof HttpError)) return false;
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
@@ -59,7 +59,15 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
|
||||
// same-document navigation the import-time snapshot never sees. The verify half got this
|
||||
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
|
||||
if (hash.get("reset")) setResetPending(true);
|
||||
if (hash.get("reset")) {
|
||||
setResetPending(true);
|
||||
// Same rule as a second verify link below: a newly pasted link must not be shadowed by a
|
||||
// banner still standing from the previous one. Without this the reset guidance is
|
||||
// unreachable whenever an unread verify outcome is on screen, and `dismiss` then clears the
|
||||
// pending flag along with the outcome — the DX-17 silence, one state later.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
}
|
||||
};
|
||||
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
||||
// feed itself.
|
||||
@@ -74,9 +82,12 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
clearEntryUrl();
|
||||
if (!token || consumed.current.has(token)) return;
|
||||
consumed.current.add(token);
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing.
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing —
|
||||
// whichever kind that outcome was. Clearing both keeps the two states mutually exclusive, which
|
||||
// is what lets the single `dismiss` below clear "whatever is showing" without eating the other.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
setResetPending(false);
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((r) => {
|
||||
|
||||
@@ -157,6 +157,42 @@ describe("confirming an emailed verification link", () => {
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// The two banners are mutually exclusive by construction, and each new link clears the other. Once
|
||||
// they could coexist, the reset guidance was gated behind "no outcome showing" and a standing
|
||||
// confirmation swallowed it — DX-17 again, one state later — and dismissing that confirmation then
|
||||
// cleared the pending reset with it, so the guidance could never appear at all.
|
||||
it("lets a pasted reset link supersede a confirmation banner still on screen", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-first", verifyEmail);
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#reset=pasted");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
expect(outcome()).toBe("none:-"); // the old banner is gone, so nothing shadows the guidance
|
||||
});
|
||||
|
||||
it("lets a pasted verify link supersede a pending reset notice", async () => {
|
||||
// The other direction, for the same reason: whichever link arrived last is the one the user is
|
||||
// acting on, and only one of the two states may be true for `dismiss` to be unambiguous.
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "second@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#reset=first", verifyEmail);
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#verify=tok-second");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(outcome()).toBe("ok:second@example.com");
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
// ?link=/?oauth= effects and the lazily-loaded module views read their own parameters. It used to
|
||||
// blank the whole query and null history.state, which silently broke first-run setup (the wizard
|
||||
|
||||
Reference in New Issue
Block a user