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 8f5e26d2ee
commit a2f61a827e
17 changed files with 528 additions and 281 deletions

View file

@ -11,7 +11,7 @@ import Tooltip from "./Tooltip";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = ["email", "youtube", "quota", "backfill", "shorts", "batch"];
const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() {
@ -58,6 +58,8 @@ export default function ConfigPanel() {
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw);
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
@ -191,6 +193,7 @@ function Field({
}) {
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@ -210,8 +213,9 @@ function Field({
}
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
// toggles a secret's reset-on-save (applied on Save, not immediately).
const showReset = item.is_set;
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
// its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
return (
<div className="flex items-start justify-between gap-3 py-3">
@ -224,16 +228,20 @@ function Field({
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
onChange={(e) => onChange(e.target.value)}
min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
/>
{isBool ? (
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
) : (
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
onChange={(e) => onChange(e.target.value)}
min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
/>
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
@ -253,3 +261,19 @@ function Field({
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}