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
+2 -2
View File
@@ -111,8 +111,8 @@ export default function App() {
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
localKey: LS.filterCollapsed,
+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}
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
//
// The notification and hints stores read their per-account localStorage when they are imported. In a new
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
// nobody's history, and the tab's first notice replaced the account's stored history with it.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
const older = {
id: 7,
level: "info",
message: "older",
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
};
localStorage.setItem("siftlode.notifications.5", JSON.stringify([older]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
setActiveAccount(5);
notifications.notify({ message: "after the pin" });
const stored = JSON.parse(localStorage.getItem("siftlode.notifications.5") ?? "[]") as {
message: string;
}[];
expect(stored.map((n) => n.message)).toEqual(["older", "before the pin", "after the pin"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once the tab is pinned", async () => {
localStorage.setItem("siftlode.hints.5", "0");
const hints = await import("./hints");
const { setActiveAccount } = await import("./api/core");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
setActiveAccount(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
+20 -2
View File
@@ -3,12 +3,30 @@
// on it, without a circular import — the account id is a lower-level primitive than the storage
// helpers that consume it.
//
// Deliberately NOT reactive: BootGate pins the account before any per-account state is read, and a
// switch reloads the page, so nothing mounted ever sees the id change underneath it.
// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads
// the page, so nothing mounted ever sees the id change underneath it. The one exception is state read at
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
const pinListeners = new Set<() => void>();
export function onAccountPinned(reload: () => void): void {
pinListeners.add(reload);
}
export function pinActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
pinListeners.forEach((reload) => reload());
}
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
+1
View File
@@ -19,6 +19,7 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import {
+2 -8
View File
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../activeAccount";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, pinActiveAccount } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -262,13 +262,7 @@ export function beacon(url: string, body: Record<string, unknown>): void {
}
}
export function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
}
export const setActiveAccount = pinActiveAccount;
export function clearActiveAccount(): void {
try {
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
+3
View File
@@ -1,6 +1,7 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { onAccountPinned } from "./activeAccount";
import { createStore } from "./store";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
@@ -9,6 +10,8 @@ function load(): boolean {
}
const store = createStore<boolean>(load());
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
onAccountPinned(() => store.set(load()));
export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
+17
View File
@@ -2,6 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@@ -205,6 +206,22 @@ function emit() {
recompute();
// The history and settings above are read at import, which in a new tab is before BootGate pins its
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
// tab's first write would replace the account's stored history with its own empty one. A notice raised
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
let loadedFor = activeAccountId();
onAccountPinned(() => {
const unpinned = loadedFor == null ? items : [];
loadedFor = activeAccountId();
config = loadSettings();
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
const history = load().map((n) => ({ ...n, id: counter++ }));
items = [...history, ...unpinned].slice(-MAX_HISTORY);
recompute();
listeners.forEach((l) => l());
});
export function subscribe(listener: () => void): () => void {
listeners.push(listener);
return () => {