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:
@@ -42,7 +42,6 @@ import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
@@ -438,7 +437,6 @@ export default function App() {
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{me!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
}
|
||||
@@ -459,7 +457,7 @@ export default function App() {
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{me && !me.is_demo && <DebugRecorder />}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
<About
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
|
||||
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
||||
expect(quotaActions.length).toBeGreaterThan(0);
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(opaqueWhats).toEqual([]);
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
"demo_add": "Demo email added",
|
||||
"demo_remove": "Demo email removed",
|
||||
"demo_reset": "Demo reset",
|
||||
"discovery_purge": "Discovery purged",
|
||||
"config_set": "Setting changed",
|
||||
"config_reset": "Setting reset",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"accessRequestsMessage_one": "{{count}} pending — review it on the Users page.",
|
||||
"accessRequestsMessage_other": "{{count}} pending — review them on the Users page.",
|
||||
"accessRequestsReview": "Review",
|
||||
"demoTitle": "Demo account",
|
||||
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
|
||||
"backToTop": "Back to top",
|
||||
"retry": "Retry",
|
||||
"retryWithEnter": "Press Enter to try again.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"title": "Couldn't complete that",
|
||||
"generic": "The server couldn't carry out that action. Please try again.",
|
||||
"demo": "This isn't available in the demo account",
|
||||
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
|
||||
"tooLarge": "That was too large for the server to accept",
|
||||
"server": "Server error",
|
||||
@@ -45,8 +44,7 @@
|
||||
"not_applicable": "This account has nothing left to confirm",
|
||||
"not_approved": "Only an approved address can be invited",
|
||||
"email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.",
|
||||
"send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again.",
|
||||
"demo": "The demo account is shared, so it can't get a set-password link"
|
||||
"send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "The server couldn't be reached. Try again.",
|
||||
@@ -132,10 +130,6 @@
|
||||
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync",
|
||||
"collection_read_only": "This collection is read-only",
|
||||
"collection_smart": "Smart and auto-list collections can't be made editable",
|
||||
"demo_activate": "The demo account can't be activated",
|
||||
"demo_delete": "The demo account can't be deleted",
|
||||
"demo_role": "The demo account's role can't be changed",
|
||||
"demo_suspend": "The demo account can't be suspended",
|
||||
"download_not_ready": "This download isn't ready",
|
||||
"edit_unfinished": "Only a finished download can be edited",
|
||||
"filters_not_object": "The view's filters are malformed",
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.",
|
||||
"setUp": "Set up my Feed",
|
||||
"browseShared": "Or just browse the shared Library",
|
||||
"demoEmptyTitle": "Nothing here in the demo",
|
||||
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared Library is yours to explore, filter and sort.",
|
||||
"browseLibrary": "Browse the Library",
|
||||
"noMatches": "No videos match these filters.",
|
||||
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared Library.",
|
||||
"viewInLibrary": "View in Library",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"skipToContent": "Skip to content",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"demo": "Demo"
|
||||
"user": "User"
|
||||
},
|
||||
"rail": {
|
||||
"home": "Home",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the Feed.",
|
||||
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
|
||||
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
||||
"demo_reset": "Resets the shared demo account (its playlists and state) so every visitor starts from the same sandbox. Does nothing when there is no demo account. If it stops, the demo keeps whatever the last visitor left behind.",
|
||||
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the Feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
||||
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
|
||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
||||
@@ -90,7 +89,6 @@
|
||||
"subscriptions": "Subscription sync from YouTube",
|
||||
"playlist_sync": "Playlist sync from YouTube",
|
||||
"maintenance": "Maintenance + validation",
|
||||
"demo_reset": "Demo account reset",
|
||||
"explore_cleanup": "Explored-channel cleanup",
|
||||
"download_gc": "Download cleanup",
|
||||
"plex_sync": "Plex library sync",
|
||||
@@ -139,7 +137,6 @@
|
||||
"revalidated": "revalidated",
|
||||
"flagged": "flagged",
|
||||
"skipped": "skipped",
|
||||
"playlists_seeded": "playlists seeded",
|
||||
"expired": "expired",
|
||||
"videos_deleted": "videos deleted",
|
||||
"channels_deleted": "channels deleted",
|
||||
@@ -183,7 +180,6 @@
|
||||
"disabled": "disabled",
|
||||
"PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured",
|
||||
"tmdbapikeynotset": "TMDB API key not set",
|
||||
"nodemoaccount": "no demo account",
|
||||
"noserviceuser": "no service user",
|
||||
"syncpaused": "sync paused",
|
||||
"lowquota": "low quota",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"deleteTitle": "Delete your account?",
|
||||
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
|
||||
"deleteConfirmButton": "Delete everything",
|
||||
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared Library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
|
||||
"signInMethods": "Sign-in methods",
|
||||
"googleLink": {
|
||||
"title": "Google account",
|
||||
@@ -132,28 +131,6 @@
|
||||
"removeConfirmAction": "Delete",
|
||||
"loadError": "Couldn't load your download authentication settings"
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo access",
|
||||
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
||||
"addPlaceholder": "Whitelist an email for demo…",
|
||||
"add": "Add",
|
||||
"added": "{{email}} added to the demo whitelist",
|
||||
"addFailed": "Couldn't add that email",
|
||||
"removeFailed": "Couldn't remove that email",
|
||||
"remove": "Remove",
|
||||
"removeHint": "Remove this email from the demo whitelist.",
|
||||
"addedBy": "added by {{who}}",
|
||||
"reset": "Reset demo state",
|
||||
"resetHint": "Wipe the demo account's playlists, hidden/watched/saved videos and settings, then re-seed a few sample playlists.",
|
||||
"resetTitle": "Reset demo state?",
|
||||
"resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.",
|
||||
"resetDone_one": "Demo reset — {{count}} sample playlist seeded",
|
||||
"resetDone_other": "Demo reset — {{count}} sample playlists seeded",
|
||||
"resetFailed": "Couldn't reset the demo account",
|
||||
"removed": "Removed from the demo whitelist",
|
||||
"removeTitle": "Remove demo access?",
|
||||
"removeConfirm": "Remove {{email}} from the demo whitelist? They'll no longer be able to enter the demo account."
|
||||
},
|
||||
"invites": {
|
||||
"title": "Access requests",
|
||||
"noPending": "No pending requests.",
|
||||
@@ -228,7 +205,6 @@
|
||||
},
|
||||
"status": "{{level}} logging · back to “Basic” in {{time}}",
|
||||
"revert": "Back to “Basic” now",
|
||||
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
|
||||
"failed": "Couldn't change the logging level"
|
||||
},
|
||||
"myLog": {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"tabs": {
|
||||
"roles": "Users & roles",
|
||||
"access": "Access requests",
|
||||
"demo": "Demo"
|
||||
"access": "Access requests"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Signed up, but still outside",
|
||||
@@ -26,14 +25,11 @@
|
||||
"empty": "No users yet.",
|
||||
"you": "you",
|
||||
"admin": "Admin",
|
||||
"demo": "Demo",
|
||||
"suspended": "Suspended",
|
||||
"pending": "Pending approval",
|
||||
"unverified": "Unverified email",
|
||||
"lastLogin": "Last sign-in: {{time}}",
|
||||
"lastLoginNone": "No sign-in recorded yet",
|
||||
"lastLoginDemo": "Not recorded for the demo account",
|
||||
"demoLocked": "The demo account's role can't be changed.",
|
||||
"selfLocked": "You can't change your own role.",
|
||||
"makeAdmin": "Make admin",
|
||||
"makeUser": "Make user",
|
||||
@@ -53,7 +49,6 @@
|
||||
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
|
||||
"done": "{{email}} suspended",
|
||||
"undone": "{{email}} unsuspended",
|
||||
"demoLocked": "The demo account can't be suspended.",
|
||||
"selfLocked": "You can't suspend your own account.",
|
||||
"failed": "Couldn't change the suspension"
|
||||
},
|
||||
@@ -86,7 +81,6 @@
|
||||
"invite": "Invite",
|
||||
"lastLogin": "Last sign-in",
|
||||
"lastLoginNone": "none recorded yet",
|
||||
"lastLoginDemo": "not recorded for the demo account",
|
||||
"inviteMissing": "this address is not on the allow-list",
|
||||
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
|
||||
"mailOff": "no mail configured",
|
||||
|
||||
@@ -77,11 +77,6 @@
|
||||
"backToSignin": "Back to sign in",
|
||||
"or": "or",
|
||||
"google": "Continue with Google",
|
||||
"tryDemo": "Try the demo",
|
||||
"demoHint": "Enter an email that's been enabled for the demo account.",
|
||||
"demoEmail": "Demo email",
|
||||
"demoEnter": "Enter",
|
||||
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
|
||||
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
|
||||
"resetDone": "Your password has been set — you can sign in now",
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||
"invite_add": "E-mail fehérlistára téve",
|
||||
"demo_add": "Demó e-mail hozzáadva",
|
||||
"demo_remove": "Demó e-mail eltávolítva",
|
||||
"demo_reset": "Demó visszaállítva",
|
||||
"discovery_purge": "Felfedezés ürítve",
|
||||
"config_set": "Beállítás módosítva",
|
||||
"config_reset": "Beállítás visszaállítva",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"accessRequestsMessage_one": "{{count}} függőben — nézd át a Felhasználók oldalon.",
|
||||
"accessRequestsMessage_other": "{{count}} függőben — nézd át a Felhasználók oldalon.",
|
||||
"accessRequestsReview": "Áttekintés",
|
||||
"demoTitle": "Demó fiók",
|
||||
"demoSharedNotice": "Egy közös demó fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demó látogatók egyszerre módosíthatják.",
|
||||
"backToTop": "Vissza a tetejére",
|
||||
"retry": "Újra",
|
||||
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"title": "Nem sikerült",
|
||||
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
|
||||
"demo": "Ez a demó fiókban nem érhető el",
|
||||
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
|
||||
"tooLarge": "Ez túl nagy, a szerver nem fogadta el",
|
||||
"server": "Szerverhiba",
|
||||
@@ -45,8 +44,7 @@
|
||||
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni",
|
||||
"not_approved": "Csak jóváhagyott címre küldhető meghívó",
|
||||
"email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.",
|
||||
"send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.",
|
||||
"demo": "A demó fiók közös, ezért nem kaphat jelszóbeállító linket"
|
||||
"send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "A szerver nem volt elérhető. Próbáld újra.",
|
||||
@@ -132,10 +130,6 @@
|
||||
"collection_pending_sync": "A kollekció a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja",
|
||||
"collection_read_only": "Ez a kollekció csak olvasható",
|
||||
"collection_smart": "Az okos és az automatikus listás kollekciók nem tehetők szerkeszthetővé",
|
||||
"demo_activate": "A demó fiók nem aktiválható",
|
||||
"demo_delete": "A demó fiók nem törölhető",
|
||||
"demo_role": "A demó fiók szerepköre nem módosítható",
|
||||
"demo_suspend": "A demó fiók nem függeszthető fel",
|
||||
"download_not_ready": "Ez a letöltés még nincs kész",
|
||||
"edit_unfinished": "Csak befejezett letöltés szerkeszthető",
|
||||
"filters_not_object": "A nézet szűrői hibás formátumúak",
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a Feededet.",
|
||||
"setUp": "Feed beállítása",
|
||||
"browseShared": "Vagy csak böngészd a közös Könyvtárat",
|
||||
"demoEmptyTitle": "Itt a demóban nincs semmi",
|
||||
"demoEmptyBody": "A demó fióknak nincsenek saját feliratkozásai — de a teljes közös Könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
|
||||
"browseLibrary": "Könyvtár böngészése",
|
||||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös Könyvtárban vannak.",
|
||||
"viewInLibrary": "Megnézés a Könyvtárban",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"skipToContent": "Ugrás a tartalomra",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"user": "Felhasználó",
|
||||
"demo": "Demó"
|
||||
"user": "Felhasználó"
|
||||
},
|
||||
"rail": {
|
||||
"home": "Kezdőlap",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a Feedben.",
|
||||
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
|
||||
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
||||
"demo_reset": "Visszaállítja a közös demó fiókot (lejátszási listák és állapot), hogy minden látogató ugyanabból a homokozóból induljon. Ha nincs demó fiók, nem csinál semmit. Ha leáll, a demóban az marad, amit az előző látogató hagyott benne.",
|
||||
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
||||
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
|
||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
||||
@@ -90,7 +89,6 @@
|
||||
"subscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
|
||||
"playlist_sync": "Lejátszási listák szinkronizálása a YouTube-ról",
|
||||
"maintenance": "Karbantartás + ellenőrzés",
|
||||
"demo_reset": "Demó fiók visszaállítása",
|
||||
"explore_cleanup": "Felfedezett csatornák takarítása",
|
||||
"download_gc": "Letöltések takarítása",
|
||||
"plex_sync": "Plex-könyvtár szinkron",
|
||||
@@ -139,7 +137,6 @@
|
||||
"revalidated": "újraérvényesítve",
|
||||
"flagged": "megjelölve",
|
||||
"skipped": "kihagyva",
|
||||
"playlists_seeded": "létrehozott lejátszási lista",
|
||||
"expired": "lejárt",
|
||||
"videos_deleted": "törölt videó",
|
||||
"channels_deleted": "törölt csatorna",
|
||||
@@ -183,7 +180,6 @@
|
||||
"disabled": "kikapcsolva",
|
||||
"PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva",
|
||||
"tmdbapikeynotset": "nincs TMDB API-kulcs",
|
||||
"nodemoaccount": "nincs demófiók",
|
||||
"noserviceuser": "nincs szolgáltatásfiók",
|
||||
"syncpaused": "a szinkron szünetel",
|
||||
"lowquota": "kevés a kvóta",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"deleteTitle": "Törlöd a fiókodat?",
|
||||
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
|
||||
"deleteConfirmButton": "Minden törlése",
|
||||
"demoNotice": "Ez a közös demó fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös Könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
|
||||
"signInMethods": "Bejelentkezési módok",
|
||||
"googleLink": {
|
||||
"title": "Google-fiók",
|
||||
@@ -132,28 +131,6 @@
|
||||
"removeConfirmAction": "Törlés",
|
||||
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat"
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demó hozzáférés",
|
||||
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demó fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
||||
"addPlaceholder": "E-mail fehérlistára a demóhoz…",
|
||||
"add": "Hozzáadás",
|
||||
"added": "{{email}} hozzáadva a demó fehérlistához",
|
||||
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
|
||||
"removeFailed": "Nem sikerült eltávolítani ezt az e-mailt",
|
||||
"remove": "Eltávolítás",
|
||||
"removeHint": "E-mail eltávolítása a demó fehérlistáról.",
|
||||
"addedBy": "hozzáadta: {{who}}",
|
||||
"reset": "Demó állapot visszaállítása",
|
||||
"resetHint": "Törli a demó fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.",
|
||||
"resetTitle": "Visszaállítod a demó állapotot?",
|
||||
"resetConfirm": "Ez törli mindazt, amit a demó fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.",
|
||||
"resetDone_one": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva",
|
||||
"resetDone_other": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva",
|
||||
"resetFailed": "Nem sikerült visszaállítani a demó fiókot",
|
||||
"removed": "Eltávolítva a demó fehérlistáról",
|
||||
"removeTitle": "Visszavonod a demó hozzáférést?",
|
||||
"removeConfirm": "Eltávolítod {{email}} címet a demó fehérlistáról? Többé nem tud belépni a demó fiókba."
|
||||
},
|
||||
"invites": {
|
||||
"title": "Hozzáférési kérelmek",
|
||||
"noPending": "Nincs függőben lévő kérelem.",
|
||||
@@ -228,7 +205,6 @@
|
||||
},
|
||||
"status": "{{level}} naplózás · {{time}} múlva visszaáll „Alap” szintre",
|
||||
"revert": "Visszaállítás most",
|
||||
"demo": "A demó fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
|
||||
"failed": "Nem sikerült módosítani a naplózási szintet"
|
||||
},
|
||||
"myLog": {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"tabs": {
|
||||
"roles": "Felhasználók és szerepek",
|
||||
"access": "Hozzáférési kérelmek",
|
||||
"demo": "Demó"
|
||||
"access": "Hozzáférési kérelmek"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Regisztrált, de még kívül van",
|
||||
@@ -26,14 +25,11 @@
|
||||
"empty": "Még nincs felhasználó.",
|
||||
"you": "te",
|
||||
"admin": "Admin",
|
||||
"demo": "Demó",
|
||||
"suspended": "Felfüggesztve",
|
||||
"pending": "Jóváhagyásra vár",
|
||||
"unverified": "Nem igazolt e-mail",
|
||||
"lastLogin": "Utolsó belépés: {{time}}",
|
||||
"lastLoginNone": "Nincs még rögzített belépés",
|
||||
"lastLoginDemo": "A demó fióknál nem rögzítjük",
|
||||
"demoLocked": "A demó fiók szerepe nem módosítható.",
|
||||
"selfLocked": "A saját szerepedet nem módosíthatod.",
|
||||
"makeAdmin": "Adminná tesz",
|
||||
"makeUser": "Felhasználóvá tesz",
|
||||
@@ -53,7 +49,6 @@
|
||||
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
|
||||
"done": "{{email}} felfüggesztve",
|
||||
"undone": "{{email}} felfüggesztése feloldva",
|
||||
"demoLocked": "A demó fiók nem függeszthető fel.",
|
||||
"selfLocked": "A saját fiókodat nem függesztheted fel.",
|
||||
"failed": "Nem sikerült módosítani a felfüggesztést"
|
||||
},
|
||||
@@ -86,7 +81,6 @@
|
||||
"invite": "Meghívó",
|
||||
"lastLogin": "Utolsó belépés",
|
||||
"lastLoginNone": "még nincs rögzítve",
|
||||
"lastLoginDemo": "a demó fióknál nem rögzítjük",
|
||||
"inviteMissing": "ez a cím nincs a fehérlistán",
|
||||
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
|
||||
"mailOff": "nincs beállítva levelezés",
|
||||
|
||||
@@ -77,11 +77,6 @@
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
"or": "vagy",
|
||||
"google": "Folytatás Google-lel",
|
||||
"tryDemo": "Demó kipróbálása",
|
||||
"demoHint": "Adj meg egy e-mailt, amely engedélyezve van a demó fiókhoz.",
|
||||
"demoEmail": "Demó e-mail",
|
||||
"demoEnter": "Belépés",
|
||||
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
|
||||
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
|
||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz",
|
||||
|
||||
@@ -87,27 +87,6 @@ describe("notifyActionError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("tells a demo user that the demo account is the reason", async () => {
|
||||
// `require_human`'s refusal was a plain English string, so round 5's fix — discard a
|
||||
// plain-string detail, show the caller's translated fallback — discarded the only explanation
|
||||
// the demo user got: a generic "couldn't pin it" with no hint why (round 6). The gate answers
|
||||
// a structured code now, and `localizeDetail` has a branch for it in both locales.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "demo" } }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = await api.addComfortPin("video", "v1").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "GENERIC FALLBACK");
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "GENERIC FALLBACK",
|
||||
message: i18n.t("errors.demo"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface Me {
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
/** The latest sign-in on any device (ISO), or null when none is recorded. */
|
||||
last_login_at: string | null;
|
||||
has_google: boolean;
|
||||
@@ -40,10 +39,9 @@ export interface Me {
|
||||
issues: { repo: string } | null;
|
||||
}
|
||||
|
||||
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user`
|
||||
* row), so it comes first. */
|
||||
export function roleKey(a: Pick<Me, "is_demo" | "role">): "admin" | "user" | "demo" {
|
||||
return a.is_demo ? "demo" : a.role === "admin" ? "admin" : "user";
|
||||
/** The `nav.role.*` key an account is shown under. */
|
||||
export function roleKey(a: Pick<Me, "role">): "admin" | "user" {
|
||||
return a.role === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
/** What an account is called on screen: its display name, else the local part of its email. */
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
|
||||
// diagnostics console's server log, the scheduler + maintenance controls, the demo-account
|
||||
// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
|
||||
// diagnostics console's server log, the scheduler + maintenance controls, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
|
||||
// module as the cohesive "admin" domain.)
|
||||
import type { Level } from "../log";
|
||||
import { req } from "./core";
|
||||
|
||||
export interface DemoWhitelistEntry {
|
||||
id: number;
|
||||
email: string;
|
||||
note: string | null;
|
||||
added_by: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -78,7 +69,6 @@ export interface AdminUserRow {
|
||||
is_active: boolean;
|
||||
is_suspended: boolean;
|
||||
email_verified: boolean;
|
||||
is_demo: boolean;
|
||||
has_password: boolean;
|
||||
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
|
||||
* account) from "we took it away" — different problems needing different help. */
|
||||
@@ -87,7 +77,7 @@ export interface AdminUserRow {
|
||||
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
|
||||
google_email: string | null;
|
||||
created_at: string | null;
|
||||
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill), or the demo account. */
|
||||
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
@@ -288,13 +278,6 @@ export const adminApi = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
||||
}),
|
||||
// --- demo whitelist ---
|
||||
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
|
||||
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
|
||||
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
|
||||
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
||||
req("/api/admin/demo/reset", { method: "POST" }),
|
||||
// --- access invites ---
|
||||
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
|
||||
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
|
||||
// session management, access requests, and the hidden demo login. Errors on the interactive paths
|
||||
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
|
||||
// and session management. Errors on the interactive paths are surfaced inline (quiet) rather than
|
||||
// via the global modal. Only touches core's req.
|
||||
import { req, uiLang } from "./core";
|
||||
|
||||
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
|
||||
@@ -126,10 +126,4 @@ export const authApi = {
|
||||
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
|
||||
// --- onboarding: demo entry ---
|
||||
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
||||
// sign-in. Returns whether a session was established.
|
||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
};
|
||||
|
||||
@@ -215,7 +215,6 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
[{ code: "activate", reason: "not_allowed" }, "errors.admin.not_allowed"],
|
||||
[{ code: "invite_send", reason: "not_approved" }, "errors.admin.not_approved"],
|
||||
[{ code: "resend", reason: "send_failed" }, "errors.admin.send_failed"],
|
||||
[{ code: "setpw", reason: "demo" }, "errors.admin.demo"],
|
||||
])("says why an admin action was refused (%o)", async (detail, localized) => {
|
||||
// These read as the generic "try again" — which no retry fixes when the address is not approved.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
|
||||
@@ -71,7 +71,7 @@ export class HttpError extends Error {
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send", "setpw"]);
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
||||
|
||||
function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
@@ -102,10 +102,6 @@ function localizeDetail(d: unknown): string {
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The shared demo account refusing a personal-state write (`require_human`). Not every affordance
|
||||
// behind that gate is UI-gated on `is_demo` — the comfort shelf is not — so without this the demo
|
||||
// user got their caller's generic "couldn't do that" and no reason at all (round 6).
|
||||
if (o?.code === "demo") return t("errors.demo");
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
@@ -163,8 +159,8 @@ function localizeDetail(d: unknown): string {
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||||
// by, a set-password link for the shared demo account. The toast's title already names the button;
|
||||
// this is the WHY, and it used to be the generic "try again", which no retry could fix.
|
||||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
||||
// "try again", which no retry could fix.
|
||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
|
||||
@@ -1499,6 +1499,7 @@
|
||||
"src/lib/reactEngine.contract.test.ts",
|
||||
"src/lib/reinsert.test.ts",
|
||||
"src/lib/releaseNotes.test.ts",
|
||||
"src/lib/removedFeatures.contract.test.ts",
|
||||
"src/lib/repoScripts.contract.test.ts",
|
||||
"src/lib/rovingHighlight.contract.test.ts",
|
||||
"src/lib/saveLanguage.dom.test.ts",
|
||||
@@ -1565,7 +1566,6 @@
|
||||
"src/components/ConfirmProvider.tsx",
|
||||
"src/components/DateFilterSelect.tsx",
|
||||
"src/components/DebugRecorder.tsx",
|
||||
"src/components/DemoBanner.tsx",
|
||||
"src/components/DiagnosticsSettings.tsx",
|
||||
"src/components/DownloadButton.tsx",
|
||||
"src/components/DownloadCenter.tsx",
|
||||
|
||||
@@ -41,13 +41,12 @@ export type ModuleDef = {
|
||||
// which owns the polling queries; this only flags which modules have one).
|
||||
hasBadge?: boolean;
|
||||
// Availability for THIS account, derived from language-independent account state (plex toggle,
|
||||
// demo restrictions, admin role). Absent = always available. Drives both the reachable module
|
||||
// admin role). Absent = always available. Drives both the reachable module
|
||||
// set (nav + stepper) and, later, the render gate.
|
||||
gate?: (me: Me) => boolean;
|
||||
};
|
||||
|
||||
const isAdmin = (me: Me) => me.role === "admin";
|
||||
const notDemo = (me: Me) => !me.is_demo;
|
||||
|
||||
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
||||
const MODULES: readonly ModuleDef[] = [
|
||||
@@ -95,7 +94,6 @@ const MODULES: readonly ModuleDef[] = [
|
||||
railKey: "nav.rail.downloads",
|
||||
icon: Download,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
|
||||
@@ -22,11 +22,8 @@ const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: {
|
||||
can_read: boolean;
|
||||
is_demo?: boolean;
|
||||
google_enabled?: boolean;
|
||||
}): boolean {
|
||||
// The shared demo account can never connect YouTube, so never nudge it to.
|
||||
if (me.is_demo) return false;
|
||||
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
|
||||
// email+password only — then there's nothing to connect, so don't nudge.
|
||||
if (me.google_enabled === false) return false;
|
||||
|
||||
@@ -101,7 +101,6 @@ export const qk = {
|
||||
["debug-sessions", id, "preview", text] as const,
|
||||
adminDownloads: () => ["admin-downloads"] as const,
|
||||
adminQuota: (days: number) => ["admin-quota", days] as const,
|
||||
demoWhitelist: () => ["demo-whitelist"] as const,
|
||||
|
||||
// --- plex ---
|
||||
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The shared demo account was removed (the owner's decision, 2026-10-04): its user, its whitelist, its
|
||||
// login entry, and every `is_demo` branch that kept it from quota, YouTube, downloads and the debug
|
||||
// recorder. A guard left behind is not harmless — it reads as a live rule, and the next reader keeps
|
||||
// it alive by copying it — so this fails on the word anywhere in the code a feature could live in:
|
||||
// the backend (app + tests), the frontend (src + e2e) and the repo scripts.
|
||||
//
|
||||
// Not scanned: the Alembic migrations (0014 added the account and 0098 removed it, and both stay true
|
||||
// as history) and the release notes, which record what shipped when. `audit_metrics.py` counts the
|
||||
// footprint by this same word, so it names it on purpose.
|
||||
|
||||
const REPO = new URL("../../../", import.meta.url);
|
||||
const ROOTS = ["backend/app", "backend/tests", "frontend/src", "frontend/e2e", "scripts"];
|
||||
const SKIP_DIRS = new Set(["node_modules", "__pycache__", "dist", ".speed", "release-notes"]);
|
||||
const EXTENSIONS = /\.(py|ts|tsx|js|mjs|json|css|md|sh)$/;
|
||||
const ALLOWED = new Set([
|
||||
"frontend/src/lib/removedFeatures.contract.test.ts",
|
||||
"scripts/audit_metrics.py",
|
||||
]);
|
||||
// "demo" as a word or an identifier part (`is_demo`, `DemoBanner`, `tryDemo`) — not "demote" (a role
|
||||
// change) and not "Demokratikus" (a country's Hungarian name in the vocab).
|
||||
const DEMO = /demo(?!t(e|ion)|kr)/i;
|
||||
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(new URL(dir, REPO), { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? SKIP_DIRS.has(e.name)
|
||||
? []
|
||||
: files(`${dir}/${e.name}`)
|
||||
: EXTENSIONS.test(e.name)
|
||||
? [`${dir}/${e.name}`]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("removed features stay removed", () => {
|
||||
it("leaves no trace of the demo account", () => {
|
||||
const hits = ROOTS.flatMap(files)
|
||||
.filter((f) => !ALLOWED.has(f))
|
||||
.flatMap((f) =>
|
||||
readFileSync(new URL(f, REPO), "utf-8")
|
||||
.split("\n")
|
||||
.flatMap((line, i) => (DEMO.test(line) ? [`${f}:${i + 1}: ${line.trim()}`] : [])),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user