fix: review round 3 — a new tab keeps its account's notices, a revoked tab account lands on the default

Deep-review round 3 over main..improvement/watch-background-audio.

Account boot
- The notification history and settings, and the hints toggle, are read
  when their modules load, which in a new tab (bookmark, typed URL) is
  before BootGate pins the account: they read as nobody's, and the tab's
  first notice replaced the account's stored history (up to 100 entries)
  with a one-item list. They now read again when the tab is pinned
  (onAccountPinned, run by setActiveAccount); a notice raised before the
  pin stays, after the history.
- A tab pinned to an account still in this browser's wallet but whose
  session ended (suspended, deleted, or revoked by "log out everywhere")
  got "signed out" from /api/me while the default account was still
  signed in, and signing in again as anyone else left it there. A refused
  per-tab account now leaves the wallet, and the probe answers as the
  default, so the tab reloads onto it.

Share page (/watch)
- The password unlock is limited per viewer (link + client IP), under a
  per-link ceiling that counts only wrong passwords. Keyed on the link
  alone, ten wrong guesses every five minutes locked out every viewer,
  the ones with the password included. client_ip moves to app.ratelimit
  so the public routes can key on it.
- Its failure messages and the load-failure screen are announced
  (role=alert, the password input described by its error). The sign-in,
  passphrase, password-change and setup form errors get role=alert too.

Comments that still described removed machinery are corrected: App's
collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig.

Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
This commit is contained in:
2026-09-15 02:25:00 +02:00
parent f569b19c49
commit e7f38396a2
26 changed files with 366 additions and 93 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// applied together via one Save/Discard bar; nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = [
@@ -4,6 +4,9 @@
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect
// this replaced applied the demo default after mounting; with it gone, a seed that did not read `me`
// would open the demo account on its empty subscriptions scope.
//
// A share link's filters are written to the account once at mount too, so the view the link opened
// survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -55,4 +58,11 @@ describe("FeedFiltersProvider seed", () => {
mount({ is_demo: false });
expect(scopes[0]).toBe("my");
});
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
window.history.replaceState(null, "", "/?scope=all");
mount({ is_demo: false });
expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
});
});
+10 -2
View File
@@ -106,7 +106,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
{err && (
<div role="alert" className="text-sm text-danger">
{err}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={cancelReset} disabled={busy}>
{t("common.cancel")}
@@ -153,7 +157,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
{err && (
<div role="alert" className="text-sm text-danger">
{err}
</div>
)}
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
+10 -7
View File
@@ -24,8 +24,8 @@ import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. App owns the state + the save; this controller is
// how the panel reads/writes it. See App.tsx.
// outcome. PrefsProvider owns the state + the save; this controller
// is how the panel reads/writes it.
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
@@ -128,8 +128,7 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
@@ -252,8 +251,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
function Notifications({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft (live preview via configureNotifications there); the
// panel's Save bar persists it. The "send test" button below still fires immediately.
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
// it. The "send test" button below fires immediately.
const { notif: cfg, setNotif } = prefs;
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
const autoOn = cfg.durationMs > 0;
@@ -498,7 +497,11 @@ function SignInMethods({ me }: { me: Me }) {
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-danger">{err}</p>}
{err && (
<p role="alert" className="text-xs text-danger">
{err}
</p>
)}
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</Button>
+5 -1
View File
@@ -267,7 +267,11 @@ export default function SetupWizard() {
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
)}
{error && <p className="text-sm text-danger mt-3">{error}</p>}
{error && (
<p role="alert" className="text-sm text-danger mt-3">
{error}
</p>
)}
<div className="flex items-center justify-between mt-6">
<button
@@ -147,6 +147,7 @@ describe("WatchPage honest failures", () => {
const page = render(<WatchPage />);
const retry = await page.findByRole("button", { name: "Try again" });
expect(page.queryByText(GONE)).toBeNull();
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
fireEvent.click(retry);
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
@@ -188,12 +189,18 @@ describe("WatchPage honest failures", () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
await page.findByText("Too many attempts. Try again in a few minutes.");
expect(page.getByPlaceholderText("Enter password")).toBeTruthy();
const alert = page.getByRole("alert");
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
alert.id,
);
});
it("an unlock that gets no answer says so instead of doing nothing", async () => {
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
await page.findByText("Couldn't check the password. Try again.");
expect((await page.findByRole("alert")).textContent).toBe(
"Couldn't check the password. Try again.",
);
});
});
+14 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
@@ -143,6 +143,7 @@ export default function WatchPage() {
const [meta, setMeta] = useState<Meta | null>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
@@ -233,7 +234,9 @@ export default function WatchPage() {
</div>
{status === "loading" && (
<div className="flex-1 grid place-items-center text-muted">{T.loading}</div>
<div role="status" className="flex-1 grid place-items-center text-muted">
{T.loading}
</div>
)}
{status === "gone" && (
@@ -247,7 +250,8 @@ export default function WatchPage() {
{status === "error" && (
<div className="flex-1 grid place-items-center text-center">
<div>
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
<div role="alert">
<div className="text-lg font-semibold">{T.failed}</div>
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
<Button variant="primary" size="md" onClick={retry} className="mt-4">
@@ -270,9 +274,15 @@ export default function WatchPage() {
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{error && <div className="text-sm text-danger mt-2">{error}</div>}
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
{error && (
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
{error}
</div>
)}
<Button
variant="primary"
size="md"
+5 -1
View File
@@ -686,7 +686,11 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
className={inputCls}
/>
)}
{error && <p className="text-xs text-danger">{error}</p>}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={busy}