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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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