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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user