feat!: remove the shared demo account

The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent cca3315836
commit 6d8b7be6d8
109 changed files with 487 additions and 1400 deletions
+3 -10
View File
@@ -175,18 +175,14 @@ export default function AccountPanel({
}
tone={a.has_google ? "ok" : "muted"}
/>
{/* The exact moment; the list row beside it only says how long ago. Null has two different
meanings, so it says which: nothing recorded yet, or the shared demo account, which is
deliberately never stamped. */}
{/* The exact moment; the list row beside it only says how long ago. */}
<AccessRow
icon={LogIn}
label={t("users.account.lastLogin")}
value={
a.last_login_at
? formatDateTime(a.last_login_at, lang)
: a.is_demo
? t("users.account.lastLoginDemo")
: t("users.account.lastLoginNone")
: t("users.account.lastLoginNone")
}
/>
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
@@ -223,9 +219,7 @@ export default function AccountPanel({
icon={<Send />}
loading={resend.isPending}
onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified}
disabled={!a.email_configured || a.email_verified}
>
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
@@ -243,7 +237,6 @@ export default function AccountPanel({
fullWidth
icon={<KeyRound />}
loading={issue.isPending}
disabled={user.is_demo}
onClick={() => issue.mutate()}
className="border border-accent/40 text-accent"
>
@@ -44,7 +44,6 @@ const row = {
email: "gone@x",
role: "user",
is_suspended: false,
is_demo: false,
email_verified: true,
is_active: true,
} as unknown as AdminUserRow;
@@ -83,7 +82,6 @@ describe("the last sign-in column (E22 S1)", () => {
vi.spyOn(api, "adminUsers").mockResolvedValue([
{ ...row, id: 3, email: "seen@x", last_login_at: at },
{ ...row, id: 4, email: "never@x", last_login_at: null },
{ ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null },
] as never);
render(
<QueryClientProvider
@@ -94,7 +92,7 @@ describe("the last sign-in column (E22 S1)", () => {
);
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2);
expect(screen.getAllByText("—")).toHaveLength(1);
});
});
+9 -140
View File
@@ -53,9 +53,9 @@ import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
// carries a badge with the count of pending requests so it's visible without switching to it.
// Admin-only user & access management: roles and access requests (the Invite whitelist). Each
// section is its own tab (persisted across reloads); the Access tab carries a badge with the count
// of pending requests so it's visible without switching to it.
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
@@ -81,7 +81,6 @@ export default function AdminUsers() {
label: t("users.tabs.access"),
badge: me.pending_invites + me.pending_activations,
},
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (
@@ -100,7 +99,6 @@ export default function AdminUsers() {
<PendingAccess />
</>
)}
{active === "demo" && <AdminDemo />}
</div>
</>
);
@@ -160,7 +158,7 @@ function UsersRoles({ me }: { me: Me }) {
});
},
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (demo / self / last admin) surface via the global error dialog; the rest, as above.
// Guards (self / last admin) surface via the global error dialog; the rest, as above.
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
});
const del = useMutation({
@@ -213,16 +211,14 @@ function UsersRoles({ me }: { me: Me }) {
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason.
// badges, the last sign-in. A locked action (on yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id;
const pending = pendingIds.has(u.id);
const locked = (demoNote: string, selfNote: string) =>
u.is_demo ? demoNote : self ? selfNote : undefined;
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked"));
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
const roleNote = self ? t("users.roles.selfLocked") : undefined;
const suspendNote = self ? t("users.suspend.selfLocked") : undefined;
const deleteNote = self ? t("users.delete.locked") : undefined;
return [
{
id: "role",
@@ -301,7 +297,6 @@ function UsersRoles({ me }: { me: Me }) {
<div className="text-2xs text-muted truncate">{u.email}</div>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a
@@ -317,9 +312,7 @@ function UsersRoles({ me }: { me: Me }) {
hint={
u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
: u.is_demo
? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone")
: t("users.roles.lastLoginNone")
}
>
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
@@ -784,130 +777,6 @@ function AdminInvites() {
);
}
function AdminDemo() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() });
const [newEmail, setNewEmail] = useState("");
const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: (_d, email) => {
setNewEmail("");
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.added", { email }) });
},
onError: (e) => notifyActionError(e, t("settings.demo.addFailed")),
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: (e) => notifyActionError(e, t("settings.demo.removeFailed")),
});
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
notify({
level: "success",
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
}),
onError: (e) => notifyActionError(e, t("settings.demo.resetFailed")),
});
const rows = list.data ?? [];
return (
<Section card title={t("settings.demo.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
{rows.length > 0 && (
<div className="space-y-1.5 mb-3">
{rows.map((r) => (
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
<div className="text-2xs text-muted truncate">
{t("settings.demo.addedBy", { who: r.added_by })}
</div>
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<IconButton
noTitle
label={t("settings.demo.remove")}
size="sm"
variant="outline-danger"
onClick={() => onRemove(r)}
disabled={remove.isPending}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
</div>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2"
>
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
/>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
</Button>
</form>
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<Button
variant="glass"
size="sm"
icon={<RotateCcw />}
loading={reset.isPending}
onClick={async () => {
if (
await confirm({
title: t("settings.demo.resetTitle"),
message: t("settings.demo.resetConfirm"),
confirmLabel: t("settings.demo.reset"),
danger: true,
})
)
reset.mutate();
}}
>
{t("settings.demo.reset")}
</Button>
</Tooltip>
</div>
</Section>
);
}
function InviteRow({
inv,
onApprove,
@@ -68,7 +68,6 @@ import AppCommandPalette from "./AppCommandPalette";
const user = (over: Record<string, unknown> = {}) => ({
id: 1,
role: "user",
is_demo: false,
plex_enabled: false,
...over,
});
@@ -143,7 +142,7 @@ describe("AppCommandPalette", () => {
expect(requestSurprise).toHaveBeenCalledTimes(1);
});
it("offers the debug recorder by its state, and not to the demo account", () => {
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"]);
@@ -153,10 +152,5 @@ describe("AppCommandPalette", () => {
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
mount();
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
cleanup();
me = user({ is_demo: true });
mount();
expect(recorder()).toEqual([]);
});
});
+24 -27
View File
@@ -151,34 +151,31 @@ export default function AppCommandPalette() {
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
// The debug recorder (E16) — not for the shared demo account, which cannot record. While
// it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// The debug recorder (E16). 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.
...(me?.is_demo
? []
: 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,
},
]),
...(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 }),
+23 -28
View File
@@ -152,18 +152,17 @@ export default function ChannelPage({
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
// account (explore spends shared quota) and when videos are already present (a revisit, or a
// subscribed channel whose uploads the scheduler backfills).
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
// already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
useEffect(() => {
if (!ch || me.is_demo) return;
if (!ch) return;
if (autoTried.current === channelId) return;
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
}, [ch, channelId, runExplore]);
const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
@@ -238,9 +237,9 @@ export default function ChannelPage({
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo });
// Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
// channel page never shows the control, so the request would be thrown away.
const tagsQuery = useTags({ enabled: !!ch?.subscribed });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
@@ -503,7 +502,7 @@ export default function ChannelPage({
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
{ch?.subscribed && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
@@ -520,7 +519,7 @@ export default function ChannelPage({
While loading, the label carries the live stored/total count (the detail poll refreshes
it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */}
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
{ch?.subscribed && !ch.backfill_done && (
<Button
variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
@@ -545,23 +544,21 @@ export default function ChannelPage({
</span>
</Button>
)}
{!me.is_demo && (
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
)}
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
{ch?.subscribed && (
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
@@ -573,8 +570,7 @@ export default function ChannelPage({
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
{!ch?.blocked &&
(ch?.subscribed ? (
<Button
variant="outline"
@@ -598,7 +594,7 @@ export default function ChannelPage({
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && !me.is_demo && (
{ch?.subscribed && (
<div className="min-w-0 flex-1">
<ChannelTagsControl
userTags={userTags}
@@ -631,7 +627,6 @@ export default function ChannelPage({
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
@@ -117,7 +117,6 @@ function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
@@ -317,7 +316,6 @@ describe("the past sessions a user goes back to check", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
+1 -1
View File
@@ -36,7 +36,7 @@ import { SURFACE } from "./ui/surfaces";
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo.
// app shell, for a signed-in account.
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string {
-15
View File
@@ -1,15 +0,0 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import Banner from "./ui/Banner";
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
export default function DemoBanner() {
const { t } = useTranslation();
return (
<Banner tone="warning" icon={AlertTriangle}>
<span className="font-semibold">{t("common.demoTitle")}</span>
<span className="text-muted"> — {t("common.demoSharedNotice")}</span>
</Banner>
);
}
@@ -60,7 +60,6 @@ function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
...me,
@@ -212,7 +211,6 @@ describe("DiagnosticsSettings", () => {
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
answerStale({
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
});
@@ -229,10 +227,4 @@ describe("DiagnosticsSettings", () => {
);
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
});
it("the shared demo account gets an explanation instead of the picker", () => {
mount({ is_demo: true });
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
});
});
+17 -24
View File
@@ -7,7 +7,6 @@ import { levelInForce } from "../lib/diagnostics";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
@@ -99,24 +98,20 @@ export default function DiagnosticsSettings() {
return (
<>
<Section title={t("settings.diagnostics.title")}>
{me.is_demo ? (
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
) : (
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<div ref={pickerRef}>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</div>
</SettingRow>
)}
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<div ref={pickerRef}>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</div>
</SettingRow>
{pending && (
<div
@@ -186,11 +181,9 @@ export default function DiagnosticsSettings() {
)}
</Section>
{!me.is_demo && (
<Section title={t("recorder.settings.title")}>
<RecorderSettings />
</Section>
)}
<Section title={t("recorder.settings.title")}>
<RecorderSettings />
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
+3 -11
View File
@@ -1,17 +1,16 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import clsx from "clsx";
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
import { api } from "../lib/api";
import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
// the shared per-user download index (one polled query shared across every card).
export default function DownloadButton({
videoId,
@@ -25,20 +24,13 @@ export default function DownloadButton({
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const me = qc.getQueryData<Me>(qk.me());
const isDemo = !!me?.is_demo;
const indexQ = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: !isDemo,
staleTime: 10000,
});
const [open, setOpen] = useState(false);
if (isDemo) return null;
const status = indexQ.data?.[videoId];
const downloaded = status === "done";
const inQueue = status === "queued" || status === "running" || status === "paused";
+16 -35
View File
@@ -111,7 +111,6 @@ export default function Feed({
view,
setView,
canRead,
isDemo = false,
ytSearch,
onYtSearch,
onExitYtSearch,
@@ -125,11 +124,10 @@ export default function Feed({
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
isDemo?: boolean;
// Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
// onExitYtSearch. Search affordances are hidden for demo.
// onExitYtSearch.
ytSearch: string | null;
onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
@@ -595,41 +593,24 @@ export default function Feed({
{t("feed.loadError")}
</StateMessage>
);
// Empty "my" feed with no YouTube access. The shared demo account can never connect
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
// Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
// library. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
</h2>
<p className="text-sm text-muted mt-2 mb-4">
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<Button
variant="primary"
size="lg"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseLibrary")}
</Button>
) : (
<>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseShared")}
</Button>
</>
)}
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseShared")}
</Button>
</div>
</div>
);
@@ -879,7 +860,7 @@ export default function Feed({
</Button>
</div>
)}
{filters.q.trim() && !isDemo && (
{filters.q.trim() && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
@@ -1,12 +1,7 @@
// @vitest-environment jsdom
//
// The shared demo account has no subscriptions, so its feed opens on the whole library. The filters are
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect
// this replaced applied the demo default after mounting; with it gone, a seed that did not read `me`
// would open the demo account on its empty subscriptions scope.
//
// A share link's filters are written to the account once at mount too, so the view the link opened
// survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
// The feed's filters are seeded at mount from the account's stored view. A share link's filters are
// written to the account once at mount too, so the view the link opened survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -49,20 +44,14 @@ afterEach(() => {
});
describe("FeedFiltersProvider seed", () => {
it("opens the shared demo account on the whole library from its first render", () => {
mount({ is_demo: true });
expect(scopes.length).toBeGreaterThan(0);
expect(scopes).toEqual(scopes.map(() => "all"));
});
it("opens any other account on its own scope", () => {
mount({ is_demo: false });
it("opens an account on its own scope", () => {
mount({});
expect(scopes[0]).toBe("my");
});
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
window.history.replaceState(null, "", "/?scope=all");
mount({ is_demo: false });
mount({});
expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
});
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import { getActiveAccount, type FeedFilters, type Me } from "../lib/api";
import { getActiveAccount, type FeedFilters } from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, writeJSON } from "../lib/storage";
@@ -28,21 +26,16 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
// feedFiltersInit.ts.
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(me: Me | null | undefined): FeedFilters {
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
const f = loadAccountFilters(getActiveAccount());
// The shared demo account has no subscriptions, so default it to the whole library.
return me?.is_demo ? { ...f, scope: "all" } : f;
return loadAccountFilters(getActiveAccount());
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
// pinned and `me` is cached, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(() =>
loadInitialFilters(qc.getQueryData<Me | null>(qk.me())),
);
// pinned, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() =>
@@ -31,7 +31,7 @@ import type { Me } from "../lib/api";
afterEach(cleanup);
function tipFor(source: Me["search_source"]): string | null {
const me = { is_demo: false, plex_enabled: false, search_source: source } as Me;
const me = { plex_enabled: false, search_source: source } as Me;
render(<Header me={me} onYtSearch={() => {}} />);
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
}
+2 -3
View File
@@ -23,8 +23,7 @@ const Header = forwardRef<
HTMLDivElement,
{
me: Me;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota).
// Trigger a live YouTube search for the current term (feed only).
onYtSearch: (q: string) => void;
}
>(function Header({ me, onYtSearch }, ref) {
@@ -64,7 +63,7 @@ const Header = forwardRef<
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed || me.is_demo}
goDisabled={!trimmed}
/>
);
} else if (page === "plex" && me.plex_enabled) {
-1
View File
@@ -124,7 +124,6 @@ export default function NavSidebar({
// lightweight per-user index the feed cards poll, so no extra request shape.
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
@@ -43,7 +43,7 @@ function mount() {
}
render(
<>
<Header me={{ is_demo: false, plex_enabled: false } as Me} onYtSearch={() => {}} />
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
<Probe />
</>,
);
+6 -12
View File
@@ -20,7 +20,6 @@ import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
@@ -51,9 +50,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setFilters } = usePlexActions();
const isDemo = useMe().is_demo;
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account.
// title opens the full manager modal (same shared components as the feed).
const { openModal, modalOpen } = useSavedViews();
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
@@ -314,15 +312,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
iconExpanded={
isDemo ? undefined : (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
)
}
onTitleClick={isDemo ? undefined : openModal}
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
// Same shared components as the feed rail (SavedViewsProvider switches source).
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
onTitleClick={openModal}
titleActionLabel={t("views.manageTitle")}
/>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
@@ -33,7 +33,6 @@ const me = (extra: Partial<Me>): Me =>
display_name: "Someone",
avatar_url: null,
role: "user",
is_demo: false,
last_login_at: null,
...extra,
}) as Me;
@@ -47,14 +46,10 @@ describe("AccountHeader", () => {
expect(screen.getByText("nav.role.user")).toBeTruthy();
});
it("says when none is recorded, and says nothing about it for the demo", () => {
it("says when none is recorded", () => {
render(<AccountHeader me={me({})} />);
expect(screen.getByTestId("account-last-login").textContent).toBe(
"settings.account.lastLoginNone",
);
cleanup();
render(<AccountHeader me={me({ is_demo: true })} />);
expect(screen.queryByTestId("account-last-login")).toBeNull();
expect(screen.getByText("nav.role.demo")).toBeTruthy();
});
});
+18 -26
View File
@@ -342,7 +342,7 @@ function AccessRow({
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors. Demo accounts never see this (handled by the caller).
// directly with inline errors.
function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -785,13 +785,11 @@ export function AccountHeader({ me }: { me: Me }) {
<div className="text-xs text-muted truncate">{me.email}</div>
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
{!me.is_demo && (
<div className="text-xs text-muted" data-testid="account-last-login">
{me.last_login_at
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
: t("settings.account.lastLoginNone")}
</div>
)}
<div className="text-xs text-muted" data-testid="account-last-login">
{me.last_login_at
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
: t("settings.account.lastLoginNone")}
</div>
</div>
</div>
</Section>
@@ -823,17 +821,13 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<>
<AccountHeader me={me} />
{!me.is_demo && <SignInMethods me={me} />}
<SignInMethods me={me} />
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{!me.is_demo && <DownloadAuth />}
<DownloadAuth />
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
</Section>
) : me.google_enabled ? (
{me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
@@ -865,17 +859,15 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</Section>
) : null}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
</p>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</Button>
</Section>
)}
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
</p>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</Button>
</Section>
</>
);
}
+4 -6
View File
@@ -21,7 +21,6 @@ import Combobox from "./ui/Combobox";
import MatchToggle from "./ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe";
import { useTags } from "../lib/useTags";
import { compareText } from "../lib/collate";
@@ -51,7 +50,6 @@ export default function Sidebar({
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const isDemo = useMe().is_demo;
const { openModal } = useSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
@@ -245,14 +243,14 @@ export default function Sidebar({
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
// full manager modal. Feed rail only.
iconExpanded={
!isDemo && source === "feed" ? (
source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined}
onTitleClick={source === "feed" ? openModal : undefined}
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
+28 -30
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { api, type AdminQuotaRow } from "../lib/api";
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -61,13 +61,13 @@ export default function Stats() {
</PageToolbar>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
</div>
);
}
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) {
// Per-user view: sync status + your own API usage + the manual sync actions.
function Overview() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
@@ -203,32 +203,30 @@ function Overview({ me }: { me: Me }) {
)}
</Section>
{!me.is_demo && (
<Section title={t("stats.sync.actions")}>
<div className="flex flex-wrap gap-2">
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<Button
variant="glass"
icon={<RefreshCw />}
loading={syncSubs.isPending}
onClick={() => syncSubs.mutate()}
>
{t("stats.sync.syncSubscriptions")}
</Button>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<Button
variant="glass"
icon={<History />}
loading={deepAll.isPending}
onClick={() => deepAll.mutate()}
>
{t("stats.sync.backfillEverything")}
</Button>
</Tooltip>
</div>
</Section>
)}
<Section title={t("stats.sync.actions")}>
<div className="flex flex-wrap gap-2">
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<Button
variant="glass"
icon={<RefreshCw />}
loading={syncSubs.isPending}
onClick={() => syncSubs.mutate()}
>
{t("stats.sync.syncSubscriptions")}
</Button>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<Button
variant="glass"
icon={<History />}
loading={deepAll.isPending}
onClick={() => deepAll.mutate()}
>
{t("stats.sync.backfillEverything")}
</Button>
</Tooltip>
</div>
</Section>
</>
);
}
@@ -9,7 +9,6 @@ import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -99,7 +98,7 @@ function Feed({
);
}
function setup(v: Video, { demo = false } = {}) {
function setup(v: Video) {
const handlers = {
onState: vi.fn(),
onToggleSave: vi.fn(),
@@ -107,7 +106,6 @@ function setup(v: Video, { demo = false } = {}) {
onOpen: vi.fn(),
};
const qc = new QueryClient();
qc.setQueryData(qk.me(), { is_demo: demo });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
@@ -175,12 +173,6 @@ describe("a video's menu", () => {
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
});
it("has no download row on the demo account", () => {
setup(video({}), { demo: true });
rightClick();
expect(labels()).not.toContain("downloads.menu.download");
});
it("the '…' opens the same menu, says so, and a second press closes it", () => {
setup(video({}));
expect(more().getAttribute("aria-haspopup")).toBe("menu");
+8 -62
View File
@@ -61,7 +61,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
};
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
// 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 }) {
@@ -211,7 +211,7 @@ function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
}
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/).
// 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,
@@ -446,13 +446,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setDeadReset(false);
}
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
const [showDemo, setShowDemo] = useState(false);
const [demoEmail, setDemoEmail] = useState("");
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState("");
const reset = () => {
setError("");
setDone("");
@@ -533,25 +526,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setBusy(false);
}
async function onDemo(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setDemoError("");
const mail = demoEmail.trim().toLowerCase();
if (!EMAIL_RE.test(mail)) {
setDemoError(t("welcome.auth.invalidEmail"));
return;
}
setDemoBusy(true);
try {
const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload();
else setDemoError(t("welcome.auth.demoDenied"));
} catch {
setDemoError(t("welcome.auth.demoDenied"));
}
setDemoBusy(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
@@ -736,46 +710,18 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
)}
</div>
{mode === "signin" && (
{/* 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>
{googleEnabled && (
<LinkButton href="/auth/login" size="lg" fullWidth>
{t("welcome.auth.google")}
</LinkButton>
)}
{!showDemo ? (
<Button
variant="outline"
size="lg"
fullWidth
className="mt-2"
onClick={() => setShowDemo(true)}
>
{t("welcome.auth.tryDemo")}
</Button>
) : (
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
<p className="text-2xs text-muted">{t("welcome.auth.demoHint")}</p>
<div className="flex gap-2">
<Input
className="w-full"
type="email"
value={demoEmail}
onChange={(e) => setDemoEmail(e.target.value)}
placeholder={t("welcome.auth.demoEmail")}
/>
<Button type="submit" variant="outline" size="lg" disabled={demoBusy}>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</Button>
</div>
{demoError && <p className="text-xs text-danger">{demoError}</p>}
</form>
)}
<LinkButton href="/auth/login" size="lg" fullWidth>
{t("welcome.auth.google")}
</LinkButton>
</>
)}
</>
+4 -6
View File
@@ -34,14 +34,13 @@ export function WizardProvider({ children }: { children: ReactNode }) {
const meId = me?.id;
const canRead = me?.can_read;
const canWrite = me?.can_write;
const isDemo = me?.is_demo;
const googleEnabled = me?.google_enabled;
useEffect(() => {
if (meId == null || canRead == null) return;
const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled };
const account = { can_read: canRead, google_enabled: googleEnabled };
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
if (shouldAutoOpenOnboarding(account)) setOpen(true);
}, [meId, canRead, canWrite, isDemo, googleEnabled]);
}, [meId, canRead, canWrite, googleEnabled]);
const actions = useMemo<WizardActions>(
() => ({ openWizard, closeWizard }),
@@ -51,10 +50,9 @@ export function WizardProvider({ children }: { children: ReactNode }) {
return (
<WizardActionsContext.Provider value={actions}>
{children}
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never
opens there. Lazy — its chunk loads only when first opened. */}
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
<Suspense fallback={null}>
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
{open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
</Suspense>
</WizardActionsContext.Provider>
);
@@ -58,7 +58,6 @@ const user = {
email: "stuck@example.com",
display_name: null,
role: "user",
is_demo: false,
is_active: false,
is_suspended: false,
};
@@ -148,15 +147,3 @@ describe("the account panel reports a refused recovery action", () => {
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
});
});
describe("the account panel on the demo account", () => {
// The demo account's address is never confirmed, so "send the confirmation link again" looked
// available — and the server refused it as "nothing left to confirm", beside a row reading
// "unconfirmed" (deep-review E11 round 3).
it("does not offer it a confirmation mail, which the server refuses", async () => {
mount({ ...user, is_demo: true });
await screen.findByText("users.account.recovery");
const resend = screen.getByText("users.account.resend").closest("button")!;
expect(resend.hasAttribute("disabled")).toBe(true);
});
});
@@ -79,7 +79,6 @@ const row = (over: Record<string, unknown> = {}) => ({
is_active: false,
is_suspended: false,
email_verified: true,
is_demo: false,
has_password: true,
has_google: false,
created_at: "2026-09-01T10:00:00Z",
+1 -5
View File
@@ -74,11 +74,7 @@ function localDetails(
head: t("scheduler.maintenance.batchLabel"),
sub: formatNumber(after.revalidate_batch, lang),
};
if (
row.action === "audit_clear" ||
row.action === "demo_reset" ||
row.action === "discovery_purge"
) {
if (row.action === "audit_clear" || row.action === "discovery_purge") {
const result = counts(after);
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -159,7 +159,7 @@ export function QuotaModal({
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = useMemo(() => (users.data ?? []).filter((u) => !u.is_demo), [users.data]);
const list = users.data ?? [];
return (
<div className="w-full sm:w-72">
<UserPicker
+1 -2
View File
@@ -33,12 +33,11 @@ export function FeedPage() {
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && !me.is_demo && <PersonalViewsModal />}
{modalOpen && <PersonalViewsModal />}
</>
);
}
+2 -2
View File
@@ -20,7 +20,7 @@ const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" |
},
};
// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this;
// Shared top-of-app notification bar. VersionBanner and VerifyBanner are thin wrappers over this;
// a future news-ticker variant can build on the same shell (rotating content as children).
export default function Banner({
tone,
@@ -41,7 +41,7 @@ export default function Banner({
* control is not an acceptable default, and `Button`'s type now says so. */
dismissTitle?: string;
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
* reader announces it. Left unset for the permanent bars (version): those are part of the
* page when it loads, and announcing them on every visit is noise, not information. */
role?: "status";
}) {
+1 -1
View File
@@ -41,7 +41,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
// the visible identity, and a screen reader still lands on a named page. A view that
// shows its own title renders it through `PageHeading`, and this one steps aside.
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region.
// - `banners` — in-flow, fixed notices (verify/version) above the scroll region.
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
+10 -18
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Bookmark,
BookmarkX,
@@ -17,7 +17,7 @@ import {
Undo2,
} from "lucide-react";
import { api, type Me, type Video, type VideoStatus } from "../lib/api";
import { api, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { qk } from "../lib/queryKeys";
import { pinVideo, useComfortPin } from "./comfortPin";
@@ -48,17 +48,13 @@ const icon = "w-4 h-4";
* panel to render beside the menu (one per list). */
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const { t } = useTranslation();
const qc = useQueryClient();
const { openChannel } = useNavigationActions();
const pin = useComfortPin(open);
const copy = useCopyItems();
// Downloads spend server disk and need a real identity: the demo account has none (as the
// player's DownloadButton).
const canDownload = !qc.getQueryData<Me>(qk.me())?.is_demo;
const index = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: open && canDownload,
enabled: open,
staleTime: 10000,
});
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
@@ -139,17 +135,13 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
},
]
: []),
...(canDownload
? [
{
id: "download",
label: t("downloads.menu.download"),
note: dlState,
icon: <Download className={icon} />,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
]
: []),
{
id: "download",
label: t("downloads.menu.download"),
note: dlState,
icon: <Download className={icon} />,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
...(details
? [
{