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
@@ -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 () => {