Files
siftlode/frontend/src/components/BootGate.tsx
T
peter f569b19c49 fix: review round 2 — a signed-out tab acts as nobody, honest share-page failures
Deep-review round 2 over main..improvement/watch-background-audio.

Account resolution (server)
- A request naming an account that is no longer signed in to this browser
  (X-Siftlode-Account or ?account=) now acts as nobody instead of the
  session's default account. A tab still pinned to an account signed out in
  another tab went on acting as the default: its player checkpoints and
  preference saves landed in that account, the messages socket and Plex
  playback ran as it, and its Sign out signed THAT account out. A hidden tab
  never polls /api/me, so a tab playing in the background kept doing it.
  The /api/me bootstrap probe alone keeps the fallback, which is how such a
  tab learns who it now is: the app's 401 handler reloads, and BootGate
  re-pins the tab.

BootGate
- On an identity change the tab reloads without moving its pin first. The
  tree being torn down still runs its unmount saves before the page goes,
  and with the pin already moved they wrote into the new account's local
  keys (a player volume still pending). The reloaded page corrects the pin.

Share page (/watch)
- Only a 404 says the link is gone. A 5xx, a dropped connection, and the
  unlock's own rate limit all rendered "no longer available ... expired or
  revoked" to a viewer with a valid link, with no way to try again. A load
  failure now offers Retry; too many password tries says so and keeps the
  form; an unlock that gets no answer says so instead of doing nothing.
  Strings in en/hu/de.

Downloads
- A cookie-authed asset is recognised under the current signature version
  and the one before it, so a job queued before a deploy that bumps the
  version still downloads with its cookie instead of failing on the login
  wall with an error that says no cookie was used.

The app chunk is preloaded through lazyWithPreload, so a failed fetch no
longer leaves an unhandled rejection beside the ErrorBoundary. The panel
layout seed is one helper (seedLayout).

Tests: the stale-tab refusal, its harmless Sign out and the probe fallback;
the pre-bump authed asset; BootGate keeping the pin on reload and staying
mounted through a same-account change; the reveal clock's later-commit
hand-off; the prefs, panel layout and demo-scope seeds from the me cache;
the share page's failure states. Each mutation-checked RED for its own
reason.
2026-09-15 01:35:25 +02:00

125 lines
6.9 KiB
TypeScript

import { useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
import { qk } from "../lib/queryKeys";
import i18n, { isSupported, setLanguage } from "../i18n";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
//
// Per-account state is everywhere — the landing page, the feed's filters, the Plex scope, the manager's
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
// the provider tree — whose initializers are then simply right, with nothing to re-read.
//
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
// match the account the server answered as before they run. And the tree is only ever built for one
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
// was pinned to was signed out in another tab and the server now answers as the browser's default),
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
// account's query cache, whose keys are not per account.
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
* loading visual, full screen. Its reveal counts from navigation start, so the gate's screen, the
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
export function BootScreen() {
return (
<div className="min-h-screen grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
}
export default function BootGate({ children }: { children: ReactNode }) {
const { t } = useTranslation();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
// every other route is locked — so `me` is held back until the instance is known to be set up
// (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
});
const needsSetup = setupQuery.data?.configured === false;
// THE fetching `me` query; every other reader is a passive (enabled:false) observer of its cache.
// Polled while signed in so an ended session is noticed with no interaction: /api/me answers a
// signed-out (or suspended) caller with 200 and no account, the identity drops to "nobody", and the
// tab reloads to the login page. Not polled signed out — that refetch is pointless and used to
// flicker the public landing.
const meQuery = useQuery({
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
const me = meQuery.data;
// Who this tab is, once known: an account id, `null` for "nobody" (signed out, or setup mode — App
// renders the landing / the wizard for those), `undefined` while unknown or unanswerable.
// Keyed on the DATA, not the status: a background refetch that fails keeps the last answer, so a
// server blip never tears the whole app down to this screen.
const identity: number | null | undefined = !setupQuery.data
? undefined
: needsSetup
? null
: me === undefined
? undefined
: (me?.id ?? null);
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
// before the browser paints: the frame that first shows the app already has the account pinned.
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
// session's default account would silently swap identity when ANOTHER tab changes that default
// (adds or switches an account); once pinned it sends its own account header. The server honours
// that header only for an account still signed in to this browser, so a pin naming one that is
// not is corrected here — otherwise the tab would run as the default account on the server while
// reading and writing the gone account's local state.
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed.
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
// The tree was built for another identity — see the header. The pin stays as it is: the tree
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
// would land under the new account's local keys. The reloaded page's first probe corrects it.
location.reload();
return;
}
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
const lang = me?.preferences?.language;
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
setReadyFor(identity);
// `me` is read for the identity it produced; a background refetch of the same account must not
// re-run the adoption over a language the user has just changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
// out": rendering the login page to a signed-in user would be a lie. After a retry the identity
// lands and the tree mounts from scratch, like any first load.
const failed =
!setupQuery.data && setupQuery.isError
? setupQuery
: me === undefined && meQuery.isError
? meQuery
: null;
if (failed)
return (
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
if (identity === undefined || readyFor !== identity) return <BootScreen />;
return <>{children}</>;
}