feat!: remove the shared demo account

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

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

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent cca3315836
commit 6d8b7be6d8
109 changed files with 487 additions and 1400 deletions
+3 -5
View File
@@ -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. */
+2 -19
View File
@@ -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"),
+2 -8
View File
@@ -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 }) }),
};
-1
View File
@@ -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(
+3 -7
View File
@@ -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