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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user