feat(auth): admin Users page + allow_registration toggle (5a frontend)

New admin-only Users page (sidebar): Users & roles (list + promote/demote with
confirm; self/demo/last-admin guarded server-side), plus the Access requests
(Invite whitelist) and Demo whitelist+reset migrated out of Settings → Account
(same data/tables — UI relocated only). Settings → Account now holds personal
content only. ConfigPanel learns a boolean field type (toggle) for the new
allow_registration setting. api methods, new 'users' page/route/nav/header, and
EN/HU/DE strings (new users namespace + access group).
This commit is contained in:
npeter83 2026-06-19 14:16:48 +02:00
parent 7efd4f4867
commit 737da6bd96
17 changed files with 528 additions and 281 deletions

View file

@ -1,13 +1,11 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
import { Bell, Check, Monitor, RotateCcw, Save, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Invite, type Me } from "../lib/api";
import { type Me } from "../lib/api";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
@ -412,265 +410,6 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</button>
</Section>
)}
{me.role === "admin" && <AdminInvites />}
{me.role === "admin" && <AdminDemo />}
</>
);
}
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin-invites"] });
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
};
const approve = useMutation({
mutationFn: (id: number) => api.approveInvite(id),
onSuccess: () => {
refresh();
notify({ level: "success", message: t("settings.invites.approved") });
},
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => {
setNewEmail("");
refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
},
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
});
const list = invites.data ?? [];
const pending = list.filter((i) => i.status === "pending");
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
{pending.map((i) => (
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate(i.id)}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2 mt-3"
>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{t("settings.invites.decided", { count: decided.length })}
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
{i.status}
</span>
</div>
))}
</div>
</details>
)}
</Section>
);
}
function AdminDemo() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
const [newEmail, setNewEmail] = useState("");
const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: () => {
setNewEmail("");
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.added") });
},
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
});
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
notify({
level: "success",
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
}),
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
});
const rows = list.data ?? [];
return (
<Section 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="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
<div className="text-[11px] text-muted truncate">
{t("settings.demo.addedBy", { who: r.added_by })}
</div>
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
onClick={() => remove.mutate(r.id)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2"
>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<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
onClick={async () => {
if (
await confirm({
title: t("settings.demo.resetTitle"),
message: t("settings.demo.resetConfirm"),
confirmLabel: t("settings.demo.reset"),
danger: true,
})
)
reset.mutate();
}}
disabled={reset.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</button>
</Tooltip>
</div>
</Section>
);
}
function InviteRow({
inv,
onApprove,
onDeny,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
</div>
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<button
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.invites.deny")}
>
<X className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
}