refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
+82
View File
@@ -0,0 +1,82 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import { formatDateTime } from "../lib/format";
import { Button } from "../components/ui/Button";
import Modal from "../components/ui/Modal";
function fmtDate(d: string | null | undefined, lang: string): string {
if (!d) return "—";
const t = new Date(d);
return isNaN(t.getTime()) ? d : formatDateTime(d, lang);
}
// About dialog: app + build + schema versions (frontend/backend/database), plus a
// shortcut into the release notes.
export default function About({
onClose,
onOpenReleaseNotes,
}: {
onClose: () => void;
onOpenReleaseNotes: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { data } = useQuery({
queryKey: qk.version(),
queryFn: api.version,
staleTime: 5 * 60_000,
});
const rows: [string, string][] = [
[
t("about.frontend"),
FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : ""),
],
[
t("about.backend"),
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
],
[t("about.database"), data?.db_revision ?? "…"],
[t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE, lang)],
];
return (
<Modal
initialFocus="close"
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
</span>
}
onClose={onClose}
>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<div className="text-sm text-muted">v{FRONTEND_VERSION}</div>
</div>
<p className="text-sm text-muted mt-2">{t("about.tagline")}</p>
<div className="mt-4 rounded-xl border border-border overflow-hidden text-sm">
{rows.map(([k, v], i) => (
<div
key={k}
className={`flex justify-between gap-4 px-3 py-2 ${i % 2 ? "bg-surface/40" : ""}`}
>
<span className="text-muted">{k}</span>
<span className="font-medium text-right break-all">{v}</span>
</div>
))}
</div>
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
</Button>
</Modal>
);
}
+499
View File
@@ -0,0 +1,499 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { saveLanguage } from "../lib/saveLanguage";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, setUnauthorizedHandler, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { setLanguage, type LangCode } from "../i18n";
import { clearParams } from "../lib/urlState";
import { reloadAfterRefusal } from "../lib/entryUrl";
import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice";
import {
PREF_KEY,
saveLayoutLocal,
seedLayout,
type PanelId,
type PanelLayout,
} from "../lib/panelLayout";
import { notify, removeByMetaKind } from "../lib/notifications";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import { useVerifyEntry } from "../lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
import { useFeedView, useFeedViewActions } from "../modules/feed/feedViewContext";
import { moduleOrder } from "../lib/modules";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
import { lazyWithPreload } from "../lib/lazyWithPreload";
import { LoadingState } from "../components/ui/QueryState";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./Welcome";
import SetupWizard from "./SetupWizard";
import Header from "./Header";
import NavSidebar from "./NavSidebar";
import BackToTop from "../components/ui/BackToTop";
import PageShell from "../components/ui/PageShell";
import { SkipLink } from "../components/ui/SkipLink";
import { pageTitleKey } from "../lib/pageMeta";
import DebugRecorder from "../features/diagnostics/DebugRecorder";
import Toaster from "../components/ui/Toaster";
import KeyboardHelp from "../components/ui/KeyboardHelp";
import AppCommandPalette from "./AppCommandPalette";
import DownloadIntake from "../features/downloads/DownloadIntake";
import DownloadTray from "../features/downloads/DownloadTray";
import { useGlobalKeymap } from "../lib/keymap";
import ErrorDialog from "./ErrorDialog";
import VersionBanner from "./VersionBanner";
import VerifyBanner from "./VerifyBanner";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { focusSettingsTab } from "../lib/settingsTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "../lib/version";
import { applyDiagnostics } from "../lib/diagnostics";
// The page modules (and their side rails) are code-split per route via the module registry
// (shell/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazyWithPreload(() => import("../modules/feed/ChannelPage"));
const About = lazy(() => import("./About"));
const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
const verifyEntry = useVerifyEntry();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
const queryClient = useQueryClient();
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
// changed on another device is right on the first paint; the local copy covers a key the server
// has never stored.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
return {
feed: seedLayout("feed", prefs),
plex: seedLayout("plex", prefs),
playlists: seedLayout("playlists", prefs),
files: seedLayout("files", prefs),
};
});
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
api
.savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// 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, 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,
parse: Boolean,
});
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
localKey: LS.playlistsCollapsed,
parse: Boolean,
});
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
// so it starts pinned open (the owner's mockup), and has its own flag.
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
localKey: LS.filesCollapsed,
parse: Boolean,
});
const railCollapse = {
filter: [filterCollapsed, setFilterCollapsed],
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
files: [filesCollapsed, setFilesCollapsed],
} as const;
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
// and waits for: App mounts only once both have answered, so their caches are always populated here.
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
enabled: false,
});
const needsSetup = setupQuery.data?.configured === false;
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
const me = meQuery.data;
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
// above the signed-out branch is worse than not binding it.
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A debug session that is
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
// applyDiagnostics computes an effective-level key and returns early when it has not moved
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
// before that early return, which is the half we WANT re-run. The old dep list picked out four
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
// job to verify, not a comment's to assert, and it silently missed any other field the module
// may come to read.
const diagnostics = me?.diagnostics;
const debugSession = me?.debug_session;
useEffect(() => {
applyDiagnostics(
diagnostics,
debugSession,
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
);
}, [diagnostics, debugSession, queryClient]);
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
// nothing; only the idle callback is re-scheduled.
useEffect(() => {
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
ChannelPage.preload().catch(() => {});
};
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
if (idle) {
const id = idle(run, { timeout: 3000 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [me]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
useEffect(() => {
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
document.body.appendChild(probe);
const w = probe.offsetWidth - probe.clientWidth;
document.body.removeChild(probe);
document.documentElement.style.setProperty("--sbw", `${w}px`);
}, []);
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
// on an account switch was never the point — by then the params are already gone and a second
// clearParams is a no-op.
const signedIn = !!me;
useEffect(() => {
if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]);
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
useAccountSwitchNotice(signedIn);
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
// can't loop the public landing. A tab showing another account than the one signed in now
// reloads as that one instead, and says why (`reloadAfterRefusal`).
useEffect(() => {
setUnauthorizedHandler((err) => {
if (!queryClient.getQueryData(qk.me())) return;
queryClient.clear();
reloadAfterRefusal(err);
});
return () => setUnauthorizedHandler(null);
}, [queryClient]);
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
// toast when the user changes language. (The old `[]` got away with it only because clearParams
// had already removed the param — an invariant held by a side effect two lines further down.)
const linkAnswered = useRef(false);
const { refetch: refetchMe } = meQuery;
useEffect(() => {
if (linkAnswered.current) return;
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
linkAnswered.current = true;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
focusSettingsTab("account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
clearParams("link");
}, [t, setPage, refetchMe]);
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
// neutral toast is the right feedback — the pre-auth login banner would never render here.
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
const oauthAnswered = useRef(false);
useEffect(() => {
if (oauthAnswered.current) return;
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
oauthAnswered.current = true;
notify({ level: "info", message: t("settings.account.googleCancelled") });
clearParams("oauth");
}, [t]);
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
// `me` is known, and only for an admin (others just land on their default page).
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
// deep link was answered.
const adminDeepLinkAnswered = useRef(false);
useEffect(() => {
if (adminDeepLinkAnswered.current) return;
if (adminDeepLink !== "access-requests" || !me) return;
adminDeepLinkAnswered.current = true;
if (me.role === "admin") {
focusAccessRequestsTab();
setPage("users");
}
clearParams("admin");
}, [adminDeepLink, me, setPage]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
// before — the notice stayed in the old language until the count changed.
const role = me?.role;
const pendingInvites = me?.pending_invites ?? 0;
const pendingActivations = me?.pending_activations ?? 0;
useEffect(() => {
if (role !== "admin") return;
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = pendingInvites + pendingActivations;
if (pending > 0) {
notify({
level: "warning",
title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }),
// Durable inbox link (survives reload, unlike a live action callback); the toast also
// gets a click via the action.
meta: { kind: "access-requests" },
action: {
label: t("common.accessRequestsReview"),
onClick: () => {
focusAccessRequestsTab();
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
setPage("users");
},
},
});
}
}, [role, pendingInvites, pendingActivations, t, setPage]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
saveLanguage(queryClient, code).catch((e: unknown) =>
notifyActionError(e, t("errors.writeFailed.prefs")),
);
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (needsSetup) return <SetupWizard />;
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!me) return <Welcome verify={verifyEntry} />;
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
const pageFallback = <LoadingState className="flex-1 justify-center" />;
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
const normalScrollKey =
page !== "feed"
? `page:${page}`
: ytSearch != null
? `yt:${ytSearch}`
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
// the account can't reach never gets here: the navigation state already moved it to the feed
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
const railDef = channelView ? undefined : PAGE_RAIL[page];
return (
<div className="h-dvh flex text-fg">
<SkipLink label={t("nav.skipToContent")} />
<NavSidebar
me={me!}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
/>
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
{railDef && (
<Suspense fallback={null}>
<railDef.Rail
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railCollapse[railDef.collapse][0]}
onToggleCollapse={() => {
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
setCollapsed(!collapsed);
}}
/>
</Suspense>
)}
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
`contain: layout`, making this column a containing block for `position: fixed` descendants
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={me!}
view={view}
setView={setView}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
// ADD to the picked set, don't replace it: channels OR together now, so arriving
// from a second channel page should widen the feed, not silently drop the first.
// Deduped — clicking it again for a channel already picked is a no-op.
setFilters({
...filters,
channelIds: [...new Set([...filters.channelIds, channelView.id])],
});
// setPage already leaves an open channel page and pushes a clean history entry.
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
// and restored whatever page the channel had been opened from.
setPage("feed");
}}
/>
</Suspense>
</PageShell>
) : (
<PageShell
title={t(pageTitleKey(page))}
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}>
<PageContent />
</Suspense>
</PageShell>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */}
<Toaster />
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
<DownloadTray />
</div>
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
Mounted once, outside the page area, so every surface answers "what can I press here" and
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
<DownloadIntake />
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
{me && <DebugRecorder />}
<Suspense fallback={null}>
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
onOpenReleaseNotes={() => {
setAboutOpen(false);
openReleaseNotes();
}}
/>
)}
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
</Suspense>
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
</div>
);
}
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
//
// The palette's DOMAIN half: which rows exist and what they do. The list/filter/keyboard shell
// (`ui/CommandPalette`) has its own tests, so it is stubbed here to hand back the groups it was given —
// what this file pins is the content: modules gated by the account, hidden channels left out, and the
// two `run`s whose ORDER matters.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CommandGroup } from "../components/ui/CommandPalette";
vi.mock("react-i18next", async (importOriginal) => ({
// The rest of the module too: the recorder's actions reach `i18n/index`, which needs it.
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
let groups: CommandGroup[] = [];
vi.mock("../components/ui/CommandPalette", () => ({
default: (props: { groups: CommandGroup[] }) => {
groups = props.groups;
return null;
},
}));
const channels = vi.fn();
const playlists = vi.fn();
vi.mock("../lib/api", () => ({
api: {
channels: (...a: unknown[]) => channels(...a),
playlists: (...a: unknown[]) => playlists(...a),
},
}));
let me: Record<string, unknown> | undefined;
vi.mock("../lib/useMe", () => ({ useMe: () => me }));
vi.mock("../lib/keymap", () => ({
usePaletteOpen: () => true,
closePalette: vi.fn(),
toggleKeyboardHelp: vi.fn(),
}));
const calls: string[] = [];
const setPage = vi.fn((page: string, then?: () => void) => {
calls.push(`setPage:${page}`);
then?.();
});
vi.mock("../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
}));
vi.mock("../modules/playlists/playlistsContext", () => ({
usePlaylistsActions: () => ({
setSelectedId: (id: number) => calls.push(`select:${id}`),
}),
}));
vi.mock("../features/prefs/prefsContext", () => ({
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
}));
vi.mock("../modules/feed/savedViewsContext", () => ({
useSavedViews: () => ({ views: [], apply: vi.fn() }),
}));
const requestSurprise = vi.fn();
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
const requestLinkBox = vi.fn();
vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() }));
import AppCommandPalette from "./AppCommandPalette";
const user = (over: Record<string, unknown> = {}) => ({
id: 1,
role: "user",
plex_enabled: false,
...over,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<AppCommandPalette />
</QueryClientProvider>,
);
}
const group = (id: string) => groups.find((g) => g.id === id);
const ids = (id: string) => group(id)?.items.map((i) => i.id) ?? [];
beforeEach(() => {
groups = [];
calls.length = 0;
me = user();
setPage.mockClear();
requestSurprise.mockClear();
channels.mockReset().mockResolvedValue([]);
playlists.mockReset().mockResolvedValue([]);
});
afterEach(cleanup);
describe("AppCommandPalette", () => {
it("offers only the modules this account can open, in nav order, and Settings", () => {
mount();
expect(ids("modules")).toContain("page:feed");
expect(ids("modules")).not.toContain("page:plex");
expect(ids("modules")).not.toContain("page:config"); // admin-only
expect(ids("modules").slice(-1)).toEqual(["page:settings"]);
cleanup();
me = user({ role: "admin", plex_enabled: true });
mount();
expect(ids("modules")).toContain("page:plex");
expect(ids("modules")).toContain("page:config");
});
it("leaves out the channels the user has hidden", async () => {
channels.mockResolvedValue([
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
{ id: "UC2", title: "Hidden", handle: "@hidden", hidden: true },
]);
mount();
await waitFor(() => expect(group("channels")).toBeTruthy());
expect(ids("channels")).toEqual(["channel:UC1"]);
});
it("selects the playlist BEFORE switching to the page, so it opens on that list", async () => {
playlists.mockResolvedValue([{ id: 7, name: "Later", item_count: 3 }]);
mount();
await waitFor(() => expect(group("playlists")).toBeTruthy());
group("playlists")!.items[0]!.run();
expect(calls).toEqual(["select:7", "setPage:playlists"]);
});
it("asks for the surprise once the navigation to Home has happened, not before", () => {
mount();
const surprise = group("actions")!.items.find((i) => i.id === "action:surprise")!;
// Over an open player the push lands a moment after the call, and the token must name the
// entry that push makes (lib/homeIntent) — so it rides `setPage`'s `then`, not the next line.
setPage.mockImplementationOnce((page: string) => void calls.push(`setPage:${page}`));
surprise.run();
expect(requestSurprise).not.toHaveBeenCalled();
const then = setPage.mock.lastCall?.[1];
expect(setPage.mock.lastCall?.[0]).toBe("home");
then?.();
expect(requestSurprise).toHaveBeenCalledTimes(1);
});
it("Download from a link takes the reader to the Files link box, asked on that entry", () => {
mount();
const item = group("actions")!.items.find((i) => i.id === "action:downloadLink")!;
setPage.mockImplementationOnce(() => {});
item.run();
expect(setPage.mock.lastCall?.[0]).toBe("files");
expect(requestLinkBox).not.toHaveBeenCalled();
setPage.mock.lastCall?.[1]?.();
expect(requestLinkBox).toHaveBeenCalledTimes(1);
});
it("offers the debug recorder by its state", () => {
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
mount();
expect(recorder()).toEqual(["action:recorder:start"]);
cleanup();
const until = new Date(Date.now() + 60_000).toISOString();
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
mount();
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
});
});
+226
View File
@@ -0,0 +1,226 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
Bug,
Download,
Flag,
Keyboard,
ListVideo,
Palette,
Sparkles,
Square,
Tv,
} from "lucide-react";
import CommandPalette, { type CommandGroup } from "../components/ui/CommandPalette";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent";
import { requestLinkBox } from "../lib/downloadIntake";
import { openRecorderStart } from "../lib/debugRecorder";
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
import { useNavigationActions } from "../features/navigation/navigationContext";
import { usePlaylistsActions } from "../modules/playlists/playlistsContext";
import { usePrefs } from "../features/prefs/prefsContext";
import { useSavedViews } from "../modules/feed/savedViewsContext";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
//
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
// would have saved.
//
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
export default function AppCommandPalette() {
const { t } = useTranslation();
const open = usePaletteOpen();
const me = useMe();
const { setPage, openChannel } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
const stopMutate = stopRecording.mutate;
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
const channelsQ = useQuery({
queryKey: qk.channels(),
queryFn: () => api.channels(),
enabled: open,
});
const playlistsQ = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
enabled: open,
});
const groups = useMemo<CommandGroup[]>(() => {
const out: CommandGroup[] = [];
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
if (me) {
const pages = [...moduleOrder(me), "settings" as const];
out.push({
id: "modules",
label: t("palette.groups.modules"),
items: pages.map((page) => {
const def = moduleDef(page);
const Icon = def.icon;
return {
id: `page:${page}`,
label: t(def.labelKey),
icon: <Icon className="w-4 h-4" />,
// The longer tab title is a second name for the same place ("Channel manager" for
// "Channels"), so match on it even though the row shows the short one.
keywords: def.titleKey ? t(def.titleKey) : "",
run: () => setPage(page),
};
}),
});
}
const channels = channelsQ.data ?? [];
if (channels.length > 0) {
out.push({
id: "channels",
label: t("palette.groups.channels"),
items: channels
.filter((c) => !c.hidden)
.map((c) => ({
id: `channel:${c.id}`,
label: c.title ?? c.id,
hint: c.handle ?? undefined,
icon: <Tv className="w-4 h-4" />,
keywords: c.handle ?? "",
run: () => openChannel(c.id, c.title ?? undefined),
})),
});
}
const playlists = playlistsQ.data ?? [];
if (playlists.length > 0) {
out.push({
id: "playlists",
label: t("palette.groups.playlists"),
items: playlists.map((p) => ({
id: `playlist:${p.id}`,
label: p.name,
hint: t("palette.videoCount", { count: p.item_count }),
icon: <ListVideo className="w-4 h-4" />,
run: () => {
setSelectedId(p.id);
setPage("playlists");
},
})),
});
}
if (savedViews.views.length > 0) {
out.push({
id: "views",
label: t("palette.groups.views"),
items: savedViews.views.map((v) => ({
id: `view:${v.id}`,
label: v.name,
run: () => savedViews.apply(v),
})),
});
}
out.push({
id: "actions",
label: t("palette.groups.actions"),
items: [
// "Meglepetés" — Home's reroll, reachable from anywhere. There is deliberately no
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
// duplication in the one list whose whole job is to be scanned quickly.
{
id: "action:surprise",
label: t("palette.actions.surprise"),
icon: <Sparkles className="w-4 h-4" />,
keywords: t("home.title"),
// The token is minted on the entry the push made (see lib/homeIntent), and asks for the
// SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press
// is answered by whichever tier the rotation reaches — for anyone with a shelf, "A
// polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
run: () => setPage("home", requestSurprise),
},
// Downloading is an action you start anywhere; with nothing pasted yet, the place to paste is
// the Files page's link box (lib/downloadIntake), focused on arrival.
{
id: "action:downloadLink",
label: t("palette.actions.downloadLink"),
icon: <Download className="w-4 h-4" />,
run: () => setPage("files", requestLinkBox),
},
{
id: "action:shortcuts",
label: t("keys.title"),
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
// The debug recorder. While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// player or a dialog where the pill's buttons are a long Tab away.
...(recordingSession
? [
{
id: "action:recorder:step",
label: t("recorder.palette.step"),
icon: <Flag className="w-4 h-4" />,
run: () => markStep(""),
},
{
id: "action:recorder:stop",
label: t("recorder.palette.stop"),
icon: <Square className="w-4 h-4" />,
run: () => stopMutate(recordingSession.id),
},
]
: [
{
id: "action:recorder:start",
label: t("recorder.palette.start"),
icon: <Bug className="w-4 h-4" />,
run: openRecorderStart,
},
]),
...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }),
icon: <Palette className="w-4 h-4" />,
keywords: s.id,
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
})),
],
});
return out;
}, [
t,
me,
channelsQ.data,
playlistsQ.data,
savedViews,
theme,
setTheme,
recordingSession,
markStep,
stopMutate,
setPage,
openChannel,
setSelectedId,
]);
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
}
+276
View File
@@ -0,0 +1,276 @@
// @vitest-environment jsdom
//
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab built
// the app for "no account" first — so with Settings → Appearance → Startup turned off,
// NavigationProvider's initializer could not read the opt-out and landed on Home, mirrored ?page=home
// into the address bar, and then re-read its own mirror once the account was known, staying on Home.
//
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider } from "../features/navigation/NavigationProvider";
import { useNavigation } from "../features/navigation/navigationContext";
import { api, type Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
const countMount = () => {
mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
};
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
useEffect(countMount, []);
return <div data-testid="app">{page}</div>;
}
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<BootGate>
<NavigationProvider>
<Probe />
</NavigationProvider>
</BootGate>
</QueryClientProvider>,
);
return qc;
}
// On a Plex-enabled instance: the remembered page these cases restore is the Plex module, which only
// an instance with Plex lets an account open.
const account = (over: Partial<Me> = {}) =>
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
let savePrefs: MockInstance<typeof api.savePrefs>;
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
// Whose local state the browser holds (storage.ts claimLocalData).
const owner = () => localStorage.getItem("siftlode.owner");
// The browser already holds account 5's state, as on any visit after the first.
const heldBy5 = (entries: Record<string, string>) => {
localStorage.setItem("siftlode.owner", "5");
for (const [k, v] of Object.entries(entries)) localStorage.setItem(k, v);
};
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab
window.history.replaceState(null, "", "/");
renders.length = 0;
mounts = 0;
await i18n.changeLanguage("en");
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
heldBy5({ "siftlode.startOnHome": "0", "siftlode.page": "plex" });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
expect(owner()).toBe("5");
});
it("renders the app in the account's language from its first render", async () => {
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
mount();
await screen.findByTestId("app");
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
});
it("an account with no language stored stores the one it is shown in, once", async () => {
await i18n.changeLanguage("hu"); // the browser's language, never saved
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" }));
expect(savePrefs).toHaveBeenCalledTimes(1);
// The server now sorts in Hungarian, and so does what reads the stored language.
await waitFor(() => expect(qc.getQueryData<Me>(qk.me())?.preferences.language).toBe("hu"));
});
it("an account with its language stored stores nothing", async () => {
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
mount();
await screen.findByTestId("app");
expect(savePrefs).not.toHaveBeenCalled();
});
it("mounts nothing of the app while the identity is still unknown", async () => {
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
mount();
await act(async () => {}); // the setup probe resolves; `me` never does
expect(screen.queryByTestId("app")).toBeNull();
expect(renders).toHaveLength(0);
});
it("signed out: mounts the app (App shows the landing) without claiming the local state", async () => {
vi.spyOn(api, "me").mockResolvedValue(null);
mount();
await screen.findByTestId("app");
expect(owner()).toBeNull();
});
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
const me = vi.spyOn(api, "me").mockResolvedValue(account());
mount();
await screen.findByTestId("app");
expect(me).not.toHaveBeenCalled();
expect(owner()).toBeNull();
});
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
vi.mocked(api.setupStatus)
.mockRejectedValueOnce(new Error("502"))
.mockResolvedValue({ configured: true });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(renders.length).toBeGreaterThan(0);
expect(owner()).toBe("5"); // it mounted for the account
});
it("an unanswered first account probe is an error with a retry", async () => {
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(owner()).toBe("5");
});
it("a background refetch that fails keeps the app mounted, as it was", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
me.mockRejectedValue(new Error("502"));
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
// react-query hands the error to its observers on a timer: let that render land before asserting.
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(qc.getQueryState(qk.me())?.status).toBe("error");
expect(screen.getByTestId("app")).toBeTruthy();
expect(mounts).toBe(1);
});
it("an account signing in where another one's state is held never reads it, not for one render", async () => {
// Account 9 used this browser last, and left it on Plex with Startup off.
localStorage.setItem("siftlode.owner", "9");
localStorage.setItem("siftlode.startOnHome", "0");
localStorage.setItem("siftlode.page", "plex");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("home");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "home"));
expect(owner()).toBe("5");
expect(localStorage.getItem("siftlode.page")).not.toBe("plex");
});
it("an account's state left under the old per-account keys is the one it boots on", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "plex");
localStorage.setItem("siftlode.page.9", "files");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(localStorage.getItem("siftlode.page.9")).toBeNull();
});
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(null); // the session ended
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
});
it("an identity change reloads without handing the local state over first", async () => {
// The tree being torn down still runs its unmount saves before the page goes; with the state
// already handed over they would land in the NEW account's (a player volume still pending, say).
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(account({ id: 6 })); // another tab signed this browser in as 6
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(owner()).toBe("5");
});
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
// rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this
// account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is
// installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which
// is exactly late enough to miss the re-run that setting `readyFor` triggers.
it("a plain boot never reloads, however many times the adoption effect re-runs", async () => {
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
act(() => {
qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
});
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
});
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
act(() => {
qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
});
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice
});
});
+145
View File
@@ -0,0 +1,145 @@
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import { log } from "../lib/log";
import { claimLocalData } from "../lib/storage";
import { saveLanguage } from "../lib/saveLanguage";
import { qk } from "../lib/queryKeys";
import i18n, { isSupported, setLanguage } from "../i18n";
import { LoadingState, StateMessage } from "../components/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> knew 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 browser's local state, which holds one account's: it is handed
// to the account the server answered as (claimLocalData) 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
// another tab signed this browser in as someone else), the tab reloads — 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-dvh grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
}
export default function BootGate({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
// 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 reads the account's state.
// • Hand the browser's local state to the account the server answered as: a different account
// than the one whose state the browser holds starts from a clean slate, so it never reads (or
// writes over) the previous one's filters, history or positions.
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
// orders its lists and writes its mail in the stored one, else English, and a Hungarian screen
// over English-ordered lists breaks "A→Z in the UI language's alphabet".
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
// `me` is read for the identity it produced, so it is an effect event: a background refetch of the same
// account must not re-run the adoption over a language the user has just changed. That rule used
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
const adoptedMe = useEffectEvent(() => me);
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
// see the header. The local state is not handed over here: the tree being torn down still runs
// its unmount saves before the page goes, and they belong to the account it was built for. The
// reloaded page's claim settles it. Comparing rather than merely testing for
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
// instead of a reload.
if (readyFor !== identity) location.reload();
return;
}
const account = adoptedMe();
if (account) claimLocalData(account.id);
const lang = account?.preferences?.language;
if (typeof lang === "string" && isSupported(lang)) {
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
if (lang !== i18n.language) setLanguage(lang);
} else if (account) {
// eslint-disable-next-line local/locale-format -- a one-shot read of the language the app is about to be shown in, stored once; nothing renders it.
const shown = i18n.language;
saveLanguage(qc, shown).catch((e: unknown) =>
log.warn("boot.language_store_failed", "the shown language could not be stored", {
error: String(e),
}),
);
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the local state is claimed and the language applied, and both are writes to things outside React (storage, i18n). A layout effect so the frame that first shows the app already has them.
setReadyFor(identity);
}, [identity, readyFor, qc]);
// 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-dvh grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
if (identity === undefined || readyFor !== identity) return <BootScreen />;
return <>{children}</>;
}
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// A render crash's notice speaks the reader's language. It showed `error.message` — the JS
// engine's English ("Cannot read properties of undefined (reading 'x')") — in the toast and the inbox,
// where it outlives the reload. The technical text stays in the `ui.crash` log.
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const notify = vi.fn();
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
import i18n from "../i18n";
import { clear, events } from "../lib/log";
import ErrorBoundary from "./ErrorBoundary";
afterEach(() => {
cleanup();
clear();
vi.restoreAllMocks();
});
it("a crash is reported in words the reader reads; the engine's text goes to the log", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the throw itself too
const Broken = () => {
throw new TypeError("Cannot read properties of undefined (reading 'x')");
};
render(
<ErrorBoundary>
<Broken />
</ErrorBoundary>,
);
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "fatal", message: i18n.t("errors.boundary.notifMessage") }),
);
expect(JSON.stringify(events().filter((e) => e.event === "ui.crash"))).toContain(
"Cannot read properties of undefined",
);
});
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
//
// A tab reloading onto a newer build (lib/staleBuild) renders the chunk it could not load as a render
// error: the boundary must neither report it as a crash (a fatal notice outlives the reload) nor flash
// the crash screen before the reload lands. Its own file: staleBuild's "reloading" flag is module state
// and never turns back off.
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const notify = vi.fn();
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
import { clear, events } from "../lib/log";
import { installStaleBuildReload } from "../lib/staleBuild";
import ErrorBoundary from "./ErrorBoundary";
afterEach(() => {
cleanup();
clear();
vi.restoreAllMocks();
});
it("a missing chunk while reloading for a new build is no crash: nothing reported, nothing shown", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the throw itself too
sessionStorage.clear();
const reload = vi.fn();
const uninstall = installStaleBuildReload(reload);
window.dispatchEvent(new Event("vite:preloadError", { cancelable: true }));
uninstall();
expect(reload).toHaveBeenCalledOnce();
const Missing = () => {
throw new Error("Failed to fetch dynamically imported module");
};
const { container } = render(
<ErrorBoundary>
<Missing />
</ErrorBoundary>,
);
expect(events().filter((e) => e.event === "ui.crash")).toEqual([]);
expect(notify).not.toHaveBeenCalled();
expect(container.innerHTML).toBe("");
});
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
import { CrashBoundary, type CrashCopy } from "../components/ui/CrashBoundary";
const copy = (): CrashCopy => ({
title: i18n.t("errors.boundary.title"),
subtitle: i18n.t("errors.boundary.subtitle"),
reload: i18n.t("errors.boundary.reload"),
});
// A fatal notification, kept in the center across the reload.
const onCrash = () =>
notify({
level: "fatal",
title: i18n.t("errors.boundary.notifTitle"),
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
// is no message for the reader — the `ui.crash` log carries it for diagnosis.
message: i18n.t("errors.boundary.notifMessage"),
requiresInteraction: true,
});
// The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center.
export default function ErrorBoundary({ children }: { children: ReactNode }) {
return (
<CrashBoundary copy={copy} onCrash={onCrash}>
{children}
</CrashBoundary>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal, { ModalFooter } from "../components/ui/Modal";
import { Button } from "../components/ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge.
export default function ErrorDialog() {
const { t } = useTranslation();
const err = useSyncExternalStore(subscribeError, getError, getError);
if (!err) return null;
return (
<Modal initialFocus="close" title={err.title} onClose={dismissError} maxWidth="max-w-md">
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
<ModalFooter>
<Button onClick={dismissError}>{t("errors.ok")}</Button>
</ModalFooter>
</Modal>
);
}
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
//
// The live-search button's tooltip says what a search costs under the search source the instance
// actually runs. It used to say "uses shared API quota" always, while the default source (the
// website's own endpoint) spends none — so the owner avoided a free search for months.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: "feed" }),
}));
vi.mock("../modules/feed/feedFiltersContext", () => ({
useFeedFilters: () => ({ q: "cats", sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
}));
vi.mock("../modules/playlists/playlistsContext", () => ({
usePlaylists: () => ({ search: "" }),
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
}));
vi.mock("../modules/channels/channelsContext", () => ({
useChannels: () => ({ search: "" }),
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
}));
vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
import Header from "./Header";
import type { Me } from "../lib/api";
afterEach(cleanup);
function tipFor(source: Me["search_source"]): string | null {
const me = { plex_enabled: false, search_source: source } as Me;
render(<Header me={me} onYtSearch={() => {}} />);
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
}
describe("the live-search tooltip", () => {
it("does not claim a quota cost under the free website source", () => {
expect(tipFor("scrape")).toBe("header.searchYoutubeTip");
});
it("names the quota cost when the instance searches through the official API", () => {
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
});
});
+137
View File
@@ -0,0 +1,137 @@
import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "../features/header/SearchBar";
import { useFiles, useFilesActions } from "../modules/files/filesContext";
import PlexSearchBox from "../modules/plex/PlexSearchBox";
import PlexScopeToggle from "../modules/plex/PlexScopeToggle";
import PlexViewToggle from "../modules/plex/PlexViewToggle";
import FeedScopeToggle from "../modules/feed/FeedScopeToggle";
import SyncStatus from "../modules/channels/SyncStatus";
import { useNavigation } from "../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
import { usePlaylists, usePlaylistsActions } from "../modules/playlists/playlistsContext";
import { useChannels, useChannelsActions } from "../modules/channels/channelsContext";
import { PaletteLauncher } from "../features/header/PaletteLauncher";
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper.
const Header = forwardRef<
HTMLDivElement,
{
me: Me;
// Trigger a live YouTube search for the current term (feed only).
onYtSearch: (q: string) => void;
}
>(function Header({ me, onYtSearch }, ref) {
const { t } = useTranslation();
const { page } = useNavigation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
// "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { search: filesSearch } = useFiles();
const { setSearch: setFilesSearch } = useFilesActions();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
// Per-page SearchBar wiring — null for modules without a search.
// A page without a search field still gets the palette's entry (PaletteLauncher).
let search: ReactNode = <PaletteLauncher />;
if (page === "feed") {
const trimmed = filters.q.trim();
search = (
<SearchBar
value={filters.q}
placeholder={t("header.searchPlaceholder")}
onChange={(q) => {
// When a search first appears, rank the feed by relevance — set atomically with the
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
// sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed}
/>
);
} else if (page === "plex" && me.plex_enabled) {
search = <PlexSearchBox />;
} else if (page === "channels") {
search = (
<SearchBar
value={channelsSearch}
placeholder={t("channels.searchPlaceholder")}
onChange={setChannelsSearch}
onGo={blur}
/>
);
} else if (page === "files") {
search = (
<SearchBar
value={filesSearch}
placeholder={t("files.searchPlaceholder")}
onChange={setFilesSearch}
onGo={blur}
/>
);
} else if (page === "playlists") {
search = (
<SearchBar
value={playlistsSearch}
placeholder={t("playlists.searchPlaceholder")}
onChange={setPlaylistsSearch}
onGo={blur}
/>
);
}
return (
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
// once the column is wider than the box's max-w — not hugged to the left edge.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
{search}
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar so the primary "what am I browsing" control is always in reach. The
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
pills rather than pinned to the far right: on a wide window that just strands it. Only on
the two modules it says anything about: the feed those subscriptions fill, and the manager
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
{(page === "feed" || page === "channels") && (
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
)}
</div>
);
});
export default Header;
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
//
// The account panel of the rail: one account per browser session, so it lists no other accounts. It
// offers "Sign in as someone else" (straight to Google's account chooser, whose pick replaces this
// account) where the instance has Google sign-in, and "Sign out", which ends the session.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "../i18n";
import { api, HttpError, type Me } from "../lib/api";
import * as actionErrors from "../lib/actionErrors";
import NavSidebar from "./NavSidebar";
vi.mock("../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: "feed" }),
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
vi.mock("../features/prefs/prefsContext", () => ({ usePrefs: () => ({ railLabels: false }) }));
const me = (over: Partial<Me> = {}) =>
({
id: 5,
email: "me@example.com",
display_name: "Me",
role: "user",
avatar_url: null,
google_email: null,
google_enabled: true,
plex_enabled: false,
preferences: {},
...over,
}) as unknown as Me;
const row = (panel: HTMLElement, text: string) =>
[...panel.querySelectorAll("button")].find((b) => b.textContent === text)!;
let href = "";
const reload = vi.fn();
async function mount(account: Me) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<NavSidebar me={account} onOpenAbout={() => {}} onChangeLanguage={() => {}} language="en" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /^Me · / }));
// Positioned by floating-ui, which never settles in jsdom: the panel stays `visibility: hidden`.
return vi.waitFor(() => {
const panel = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Me"]');
if (!panel) throw new Error("the account panel did not open");
return panel;
});
}
beforeEach(async () => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
await i18n.changeLanguage("en");
href = "";
reload.mockReset();
vi.stubGlobal("location", {
...window.location,
reload,
set href(v: string) {
href = v;
},
get href() {
return href;
},
});
vi.spyOn(api, "notificationUnreadCount").mockResolvedValue({ count: 0 } as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("the account panel", () => {
it("offers signing in as someone else, which goes straight to Google's account chooser", async () => {
const panel = await mount(me());
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
expect(rows).toEqual(["Sign in as someone else", "Sign out"]);
fireEvent.click(row(panel, "Sign in as someone else"));
expect(href).toBe("/auth/login");
});
it("offers only signing out where the instance has no Google sign-in", async () => {
const panel = await mount(me({ google_enabled: false }));
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
expect(rows).toEqual(["Sign out"]);
});
it("signing out ends the session, then reloads onto the sign-in page", async () => {
const logout = vi.spyOn(api, "logout").mockResolvedValue({ ok: true });
const panel = await mount(me());
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
expect(logout).toHaveBeenCalledOnce();
});
// Reloading after a sign-out that failed only brought the session back, with no word of why.
it("a sign-out that failed says so and keeps the page", async () => {
vi.spyOn(api, "logout").mockRejectedValue(new HttpError(500, "boom"));
const report = vi.spyOn(actionErrors, "notifyActionError").mockImplementation(() => {});
const panel = await mount(me());
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
expect(reload).not.toHaveBeenCalled();
});
});
+278
View File
@@ -0,0 +1,278 @@
import { useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from "lucide-react";
import { api, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import {
moduleDef,
moduleLabelKey,
moduleOrder,
moduleRailKey,
SYSTEM_PAGES,
} from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import AvatarImg from "../components/ui/Avatar";
import LanguageSwitcher from "../components/ui/LanguageSwitcher";
import { PopoverSurface } from "../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../components/ui/anchoredPopoverHooks";
import { Pressable } from "../components/ui/Pressable";
import { SURFACE } from "../components/ui/surfaces";
import {
NavRail,
NavRailAction,
NavRailDivider,
NavRailGroup,
NavRailItem,
NavRailList,
RAIL_ROW,
} from "../components/ui/NavRail";
import { usePrefs } from "../features/prefs/prefsContext";
import { pageHref } from "../lib/nav";
import { notifyActionError } from "../lib/actionErrors";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
// actions sit at the bottom in a popover.
export default function NavSidebar({
me,
onOpenAbout,
onChangeLanguage,
language,
}: {
me: Me;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
language: LangCode;
}) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
// The rail is ALWAYS a thin icon-only column flush against the left edge. There is no
// expanded/pinned mode anymore — instead each icon reveals a floating name label on hover or
// keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the
// rail ever growing.
const [acctOpen, setAcctOpen] = useState(false);
// The account panel holds mixed content with its own tab order (the profile, then its actions),
// so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
// comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
open: acctOpen,
onOpenChange: setAcctOpen,
mode: "dialog",
role: "dialog",
placement: "right-end",
offset: 8,
});
const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() {
// Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
// so and stays: the reload would only bring back the session that is still there.
const signedOut = await api.logout().then(
() => true,
(err: unknown) => {
notifyActionError(err, t("errors.writeFailed.signOut"));
return false;
},
);
if (signedOut) location.reload();
}
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, {
intervalMs: 30000,
});
// One indicator for both layers: durable server notifications + the client-side transient
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
// module's icon + label — come from the shared module registry (the same source the header's
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
const BADGE: Partial<Record<Page, number>> = {
notifications: unread,
};
const META = (p: Page): Omit<NavItem, "page"> => ({
icon: moduleDef(p).icon,
label: t(moduleLabelKey[p]),
badge: BADGE[p],
});
const sys = new Set<Page>(SYSTEM_PAGES);
const order = moduleOrder(me);
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = order
.filter((p) => !sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const systemItems: NavItem[] = order
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const { railLabels } = usePrefs();
const name = displayName(me);
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
const roleLabel = t(`nav.role.${roleKey(me)}`);
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
<NavRailItem
key={p}
testId={`nav-${p}`}
icon={icon}
label={label}
shortLabel={t(moduleRailKey[p])}
showLabel={railLabels}
active={page === p}
badge={badge}
href={pageHref(p)}
onClick={() => setPage(p)}
/>
);
return (
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
// against its right edge.
<div className="relative shrink-0 w-15">
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
<NavRailList ref={listFadeRef} style={listFadeStyle}>
{userItems.map(renderItem)}
{systemItems.length > 0 && (
<>
<NavRailDivider />
{/* The admin pages, one row opening a panel of them: five rows of their own sat
below the fold at 133% zoom, behind a fade alone. */}
<NavRailGroup
testId="nav-admin"
icon={Shield}
label={t("nav.admin")}
shortLabel={t("nav.rail.admin")}
showLabel={railLabels}
pages={systemItems.map(({ page: p, icon, label }) => ({
id: p,
icon,
label,
href: pageHref(p),
active: page === p,
onSelect: () => setPage(p),
}))}
/>
</>
)}
</NavRailList>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div className="flex flex-col gap-1 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<NavRailAction icon={Info} label={t("header.account.about")} onClick={onOpenAbout} />
<NavRailAction icon={LogOut} label={t("header.account.signOut")} onClick={logout} />
</div>
<NavRailItem
testId="nav-settings"
icon={Settings}
label={t("header.account.settings")}
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
href={pageHref("settings")}
onClick={() => setPage("settings")}
/>
<div>
<Pressable
ref={setAcctAnchor}
{...acctPop.getReferenceProps()}
title={`${name} · ${roleLabel}`}
aria-label={`${name} · ${roleLabel}`}
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
>
<span className="relative shrink-0">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-5.5 h-5.5 rounded-full text-3xs"
/>
</span>
</Pressable>
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
{/* The address Google answers with, when it isn't the one above. /api/me sends it
only when the two differ, so for most accounts this line never exists — and for
someone who registered as `me+siftlode@gmail.com` it answers the question their
sign-in screen would otherwise leave open: which address gets me in? A text
label rather than a brand glyph, since the app draws one icon set (lucide) and a
generic mail icon here would say nothing. */}
{me.google_email && (
<div
className="text-2xs text-muted truncate mt-0.5"
title={t("header.account.googleAddress", { email: me.google_email })}
>
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
</div>
)}
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-2xs font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
{/* Straight to Google's account chooser: the account picked there takes this one's
place in the browser (one account per session), and a cancel there leaves this
one signed in. A password account signs in from the sign-in page, after Sign out. */}
{me.google_enabled && (
<Pressable
onClick={() => {
window.location.href = "/auth/login";
}}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<ArrowLeftRight className="w-4 h-4" />
{t("header.account.signInAsOther")}
</Pressable>
)}
<Pressable
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</Pressable>
</div>
</PopoverSurface>
</div>
</div>
</NavRail>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { loadReleaseNotes, notesLang, visibleNotes } from "../lib/releaseNotes";
import { qk } from "../lib/queryKeys";
import { formatDate } from "../lib/format";
import { Button } from "../components/ui/Button";
import Modal from "../components/ui/Modal";
import { LoadingState, StateMessage } from "../components/ui/QueryState";
// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
// back to the raw string (a hand-written note could carry anything).
function fmtDate(d: string, lang: string): string {
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
}
// The newest releases are what a user opens this for; the whole history (150+ versions) rendered on
// every open was what made the dialog slow to appear. The rest are one click away.
const RECENT = 10;
// Public release notes in the UI language. `highlight` rings the version the user just updated to
// when opened from the new-version banner.
export default function ReleaseNotes({
onClose,
highlight,
}: {
onClose: () => void;
highlight?: string;
}) {
const { t, i18n } = useTranslation();
const lang = notesLang(i18n.language);
const q = useQuery({
queryKey: qk.releaseNotes(lang),
queryFn: () => loadReleaseNotes(lang),
staleTime: Infinity,
});
const [showAll, setShowAll] = useState(false);
const notes = visibleNotes(q.data ?? []);
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
// so the banner always rings *something* current instead of nothing.
const at = notes.findIndex((r) => r.version === highlight);
const active = at >= 0 ? highlight : notes[0]?.version;
const shown = showAll || at >= RECENT ? notes : notes.slice(0, RECENT);
return (
<Modal
initialFocus="close"
title={t("about.releaseNotes")}
onClose={onClose}
maxWidth="max-w-2xl"
>
{q.isError ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("about.notesError")}
</StateMessage>
) : !q.data ? (
<LoadingState />
) : (
<div className="flex flex-col gap-5">
{shown.map((r) => (
<section
key={r.version}
className={active === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
>
<div className="flex items-baseline gap-2">
<h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date, i18n.language)}</span>
</div>
<Items icon={<Sparkles className="w-3.5 h-3.5" />} title={t("about.new")} accent>
{r.new}
</Items>
<Items icon={<Bug className="w-3.5 h-3.5" />} title={t("about.fixes")}>
{r.fixed}
</Items>
</section>
))}
{shown.length < notes.length && (
<Button variant="outline" className="self-center" onClick={() => setShowAll(true)}>
{t("about.olderVersions", { count: notes.length - shown.length })}
</Button>
)}
</div>
)}
</Modal>
);
}
function Items({
icon,
title,
accent,
children,
}: {
icon: ReactNode;
title: string;
accent?: boolean;
children: string[];
}) {
if (children.length === 0) return null;
const tone = accent ? "text-accent" : "text-muted";
return (
<div className="mt-2.5">
<div
className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${tone}`}
>
{icon} {title}
</div>
<ul className="mt-1 space-y-1 text-sm">
{children.map((line, i) => (
<li key={i} className="flex gap-2">
<span className={tone}>•</span>
<span>{line}</span>
</li>
))}
</ul>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "../components/ui/LanguageSwitcher";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { SURFACE } from "../components/ui/surfaces";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
// posts it back as the X-Setup-Token header. On finish the instance flips to configured, the
// wizard disappears, and the operator signs in with the admin account they just created.
type StepId = "intro" | "admin" | "google" | "smtp" | "finish";
const PASSWORD_MIN = 10;
export default function SetupWizard() {
const { t, i18n } = useTranslation();
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
// than pushed into state by an effect, which had to run and commit before the error could show.
const [infoFailed, setInfoFailed] = useState(false);
const tokenError = !token || infoFailed;
useEffect(() => {
if (!token) return;
api
.setupInfo(token)
.then(setInfo)
.catch(() => setInfoFailed(true));
}, [token]);
// Step state.
const [stepIdx, setStepIdx] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
// and a set in one handler commit together, leaving the node untouched).
const [attempt, setAttempt] = useState(0);
// Admin account.
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// Google OAuth (optional).
const [gid, setGid] = useState("");
const [gsecret, setGsecret] = useState("");
// SMTP (optional).
const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", from: "", password: "" });
const [testTo, setTestTo] = useState("");
const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle");
if (tokenError)
return (
<Centered>
<TokenError />
</Centered>
);
if (!info)
return (
<Centered>
<Loader2 className="w-6 h-6 animate-spin text-muted" />
</Centered>
);
// Steps adapt to the instance: Google + SMTP need encrypted secret storage (TOKEN_ENCRYPTION_KEY).
const steps: StepId[] = [
"intro",
"admin",
...(info.secrets_manageable ? (["google", "smtp"] as StepId[]) : []),
"finish",
];
const step = steps[stepIdx];
const go = (delta: number) => {
setError("");
setStepIdx((i) => Math.min(steps.length - 1, Math.max(0, i + delta)));
};
// Each step's "Next" action persists what it collected, then advances. Optional steps with empty
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag); a step's own
// validation sets its message and stays.
async function next() {
setAttempt((n) => n + 1);
setError("");
setBusy(true);
try {
await saveStep();
} catch (e) {
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
}
setBusy(false);
}
// next()'s steps, apart so `busy` clears on every way out without a try/finally (the React
// Compiler cannot lower one yet, and skips the whole component).
async function saveStep() {
if (step === "admin") {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return setError(t("setup.admin.invalidEmail"));
if (password.length < PASSWORD_MIN)
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
await api.setupAdmin(token, email, password);
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
await api.setupConfig(token, {
google_client_id: gid.trim(),
google_client_secret: gsecret.trim(),
});
} else if (step === "smtp" && smtp.host.trim()) {
await api.setupConfig(token, {
smtp_host: smtp.host.trim(),
smtp_port: Number(smtp.port) || 587,
smtp_user: smtp.user.trim(),
smtp_from: smtp.from.trim(),
smtp_password: smtp.password,
});
} else if (step === "finish") {
await api.setupFinish(token);
window.location.href = "/"; // configured now → reloads into the login page
return;
}
go(1);
}
async function sendTest() {
setTestState("sending");
const port = Number(smtp.port) || 587;
try {
// Persist the SMTP fields first so the test uses them, then send.
if (smtp.host.trim())
await api.setupConfig(token, {
smtp_host: smtp.host.trim(),
smtp_port: port,
smtp_user: smtp.user.trim(),
smtp_from: smtp.from.trim(),
smtp_password: smtp.password,
});
await api.setupTestEmail(token, testTo.trim());
setTestState("ok");
} catch {
setTestState("fail");
}
}
return (
<Centered>
<div className="w-full max-w-lg">
<div className="flex items-center justify-between mb-4">
<div className="text-xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</div>
{/* Step progress dots */}
<div className="flex items-center gap-1.5 mb-4">
{steps.map((s, i) => (
<div
key={s}
className={`h-1.5 flex-1 rounded-full transition ${
i <= stepIdx ? "bg-accent" : "bg-border"
}`}
/>
))}
</div>
<div className={`${SURFACE.panel} p-6`}>
{step === "intro" && (
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
)}
{step === "admin" && (
<Section title={t("setup.admin.title")} desc={t("setup.admin.desc")}>
<Input
className="w-full"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("setup.admin.email")}
autoComplete="username"
/>
<Input
className="w-full"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("setup.admin.password", { n: PASSWORD_MIN })}
autoComplete="new-password"
/>
</Section>
)}
{step === "google" && (
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
<Input
className="w-full"
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={t("setup.google.clientId")}
/>
<Input
className="w-full"
value={gsecret}
onChange={(e) => setGsecret(e.target.value)}
placeholder={t("setup.google.clientSecret")}
/>
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
</Section>
)}
{step === "smtp" && (
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
<Input
className="w-full"
value={smtp.host}
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
placeholder={t("setup.smtp.host")}
/>
<div className="flex gap-2">
<Input
className="w-24"
value={smtp.port}
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
placeholder={t("setup.smtp.port")}
/>
<Input
className="w-full"
value={smtp.user}
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
placeholder={t("setup.smtp.user")}
/>
</div>
<Input
className="w-full"
value={smtp.from}
onChange={(e) => setSmtp({ ...smtp, from: e.target.value })}
placeholder={t("setup.smtp.from")}
/>
<Input
className="w-full"
type="password"
value={smtp.password}
onChange={(e) => setSmtp({ ...smtp, password: e.target.value })}
placeholder={t("setup.smtp.password")}
/>
{smtp.host.trim() && (
<div className="flex items-center gap-2 pt-1">
<Input
className="w-full"
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
placeholder={t("setup.smtp.testTo")}
/>
<Button
variant="glass"
size="md"
className="shrink-0"
type="button"
onClick={sendTest}
disabled={testState === "sending" || !testTo.trim()}
>
<Send
className={`w-4 h-4 ${testState === "sending" ? "motion-safe:animate-pulse" : ""}`}
/>
{t("setup.smtp.test")}
</Button>
</div>
)}
{testState === "ok" && (
<p className="text-xs text-success">{t("setup.smtp.testOk")}</p>
)}
{testState === "fail" && (
<p className="text-xs text-danger">{t("setup.smtp.testFail")}</p>
)}
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
</Section>
)}
{step === "finish" && (
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
)}
{error && (
<p key={attempt} role="alert" className="text-sm text-danger mt-3">
{error}
</p>
)}
<div className="flex items-center justify-between mt-6">
<Button
variant="glass"
icon={<ChevronLeft />}
onClick={() => go(-1)}
disabled={stepIdx === 0 || busy}
>
{t("setup.nav.back")}
</Button>
<Button
onClick={next}
loading={busy}
icon={step === "finish" ? <Check /> : undefined}
className="shadow-md shadow-accent/20"
>
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return <div className="min-h-dvh bg-bg text-fg grid place-items-center p-4">{children}</div>;
}
function Section({
title,
desc,
children,
}: {
title: string;
desc: string;
children?: React.ReactNode;
}) {
return (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted leading-relaxed mt-1">{desc}</p>
</div>
{children}
</div>
);
}
function TokenError() {
const { t } = useTranslation();
return (
<div className={`${SURFACE.panel} max-w-md p-6 text-center`}>
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import Banner from "../components/ui/Banner";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
// bar is only for the signed-in half.
//
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
// wrong-account case is why the address is named.
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
const { t } = useTranslation();
// A reset link opened here can only be ACTED on signed out, so say that rather than nothing —
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
// signing out from this same tab picks it up.
//
// Tested on `resetPending` ALONE, not `!outcome && resetPending`. The two can both be set (a
// reset link pasted over an unread confirmation, or a slow verify resolving after one), and this
// one goes first because it is the banner with something to DO. `dismiss` then peels it and the
// outcome shows underneath — where `!outcome &&` would have hidden this one until the other was
// dismissed, and dismissing that used to clear both.
if (entry.resetPending) {
return (
<Banner
tone="warning"
icon={AlertTriangle}
role="status"
onDismiss={entry.dismiss}
dismissTitle={t("header.banner.dismiss")}
>
{t("header.verify.resetSignedOut")}
</Banner>
);
}
if (!entry.outcome) return null;
const ok = entry.outcome === "ok" || entry.outcome === "ok_reset";
const text =
entry.outcome === "invalid"
? t("header.verify.invalid")
: entry.outcome === "unreachable"
? t("header.verify.unreachable")
: entry.email
? t("header.verify.confirmedNamed", { email: entry.email })
: t("header.verify.confirmed");
return (
<Banner
tone={ok ? "accent" : "warning"}
icon={ok ? CheckCircle2 : AlertTriangle}
role="status"
onDismiss={entry.dismiss}
dismissTitle={t("header.banner.dismiss")}
>
{text}
{entry.outcome === "ok_reset" && (
<span className="text-muted"> {t("header.verify.reset")}</span>
)}
</Banner>
);
}
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// The "updated" banner shows once per release for the ACCOUNT: the version it was last dismissed for
// is an account preference, so a banner dismissed on one device does not come back on the next. A
// version this browser kept before the server held it (a bare string) still counts.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("../lib/version", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/version")>()),
FRONTEND_VERSION: "1.2.3",
}));
import VersionBanner from "./VersionBanner";
import { api } from "../lib/api";
import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
let save: ReturnType<typeof vi.spyOn>;
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences });
render(
<QueryClientProvider client={qc}>
<VersionBanner onOpen={() => {}} />
</QueryClientProvider>,
);
}
const banner = () => screen.queryByText("header.banner.updated", { exact: false });
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the release banner", () => {
it("shows for a release the account has not seen", () => {
mount({ seenVersion: "1.2.2" });
expect(banner()).not.toBeNull();
});
it("stays hidden where the account dismissed this release, on another device", () => {
mount({ seenVersion: "1.2.3" });
expect(banner()).toBeNull();
});
it("takes the version this browser kept, and saves it to the account", () => {
localStorage.setItem(LS.seenVersion, "1.2.3");
mount({});
expect(banner()).toBeNull();
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
});
it("saves a dismissal to the account", () => {
mount({});
act(() => screen.getByTitle("header.banner.dismiss").click());
expect(banner()).toBeNull();
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
});
});
+35
View File
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Sparkles } from "lucide-react";
import { FRONTEND_VERSION, SEEN_VERSION_OPTIONS, SEEN_VERSION_PREF } from "../lib/version";
import { usePref } from "../lib/usePref";
import Banner from "../components/ui/Banner";
// Eye-catching, dismissible bar shown once after the running build's version changes
// (compares the baked frontend version to the last one the user acknowledged).
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslation();
const [seen, setSeen] = usePref(SEEN_VERSION_PREF, null, SEEN_VERSION_OPTIONS);
// Don't nag in un-stamped dev builds, or once this version has been seen.
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION;
if (!show) return null;
const markSeen = () => setSeen(FRONTEND_VERSION);
return (
<Banner
tone="accent"
icon={Sparkles}
action={{
label: t("header.banner.releaseNotes"),
onClick: () => {
onOpen();
markSeen();
},
}}
onDismiss={markSeen}
dismissTitle={t("header.banner.dismiss")}
>
{t("header.banner.updated", { version: FRONTEND_VERSION })}
</Banner>
);
}
+881
View File
@@ -0,0 +1,881 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useHistorySubview } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Check,
Clapperboard,
Clock,
Code2,
FolderOpen,
Expand,
Filter,
Inbox,
Link2Off,
ListVideo,
Lock,
MailWarning,
PlayCircle,
Search,
ShieldAlert,
X,
type LucideIcon,
} from "lucide-react";
// Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "../components/ui/LanguageSwitcher";
import { Button } from "../components/ui/Button";
import { trapTab, useDialogChrome } from "../components/ui/dialogFocus";
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../lib/layerStack";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
import { MODE_URL, type Mode } from "./welcomeMode";
import { IconButton } from "../components/ui/IconButton";
import { Pressable } from "../components/ui/Pressable";
import { Input } from "../components/ui/Input";
import { SURFACE } from "../components/ui/surfaces";
import { LinkButton } from "../components/ui/LinkButton";
import RemoteImage from "../components/ui/RemoteImage";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
// keeps adding a new mode a one-line change and makes a missing key a compile error.
const TITLE_KEY: Record<Mode, string> = {
signin: "welcome.auth.signinTitle",
register: "welcome.auth.createTitle",
forgot: "welcome.auth.forgotTitle",
reset: "welcome.auth.resetTitle",
resend: "welcome.auth.resendTitle",
};
const SUBMIT_KEY: Record<Mode, string> = {
signin: "welcome.auth.signinButton",
register: "welcome.auth.createButton",
forgot: "welcome.auth.forgotButton",
reset: "welcome.auth.resetButton",
resend: "welcome.auth.resendButton",
};
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
// password, Google) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome({ verify }: { verify: VerifyEntry }) {
const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
const open = (src: string, label: string) => setZoomed({ src, label });
return (
<div className="min-h-dvh bg-bg text-fg overflow-y-auto">
<header className="flex items-center justify-between px-5 sm:px-8 py-4">
<div className="text-xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</header>
<main className="max-w-6xl mx-auto px-5 sm:px-8 pb-16">
{/* Hero: pitch + auth card */}
<section className="grid lg:grid-cols-2 gap-10 items-center py-8 lg:py-14">
<div className="max-w-xl">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-tight">
{t("welcome.hero.title")}
</h1>
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
</div>
<AuthCard verify={verify} />
</section>
{/* App preview */}
<Preview
src="/welcome/feed.webp"
label={t("welcome.preview.feed")}
className="aspect-video"
onOpen={open}
/>
{/* Open source — Siftlode's code is public; nudge visitors to read it or self-host. The
footer keeps a compact link too. */}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/6 px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
>
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
<Code2 className="w-5 h-5" />
</span>
<span className="flex-1">
<span className="block font-semibold">{t("welcome.openSource.title")}</span>
<span className="block text-sm text-muted leading-relaxed">
{t("welcome.openSource.body")}
</span>
</span>
<span className="inline-flex items-center gap-1 text-sm font-medium text-accent whitespace-nowrap">
{t("welcome.openSource.link")}
<ArrowRight className="w-4 h-4 transition group-hover:translate-x-0.5" />
</span>
</a>
{/* Features */}
<section className="mt-14">
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{FEATURES.map((f) => (
<FeatureCard key={f.key} icon={f.icon} k={f.key} />
))}
</div>
</section>
{/* Gallery — one shot per module; click any to view it full size. */}
<section className="mt-12">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{GALLERY.map((g) => (
<Preview
key={g.key}
src={g.src}
label={t(`welcome.preview.${g.key}`)}
className="aspect-video"
onOpen={open}
/>
))}
</div>
</section>
</main>
{zoomed && <Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />}
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
<span>
Sift<span className="text-accent">lode</span>
</span>
<span className="hidden sm:inline">·</span>
<a href="/privacy" className="hover:text-fg transition">
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
{t("common.termsOfService")}
</a>
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 hover:text-fg transition"
>
<Code2 className="w-3.5 h-3.5" />
{t("common.sourceCode")}
</a>
</footer>
</div>
);
}
// The screenshot gallery below the hero — one shot per module, each opening full-size in the
// lightbox. Order roughly follows the nav rail; the feed leads as the hero above the fold.
const GALLERY: { key: string; src: string }[] = [
{ key: "channels", src: "/welcome/channels.webp" },
{ key: "playlists", src: "/welcome/playlists.webp" },
{ key: "search", src: "/welcome/search.webp" },
{ key: "player", src: "/welcome/player.webp" },
{ key: "files", src: "/welcome/files.webp" },
{ key: "plex", src: "/welcome/plex.webp" },
];
const FEATURES: { key: string; icon: LucideIcon }[] = [
{ key: "readable", icon: Filter },
{ key: "neverMiss", icon: Inbox },
{ key: "search", icon: Search },
{ key: "playlists", icon: ListVideo },
{ key: "player", icon: PlayCircle },
{ key: "files", icon: FolderOpen },
{ key: "plex", icon: Clapperboard },
{ key: "private", icon: Lock },
];
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
const { t } = useTranslation();
return (
<div className={`${SURFACE.panel} p-5`}>
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
<Icon className="w-5 h-5" />
</div>
<h3 className="font-semibold">{t(`welcome.features.${k}.title`)}</h3>
<p className="text-sm text-muted leading-relaxed mt-1">{t(`welcome.features.${k}.body`)}</p>
</div>
);
}
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the screenshots drop into frontend/public/welcome/).
// When loaded it's a button that opens the full-size lightbox (onOpen).
function Preview({
src,
label,
className,
onOpen,
}: {
src: string;
label: string;
className?: string;
onOpen?: (src: string, label: string) => void;
}) {
return (
<Pressable
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<RemoteImage
src={src}
alt={label}
loading="lazy"
className="w-full h-full object-cover object-top"
fallback={<ShotLabel label={label} />}
/>
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
</span>
</Pressable>
);
}
// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark.
function ShotLabel({ label }: { label: string }) {
return (
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label}
</div>
);
}
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
const { t } = useTranslation();
// An `aria-modal` shell that the focus extraction did not sweep in: it declared itself a
// modal dialog while leaving focus on the thumbnail behind an 80%-black scrim, announcing nothing,
// and letting Tab walk the landing page underneath — the ✕ reachable only after tabbing past every
// remaining gallery shot. The contract comes from the same place `ui/Modal`
// and the wizard get theirs; the body-scroll lock moved with it, and a later fix added the layer
// membership below.
//
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
// joined last, so all four (`ui/Modal`, the wizard, this, the player) now share
// `useDialogChrome` and its one counted scroll lock.
const panelRef = useRef<HTMLDivElement>(null);
useDialogChrome(panelRef);
// An effect event, so the stack id is assigned once per mount (as in ui/Modal).
const close = useEffectEvent(onClose);
useEffect(() => {
// One fix gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three
// have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That
// is the shape the wizard's own comment records being bitten by — "one Esc closed the error AND
// ended onboarding" — and it is unreachable today only because `App` returns `<Welcome/>` before
// it mounts `ErrorDialog` and the Toaster, i.e. by an ordering nothing enforces. Joining the
// stack costs two lines and removes the trap-fights-trap failure mode from the next layer the
// landing page grows.
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) {
e.stopPropagation();
close();
return;
}
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
const panel = panelRef.current;
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
removeLayer(id);
};
}, []);
return (
<div
ref={panelRef}
tabIndex={-1}
className="fixed inset-0 z-popover grid place-items-center bg-black/80 overlay-backdrop p-4 sm:p-8 outline-none"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={label}
>
<IconButton
variant="media"
size="md"
onClick={onClose}
label={t("common.close")}
className="absolute top-4 right-4"
>
<X />
</IconButton>
<figure
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<RemoteImage
src={src}
alt={label}
className="max-h-[82dvh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
/>
<figcaption className="text-sm text-muted">{label}</figcaption>
</figure>
</div>
);
}
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
// in-flight promise means a remount joins the request already running instead of starting another.
const resetTokenChecks = new Map<string, Promise<boolean>>();
function checkResetToken(token: string): Promise<boolean> {
let pending = resetTokenChecks.get(token);
if (!pending) {
pending = api
.checkPasswordReset(token)
.then((r) => r.ok)
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
// itself: a network blip must never be cached as "this link is dead".
.catch(() => {
resetTokenChecks.delete(token);
return true;
});
resetTokenChecks.set(token, pending);
}
return pending;
}
function AuthCard({ verify }: { verify: VerifyEntry }) {
const { t } = useTranslation();
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
//
// State, not a const: once the token has been spent the reset view must stop being the card's
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
// explaining; the same spelling twice is just a repeat.
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
const bouncedEmail = entryHash.get("email");
const bouncedLinked = entryHash.get("linked");
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
// one emailed link behaves the same whether or not this browser already holds a session.
const verifyOutcome = verify.outcome;
const { view: mode, open: setMode } = useHistorySubview<Mode>(
resetToken ? "reset" : "signin",
MODE_URL,
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
// and a set in one handler commit together, leaving the node untouched).
const [attempt, setAttempt] = useState(0);
// Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth
// configured. Optimistic (most instances have it); the rare Google-less instance just sees it
// disappear once the config loads.
const [googleEnabled, setGoogleEnabled] = useState(true);
useEffect(() => {
api
.authConfig()
.then((c) => setGoogleEnabled(c.google_enabled))
.catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Why password sign-in refused, when the server named a state worth a screen rather than a red line.
// It REPLACES the form: in all three of those states the password was already proven
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
const [deadReset, setDeadReset] = useState(false);
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
useEffect(() => {
const token = entryHash.get("reset");
if (!token) return;
let live = true;
void checkResetToken(token).then((ok) => {
if (!live) return;
if (!ok) {
setDeadReset(true);
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
}
setCheckingReset(false);
});
return () => {
live = false;
};
}, []);
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
// Cleared during the render that changes the view, not a commit later: the render is
// `done ? msg : form`, so an effect meant Back out of a submitted form drew the old success
// message under the new view's title for one frame — which is the exact symptom this guards.
const [outcomeFor, setOutcomeFor] = useState(mode);
if (outcomeFor !== mode) {
setOutcomeFor(mode);
setDone("");
setError("");
setBlocked(null);
setDeadReset(false);
}
const reset = () => {
setError("");
setDone("");
setBlocked(null);
setDeadReset(false);
// The verify/login banners are one-time entry notices — dismiss once the user takes any
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
verify.dismiss();
setLogin(null);
};
// Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link.
const goResend = () => {
reset();
setMode("resend");
};
// Used by the "verified, but your password was revoked" banner — the only way back into that
// account is a reset link, so put the user one click from it.
const goForgot = () => {
reset();
setMode("forgot");
};
async function onSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setAttempt((n) => n + 1);
reset();
const mail = email.trim().toLowerCase();
if (
(mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") &&
!EMAIL_RE.test(mail)
) {
setError(t("welcome.auth.invalidEmail"));
return;
}
if ((mode === "register" || mode === "reset") && password.length < PASSWORD_MIN) {
setError(t("welcome.auth.weakPassword", { min: PASSWORD_MIN }));
return;
}
setBusy(true);
const resetWith = resetToken ?? "";
try {
if (mode === "signin") {
await api.passwordLogin(mail, password);
window.location.reload();
} else if (mode === "register") {
await api.register(mail, password);
setDone(t("welcome.auth.registerDone"));
} else if (mode === "forgot") {
await api.requestPasswordReset(mail);
setDone(t("welcome.auth.forgotDone"));
} else if (mode === "resend") {
await api.resendVerification(mail);
setDone(t("welcome.auth.resendDone"));
} else if (mode === "reset") {
await api.confirmPasswordReset(resetWith, password);
setResetToken(null); // single-use: the reset view is no longer a place to come back to
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
// under the form, because there the form IS the next step.
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
if (refusal && PANEL_REASONS.has(refusal.reason)) {
setBlocked(refusal);
} else if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
// The token died between the pre-flight check and this submit — or was never checked,
// because the probe itself failed. Either way the answer is the same screen, not a red line.
setDeadReset(true);
setResetToken(null);
} else {
setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
);
}
}
setBusy(false);
}
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
// than a box sitting inside a heading that still says "Set a new password".
const titleKey = blocked
? `welcome.auth.blocked.${blocked.reason}.title`
: deadReset
? "welcome.auth.deadLink.title"
: TITLE_KEY[mode];
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
return (
<div className={`${SURFACE.panel} p-6 sm:p-7 w-full max-w-md lg:justify-self-end`}>
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{StateIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
<StateIcon className="w-4.5 h-4.5" />
</span>
)}
{t(titleKey)}
</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verifyOutcome === "ok_reset" && (
<Banner tone="warn">
{t("welcome.auth.verifyOkReset")}{" "}
<Button variant="link" onClick={goForgot}>
{t("welcome.auth.verifyOkResetCta")}
</Button>
</Banner>
)}
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
could not reach the server — so pushing the user to request a fresh link would act on a
statement we know to be false (DX-16). Retrying the same link is the correct next move. */}
{verifyOutcome === "unreachable" && (
<Banner tone="warn">{t("welcome.auth.verifyUnreachable")}</Banner>
)}
{verifyOutcome === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<Button variant="link" onClick={goResend}>
{t("welcome.auth.resendCta")}
</Button>
</Banner>
)}
{bounced === "requested" &&
(bouncedEmail ? (
<Banner tone="ok">
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
<Address value={bouncedEmail} />
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
</Banner>
) : (
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
))}
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
mailbox whose request is already on file under another. Silence here is what let the
2026-09-03 registrant believe nothing had been recorded at all. */}
{bounced === "requested" && bouncedLinked && (
<Banner tone="warn">
<span className="block">{t("welcome.auth.accessLinked")}</span>
<Address value={bouncedLinked} />
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
</Banner>
)}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
<Banner tone="warn">{t("welcome.auth.oauthCancelled")}</Banner>
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{blocked ? (
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
) : deadReset ? (
<StatePanel
body={t("welcome.auth.deadLink.body")}
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
onBack={() => {
reset();
setMode("signin");
}}
/>
) : checkingReset ? (
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
// whole check exists to remove.
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
) : done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<Button
variant="link"
className="mt-4 text-sm"
onClick={() => {
setMode("signin");
setDone("");
}}
>
{t("welcome.auth.backToSignin")}
</Button>
</div>
) : (
<>
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
{mode !== "reset" && (
<Input
className="w-full"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("welcome.auth.email")}
/>
)}
{mode !== "forgot" && mode !== "resend" && (
<Input
className="w-full"
type="password"
autoComplete={mode === "signin" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={
mode === "reset" || mode === "register"
? t("welcome.auth.newPassword", { min: PASSWORD_MIN })
: t("welcome.auth.password")
}
/>
)}
{error && (
<p key={attempt} role="alert" className="text-xs text-danger">
{error}
</p>
)}
<Button type="submit" size="lg" disabled={busy}>
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
</Button>
</form>
{/* A confirmation mail can be slow, filtered, or — in older builds — never sent at all for a
repeat sign-up. Offered right under the form rather than below the mode switches: it is
the second thing a stuck person needs, after the password they just tried. */}
{mode === "signin" && (
<Button variant="link-muted" fullWidth className="mt-3 text-xs" onClick={goResend}>
{t("welcome.auth.resendLink")}
</Button>
)}
{/* Mode switches */}
<div className="flex items-center justify-between mt-3 text-xs">
{mode === "signin" ? (
<>
<Button
variant="link"
onClick={() => {
reset();
setMode("register");
}}
>
{t("welcome.auth.createLink")}
</Button>
<Button
variant="link-muted"
onClick={() => {
reset();
setMode("forgot");
}}
>
{t("welcome.auth.forgotLink")}
</Button>
</>
) : (
<Button
variant="link"
onClick={() => {
reset();
setMode("signin");
}}
>
{t("welcome.auth.backToSignin")}
</Button>
)}
</div>
{/* The divider separates two ways in, so it goes with the second one: on an instance with
no Google sign-in there is nothing to say "or" to. */}
{mode === "signin" && googleEnabled && (
<>
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")}
<span className="h-px flex-1 bg-border" />
</div>
<LinkButton href="/auth/login" size="lg" fullWidth>
{t("welcome.auth.google")}
</LinkButton>
</>
)}
</>
)}
</div>
);
}
// An email address the user has to READ and compare, character by character — the recorded address on
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
// noticed on 2026-09-03.
function Address({ value }: { value: string }) {
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
}
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
// line under it is the honest size for them.
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
]);
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
email_unverified: MailWarning,
pending_approval: Clock,
suspended: ShieldAlert,
};
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
// should not drift apart in shape.
function StatePanel({
body,
cta,
onBack,
children,
}: {
body: string;
cta?: { label: string; onClick: () => void };
onBack: () => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
// live region a screen reader reports nothing at all, and without the focus move the button they
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
// half — it is the whole answer to a submit — so it announces assertively.
const heading = useRef<HTMLParagraphElement>(null);
useEffect(() => heading.current?.focus(), []);
return (
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
<p
ref={heading}
tabIndex={-1}
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
>
{body}
</p>
{children}
<div className="flex items-center justify-between gap-3">
<Button variant="link" className="text-sm" onClick={onBack}>
{t("welcome.auth.backToSignin")}
</Button>
{cta && (
<Button variant="outline" size="sm" onClick={cta.onClick}>
{cta.label}
</Button>
)}
</div>
</div>
);
}
// The named state behind a 403, in place of the sign-in form. It says WHICH mailbox the
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
// string it replaces was true and useless: it never revealed that the address the account carried was
// a different spelling of the one the user had just typed.
function BlockedPanel({
blocked,
onResend,
onBack,
}: {
blocked: LoginBlocked;
onResend: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const { reason, email, emailVerified, approved, operator } = blocked;
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
const gated = reason !== "suspended";
return (
<StatePanel
body={t(`welcome.auth.blocked.${reason}.body`)}
cta={
reason === "email_unverified"
? { label: t("welcome.auth.resendCta"), onClick: onResend }
: undefined
}
onBack={onBack}
>
{gated && (
<div className={`${SURFACE.card} px-3.5 py-3 flex flex-col gap-2`}>
<Gate done={emailVerified === true} k="email" />
<Gate done={approved === true} k="approval" />
</div>
)}
{email && (
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
<span className="text-2xs uppercase tracking-wide text-muted">
{t("welcome.auth.blocked.addressLabel")}
</span>
<span className="text-sm">
<Address value={email} />
</span>
</div>
)}
{operator && (
<p className="text-xs text-muted leading-relaxed">
{t("welcome.auth.blocked.operator", { contact: operator })}
</p>
)}
</StatePanel>
);
}
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
// label plus a status word: Hungarian puts them together differently, and gluing translated
// fragments is how a bilingual UI ends up reading like neither language.
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
const { t } = useTranslation();
const Icon = done ? Check : Clock;
return (
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
</span>
);
}
// `role="status"` because these appear in response to something that just happened (a verification
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
// without it a screen-reader user gets no announcement that anything changed.
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
return (
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
{children}
</div>
);
}
@@ -0,0 +1,283 @@
// @vitest-environment jsdom
//
// The honest states of the sign-in card. Two things are under test and they share a root:
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
// the funnel stayed silent about it.
//
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const noVerify: VerifyEntry = {
outcome: null,
email: null,
resetPending: false,
dismiss: () => {},
};
// Every case re-imports the whole Welcome tree after a module reset. The FIRST import also transforms
// it, which under the full suite's parallel load ran past the 5 s test timeout (a gate flake).
// Transforms survive resetModules, so warming them once here leaves each case only the evaluation.
beforeAll(async () => {
await import("./Welcome");
}, 60_000);
// Warm or not, each case still loads and evaluates the whole Welcome tree afresh after its module
// reset: ~0.1 s idle, past 5 s when the full suite's workers share a busy machine (seen at 5.01 s).
const CASE_BUDGET = 30_000;
afterEach(() => {
cleanup();
vi.resetModules();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
window.history.replaceState(null, "", url);
vi.resetModules();
vi.doMock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
i18n: { language: "en" },
}),
};
});
vi.doMock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
authConfig: () => Promise.resolve({ google_enabled: false }),
...apiOverrides,
},
};
});
const { HttpError } = await import("../lib/api");
const { default: Welcome } = await import("./Welcome");
await act(async () => {
render(<Welcome verify={noVerify} />);
});
return { HttpError, Welcome };
}
/** Fill the sign-in form and submit it. */
async function signIn(email = "someone@example.com") {
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
target: { value: "correct horse battery" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
});
}
/** A password-login refusal carrying the server's structured detail. */
function refusal(
HttpError: typeof import("../lib/api").HttpError,
detail: Record<string, unknown>,
) {
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
}
describe("a refused sign-in becomes a named state", { timeout: CASE_BUDGET }, () => {
it("names the address the account is on, not the one that was typed", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "pending_approval",
email: "np.temu@gmail.com",
email_verified: true,
approved: false,
}),
);
await signIn("nptemu@gmail.com");
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
// The password was already proven correct, so the form is not the next step and must be gone —
// leaving it up would only invite a retry that cannot succeed.
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
});
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "email_unverified",
email: "approved.first@example.com",
email_verified: false,
approved: true,
}),
);
await signIn("approved.first@example.com");
// The two gates are order-independent; a screen that assumed a sequence would send this
// user off to wait for an admin who has already decided.
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
});
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "suspended",
email: "banned@example.com",
operator: "ops@example.com",
}),
);
await signIn("banned@example.com");
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
});
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(() =>
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
);
await signIn();
expect(screen.getByText("localized invalid")).toBeTruthy();
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
});
});
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
// password" form purely because a `#reset=` fragment was present, and only told the user after they
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
// whoever got two mails clicks the older one.
describe("an emailed reset link that is already finished", { timeout: CASE_BUDGET }, () => {
it("refuses to draw a form that cannot succeed", async () => {
await mountCard("/#reset=dead-token", {
checkPasswordReset: () => Promise.resolve({ ok: false }),
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
});
it("draws the form when the link is still good", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("takes the form down if the link dies between the check and the submit", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=racing-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "brand new password" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
});
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "0123456789ab" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("probes a token once, however many times the card mounts", async () => {
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
await act(async () => {
render(<Welcome verify={noVerify} />);
});
// StrictMode double-invokes effects and the shell remounts this card as the session probe
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
// and silently drop back to drawing the form unchecked.
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
});
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.reject(new Error("network")),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
});
});
describe("the access-request notice from a denied Google sign-in", { timeout: CASE_BUDGET }, () => {
it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com");
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
});
it("explains the alias when the request is already on file under another spelling", async () => {
await mountCard(
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
);
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
// address family this epic exists to protect.
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
});
it("falls back to the unnamed notice when the server sent no address", async () => {
await mountCard("/#access=requested");
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
});
});
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// What the user was SHOWN — a toast, the blocking error dialog, the crash screen — and what
// failed behind the scenes (a query, a mutation) all land in the client log. Each producer is its
// own module, so each is asserted here through the real thing, not a mock of it.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { clear, events } from "../lib/log";
import { notify } from "../lib/notifications";
import { reportError } from "../lib/errorDialog";
import { createQueryClient } from "../lib/queryClient";
import ErrorBoundary from "./ErrorBoundary";
import { HttpError } from "../lib/api/core";
afterEach(() => {
cleanup();
clear();
vi.restoreAllMocks();
});
const logged = (code: string) => events().filter((e) => e.event === code);
describe("client log wiring", () => {
it("records every notification at its level", () => {
notify({ level: "success", title: "Saved", message: "Playlist renamed" });
notify({ level: "error", message: "Could not reach someone@mail.example" });
expect(logged("ui.notify").map((e) => [e.level, e.msg, e.attrs])).toEqual([
["INFO", "Playlist renamed", { level: "success", title: "Saved" }],
["ERROR", "Could not reach [email]", { level: "error" }],
]);
});
it("records the blocking error dialog", () => {
reportError("The server refused", "Couldn't save");
expect(logged("ui.error_dialog")).toMatchObject([
{ level: "ERROR", msg: "The server refused", attrs: { title: "Couldn't save" } },
]);
});
it("records a failed query by its resource only — the rest of the key is filter state", async () => {
const client = createQueryClient();
await client
.fetchQuery({
queryKey: ["videos", { tags: ["private-tag"] }],
queryFn: () => Promise.reject(new HttpError(503, "down")),
})
.catch(() => {});
expect(logged("query.failed")).toMatchObject([
{ level: "WARNING", attrs: { key: "videos", status: 503, message: "down" } },
]);
expect(JSON.stringify(events())).not.toContain("private-tag");
});
it("records a failed mutation", async () => {
const client = createQueryClient();
const mutation = client.getMutationCache().build(client, {
mutationKey: ["playlists", 42],
mutationFn: () => Promise.reject(new HttpError(409, "conflict")),
meta: { errorShownInline: true },
});
await mutation.execute(undefined).catch(() => {});
expect(logged("mutation.failed")).toMatchObject([
{ level: "WARNING", attrs: { key: "playlists", status: 409 } },
]);
});
it("records a render crash with its component stack", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the crash itself too
const Boom = () => {
throw new Error("render exploded");
};
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>,
);
const [crash] = logged("ui.crash");
expect(crash).toMatchObject({ level: "CRITICAL", attrs: { message: "render exploded" } });
expect(String(crash!.attrs!.component_stack)).toContain("Boom");
});
});
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
//
// A form's error is announced (role="alert"), and failing the same way again must be announced again. The
// clear and the set of a validation failure commit together, so the alert node used to stay untouched on
// the second attempt and a screen reader said nothing — a press that seemed to do nothing.
//
// i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
authConfig: () => Promise.resolve({ google_enabled: false }),
me: () => Promise.resolve({ authenticated: false }),
setupInfo: () => Promise.resolve({ secrets_manageable: false }),
},
};
});
import SetupWizard from "./SetupWizard";
import Welcome from "./Welcome";
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
// Fails twice the same way; the second alert must be a new node carrying the same message.
async function failTwice(fail: () => void, message: string) {
fail();
const first = await screen.findByRole("alert");
expect(first.textContent).toBe(message);
// The wizard's Next is busy until its failed step returns (a tick after the message shows), and a
// busy button ignores a press: press again only once nothing on the form says it is working.
await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeNull());
fail();
const second = screen.getByRole("alert");
expect(second.textContent).toBe(message);
expect(second).not.toBe(first);
}
describe("a form failing the same way twice announces it twice", () => {
it("the sign-in card", async () => {
render(
<Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />,
);
const email = await screen.findByPlaceholderText("welcome.auth.email");
fireEvent.change(email, { target: { value: "not-an-address" } });
await failTwice(() => fireEvent.submit(email.closest("form")!), "welcome.auth.invalidEmail");
});
it("the install wizard, with the step's own message rather than the generic one", async () => {
window.history.replaceState(null, "", "/?token=setup-token");
render(<SetupWizard />);
fireEvent.click(await screen.findByText("setup.nav.next")); // past the intro
fireEvent.change(await screen.findByPlaceholderText("setup.admin.email"), {
target: { value: "not-an-address" },
});
await failTwice(
() => fireEvent.click(screen.getByText("setup.nav.next")),
"setup.admin.invalidEmail",
);
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { ComponentType } from "react";
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import Sidebar from "../modules/feed/Sidebar";
import PlaylistsRail from "../modules/playlists/PlaylistsRail";
import { Feed, FeedPage } from "../modules/feed/modulePages";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
// `page === X` chrome conditionals.
//
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page
// threads its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
const Home = lazyWithPreload(() => import("../modules/home/Home"));
const Channels = lazyWithPreload(() => import("../modules/channels/Channels"));
const Playlists = lazyWithPreload(() => import("../modules/playlists/Playlists"));
const PlexBrowse = lazyWithPreload(() => import("../modules/plex/PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("../modules/plex/PlexSidebar"));
const NotificationsPanel = lazyWithPreload(
() => import("../modules/notifications/NotificationsPanel"),
);
const Files = lazyWithPreload(() => import("../modules/files/Files"));
const FilesRail = lazyWithPreload(() => import("../modules/files/FilesRail"));
const Stats = lazyWithPreload(() => import("../modules/stats/Stats"));
const Scheduler = lazyWithPreload(() => import("../modules/scheduler/Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("../modules/config/ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("../modules/users/AdminUsers"));
const Activity = lazyWithPreload(() => import("../modules/activity/Activity"));
const SettingsPanel = lazyWithPreload(() => import("../modules/settings/SettingsPanel"));
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
// a new page cannot silently opt out of preloadPages.
Component: PreloadableComponent<object>;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
// The two wrappers preload the lazy chunk they render.
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
channels: { Component: Channels, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
files: { Component: Files, maxWidth: "80rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
activity: { Component: Activity },
settings: { Component: SettingsPanel },
};
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
// its provider and `me` from useMe().
type RailProps = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
// have their own).
type RailCollapseKey = "filter" | "playlists" | "files";
export type RailDef = {
Rail: ComponentType<RailProps>;
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
panelId: PanelId;
collapse: RailCollapseKey;
};
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
export function preloadPages(pages: Page[]): void {
for (const p of pages) {
PAGE_CONTENT[p].Component.preload().catch(() => {});
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
rail?.preload?.().catch(() => {});
}
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
// before the card and the address bar moved while the form on screen did not. They now ride
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
// are mirrored, and that Back walks the views. The hook's own mechanics live in
// history.subview.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview } from "../lib/history";
import { MODE_URL, type Mode } from "./welcomeMode";
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
const popTo = (search: string, state: unknown = null) =>
act(() => {
window.history.replaceState(state, "", search || "/");
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
// jsdom keeps one history across a file; land on a clean root entry before each test.
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => window.history.replaceState(null, "", "/"));
describe("login card mode ↔ URL", () => {
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
expect(MODE_URL.encode("signin")).toBeNull();
expect(MODE_URL.encode("register")).toBe("register");
expect(MODE_URL.encode("forgot")).toBe("forgot");
expect(MODE_URL.encode("resend")).toBe("resend");
});
it("never mirrors the reset view", () => {
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
// hand out a bookmark that opens a password form with no token behind it.
expect(MODE_URL.encode("reset")).toBeNull();
expect(MODE_URL.decode("reset")).toBeNull();
});
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
expect(MODE_URL.decode("register")).toBe("register");
expect(MODE_URL.decode("")).toBeNull();
expect(MODE_URL.decode("nonsense")).toBeNull();
});
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
const { result } = render();
const entries = window.history.length;
expect(result.current.view).toBe("signin");
act(() => result.current.open("forgot"));
// The whole point: a Back has somewhere to go. Before this the click changed state only.
expect(window.history.length).toBe(entries + 1);
expect(result.current.view).toBe("forgot");
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("walks back one view at a time when the user hops between them", () => {
const { result } = render();
const entries = window.history.length;
act(() => result.current.open("register"));
act(() => result.current.open("forgot"));
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
popTo("/?m=register", { _sub: "register" });
expect(result.current.view).toBe("register");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
const { result } = render("reset");
expect(result.current.view).toBe("reset");
expect(window.location.search).toBe("");
});
it("restores a bookmarked view from the URL alone", () => {
window.history.replaceState(null, "", "/?m=resend");
const { result } = render();
expect(result.current.view).toBe("resend");
});
});
@@ -0,0 +1,206 @@
// @vitest-environment jsdom
//
// The sign-in card's sub-views as the user meets them: clicking a link must leave a history entry,
// so Back — including the mouse's back button — returns to the previous view. They used to be plain
// React state, and Back moved only the address bar while the form on screen stayed put. The
// encode/decode table lives in welcomeMode.dom.test.ts; this file renders the real card, so a revert
// to useState fails here.
//
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
// the card probes /auth/config on mount and must not reach the network.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }) },
};
});
import Welcome from "./Welcome";
import { pushLayer, removeLayer } from "../lib/layerStack";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// App confirms the emailed token and hands the outcome down; these tests are about the card's
// sub-views, so there is nothing to report.
const noVerify: VerifyEntry = {
outcome: null,
email: null,
resetPending: false,
dismiss: () => {},
};
const back = () =>
act(() => {
// jsdom's history.back() is async and does not rewind location, so land on the previous entry
// the way the browser would and fire popstate. The push itself is asserted via history.length.
window.history.replaceState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
});
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("sign-in card sub-views and the Back button", () => {
it("opens Forgot password as a history entry and comes back to sign in", async () => {
render(<Welcome verify={noVerify} />);
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
const entries = window.history.length;
fireEvent.click(screen.getByText("welcome.auth.forgotLink"));
expect(screen.getByText("welcome.auth.forgotTitle")).toBeTruthy();
expect(window.history.length).toBe(entries + 1); // a Back now has somewhere to go
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
expect(screen.queryByText("welcome.auth.forgotTitle")).toBeNull();
});
it("does the same for Create an account and the verification-email link", async () => {
render(<Welcome verify={noVerify} />);
await screen.findByText("welcome.auth.signinTitle");
fireEvent.click(screen.getByText("welcome.auth.createLink"));
expect(screen.getByText("welcome.auth.createTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
fireEvent.click(screen.getByText("welcome.auth.resendLink"));
expect(screen.getByText("welcome.auth.resendTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
});
});
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
// screen that said nothing at all.
describe("the verification outcome handed down from App", () => {
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
outcome,
email: "someone@example.com",
resetPending: false,
dismiss: () => {},
});
it("announces a confirmed address", async () => {
render(<Welcome verify={withOutcome("ok")} />);
const banner = await screen.findByText("welcome.auth.verifyOk");
expect(banner.closest('[role="status"]')).toBeTruthy();
});
it("offers the reset flow when the account's password had been revoked", async () => {
render(<Welcome verify={withOutcome("ok_reset")} />);
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
});
it("offers a new link when the token is spent", async () => {
render(<Welcome verify={withOutcome("invalid")} />);
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
});
// DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link —
// that would act on a statement we know to be false. Retrying the same link is the way out.
it("does not offer a new link when the server was merely unreachable", async () => {
render(<Welcome verify={withOutcome("unreachable")} />);
expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy();
expect(screen.queryByText("welcome.auth.resendCta")).toBeNull();
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
});
it("shows no banner when there was no token to confirm", async () => {
render(<Welcome verify={noVerify} />);
await screen.findByText("welcome.auth.signinTitle");
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
});
});
describe("the screenshot lightbox", () => {
// The fourth `aria-modal` surface in the app, and the one the focus extraction did not
// sweep in: it declared itself a modal dialog while leaving focus on the thumbnail behind an
// 80%-black scrim and letting Tab walk the landing page underneath.
let origDesc: PropertyDescriptor | undefined;
beforeEach(() => {
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get(this: HTMLElement) {
return this.isConnected ? document.body : null;
},
});
});
afterEach(() => {
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
});
const openIt = async () => {
render(<Welcome verify={noVerify} />);
const shot = (await screen.findAllByLabelText("welcome.preview.feed"))[0]!;
shot.focus();
fireEvent.click(shot);
return { shot, panel: await screen.findByRole("dialog") };
};
it("opens focused inside itself and holds the Tab instead of letting it walk the page", async () => {
const { panel } = await openIt();
expect(panel.getAttribute("aria-modal")).toBe("true");
expect(panel.contains(document.activeElement)).toBe(true);
// Assert the trap ACTED (the keydown was cancelled), not where focus ended up: jsdom never
// performs the browser's default Tab traversal, so "focus is still inside" is true with no trap
// at all — a green assertion about nothing (the mutation check caught exactly that).
screen.getByLabelText("common.close").focus();
const notCancelled = fireEvent.keyDown(window, { key: "Tab", cancelable: true });
expect(notCancelled).toBe(false);
});
it("hands the focus back to the thumbnail that opened it", async () => {
const { shot } = await openIt();
fireEvent.keyDown(window, { key: "Escape" });
await waitFor(() => expect(document.activeElement).toBe(shot));
});
it("defers its Escape and its Tab to a layer opened above it", async () => {
// One fix gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other
// three have, so both fired from a raw window listener no matter what was on top. That is the
// shape the wizard's own comment records being bitten by ("one Esc closed the error AND ended
// onboarding"), and it was unreachable here only because `App` renders `<Welcome/>` before it
// mounts `ErrorDialog` and the Toaster — an ordering nothing enforces.
const { panel } = await openIt();
const above = pushLayer(); // whatever the landing page grows next
try {
fireEvent.keyDown(window, { key: "Escape" });
// Still open: the layer above owns that Escape.
expect(screen.queryByRole("dialog")).toBe(panel);
// And it must not fight the layer above for the Tab either — same reasoning as the test
// above, so assert the keydown was NOT cancelled rather than where the focus went.
screen.getByLabelText("common.close").focus();
expect(fireEvent.keyDown(window, { key: "Tab", cancelable: true })).toBe(true);
} finally {
removeLayer(above);
}
// With the layer above gone it is topmost again and closes normally.
fireEvent.keyDown(window, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { SubviewUrl } from "../lib/history";
// The sign-in card's sub-view type and its URL mirror, split out of Welcome so that file exports only
// components (Fast Refresh).
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
// any other page — they used to be plain state, and Back silently changed only the address bar.
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
// form that cannot submit.
export const MODE_URL: SubviewUrl<Mode> = {
key: "m",
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
decode: (raw: string) =>
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
};