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:
@@ -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