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

@ -31,6 +31,7 @@ import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel"; import ConfigPanel from "./components/ConfigPanel";
import AdminUsers from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel"; import NotificationsPanel from "./components/NotificationsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
@ -472,6 +473,8 @@ export default function App() {
<Scheduler /> <Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? ( ) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel /> <ConfigPanel />
) : page === "users" && meQuery.data!.role === "admin" ? (
<AdminUsers me={meQuery.data!} />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? ( ) : page === "notifications" ? (

View file

@ -0,0 +1,383 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite
// and demo data are unchanged (same tables) — only the UI relocated.
export default function AdminUsers({ me }: { me: Me }) {
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<UsersRoles me={me} />
<AdminInvites />
<AdminDemo />
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
{children}
</div>
);
}
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
? "border-amber-500/40 text-amber-500"
: "border-border text-muted";
return (
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated") });
},
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
});
const onToggle = async (u: AdminUserRow) => {
const makeAdmin = u.role !== "admin";
const ok = await confirm({
title: makeAdmin ? t("users.roles.promoteTitle") : t("users.roles.demoteTitle"),
message: t(makeAdmin ? "users.roles.promoteConfirm" : "users.roles.demoteConfirm", {
email: u.email,
}),
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
danger: !makeAdmin,
});
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user" });
};
const rows = q.data ?? [];
return (
<Section title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
<div className="space-y-1.5">
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">
{u.display_name ?? u.email.split("@")[0]}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-[11px] text-muted truncate">{u.email}</div>
</div>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{u.is_active && !u.email_verified && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
hint={
u.is_demo
? t("users.roles.demoLocked")
: self
? t("users.roles.selfLocked")
: u.role === "admin"
? t("users.roles.makeUser")
: t("users.roles.makeAdmin")
}
>
<button
onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
>
<Shield className="w-3.5 h-3.5" />
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button>
</Tooltip>
</div>
);
})}
</div>
)}
</Section>
);
}
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
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>
);
}

View file

@ -11,7 +11,7 @@ import Tooltip from "./Tooltip";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // 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. // the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together. // 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"; type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
@ -58,6 +58,8 @@ export default function ConfigPanel() {
if (it.secret) { if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key); if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw); else if (raw.trim()) await api.setConfig(key, raw);
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") { } else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else { } else {
@ -191,6 +193,7 @@ function Field({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isNum = item.type === "int"; const isNum = item.type === "int";
const isBool = item.type === "bool";
const secretDisabled = item.secret && !secretsManageable; const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value. // 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 // 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). // toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
const showReset = item.is_set; // its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
return ( return (
<div className="flex items-start justify-between gap-3 py-3"> <div className="flex items-start justify-between gap-3 py-3">
@ -224,16 +228,20 @@ function Field({
</div> </div>
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
<input {isBool ? (
type={item.secret ? "password" : isNum ? "number" : "text"} <Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
value={value} ) : (
disabled={secretDisabled || (item.secret && pendingSecretReset)} <input
onChange={(e) => onChange(e.target.value)} type={item.secret ? "password" : isNum ? "number" : "text"}
min={isNum && item.min != null ? item.min : undefined} value={value}
max={isNum && item.max != null ? item.max : undefined} disabled={secretDisabled || (item.secret && pendingSecretReset)}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} onChange={(e) => onChange(e.target.value)}
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" 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 && ( {showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}> <Tooltip hint={t("config.resetHint")}>
<button <button
@ -253,3 +261,19 @@ function Field({
</div> </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>
);
}

View file

@ -77,7 +77,9 @@ export default function Header({
? t("header.scheduler") ? t("header.scheduler")
: page === "config" : page === "config"
? t("header.configuration") ? t("header.configuration")
: t("header.channelManager")} : page === "users"
? t("header.users")
: t("header.channelManager")}
</div> </div>
)} )}
</header> </header>

View file

@ -15,6 +15,7 @@ import {
Settings, Settings,
Shield, Shield,
SlidersHorizontal, SlidersHorizontal,
Users,
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
@ -142,6 +143,7 @@ export default function NavSidebar({
? [ ? [
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, { page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") }, { page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
{ page: "users", icon: Users, label: t("header.account.users") },
] ]
: []; : [];

View file

@ -1,13 +1,11 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, Monitor, RotateCcw, Save, User } from "lucide-react";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; 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 Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // 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). // 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> </button>
</Section> </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>
);
}

View file

@ -2,6 +2,7 @@
"intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.", "intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.",
"loading": "Konfiguration wird geladen…", "loading": "Konfiguration wird geladen…",
"groups": { "groups": {
"access": "Zugang",
"email": "E-Mail / SMTP", "email": "E-Mail / SMTP",
"youtube": "YouTube-API", "youtube": "YouTube-API",
"quota": "Kontingent", "quota": "Kontingent",
@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." }, "shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." },
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, "enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, "autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." } "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
}, },
"save": "Speichern", "save": "Speichern",
"saving": "Speichern…", "saving": "Speichern…",

View file

@ -5,6 +5,7 @@
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"configuration": "Konfiguration", "configuration": "Konfiguration",
"users": "Benutzer",
"scope": { "scope": {
"label": "Feed-Quelle", "label": "Feed-Quelle",
"my": "Meine", "my": "Meine",
@ -20,6 +21,7 @@
"stats": "Statistik", "stats": "Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"config": "Konfiguration", "config": "Konfiguration",
"users": "Benutzer",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden", "signOut": "Abmelden",

View file

@ -0,0 +1,21 @@
{
"roles": {
"title": "Benutzer & Rollen",
"intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.",
"empty": "Noch keine Benutzer.",
"you": "du",
"admin": "Admin",
"demo": "Demo",
"pending": "Wartet auf Freigabe",
"unverified": "E-Mail nicht bestätigt",
"demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.",
"selfLocked": "Du kannst deine eigene Rolle nicht ändern.",
"makeAdmin": "Zum Admin machen",
"makeUser": "Zum Benutzer machen",
"updated": "Rolle aktualisiert",
"promoteTitle": "Zum Admin machen?",
"demoteTitle": "Admin entfernen?",
"promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?",
"demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer."
}
}

View file

@ -2,6 +2,7 @@
"intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.", "intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.",
"loading": "Loading configuration…", "loading": "Loading configuration…",
"groups": { "groups": {
"access": "Access",
"email": "Email / SMTP", "email": "Email / SMTP",
"youtube": "YouTube API", "youtube": "YouTube API",
"quota": "Quota", "quota": "Quota",
@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." }, "shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." },
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, "enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, "autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." } "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
}, },
"save": "Save", "save": "Save",
"saving": "Saving…", "saving": "Saving…",

View file

@ -5,6 +5,7 @@
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"configuration": "Configuration", "configuration": "Configuration",
"users": "Users",
"scope": { "scope": {
"label": "Feed source", "label": "Feed source",
"my": "Mine", "my": "Mine",
@ -20,6 +21,7 @@
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"config": "Configuration", "config": "Configuration",
"users": "Users",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",

View file

@ -0,0 +1,21 @@
{
"roles": {
"title": "Users & roles",
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
"empty": "No users yet.",
"you": "you",
"admin": "Admin",
"demo": "Demo",
"pending": "Pending approval",
"unverified": "Unverified email",
"demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin",
"makeUser": "Make user",
"updated": "Role updated",
"promoteTitle": "Make admin?",
"demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user."
}
}

View file

@ -2,6 +2,7 @@
"intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.", "intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.",
"loading": "Konfiguráció betöltése…", "loading": "Konfiguráció betöltése…",
"groups": { "groups": {
"access": "Hozzáférés",
"email": "E-mail / SMTP", "email": "E-mail / SMTP",
"youtube": "YouTube API", "youtube": "YouTube API",
"quota": "Kvóta", "quota": "Kvóta",
@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." }, "shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." },
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, "enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." } "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
}, },
"save": "Mentés", "save": "Mentés",
"saving": "Mentés…", "saving": "Mentés…",

View file

@ -5,6 +5,7 @@
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"configuration": "Konfiguráció", "configuration": "Konfiguráció",
"users": "Felhasználók",
"scope": { "scope": {
"label": "Hírfolyam forrása", "label": "Hírfolyam forrása",
"my": "Sajátom", "my": "Sajátom",
@ -20,6 +21,7 @@
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"config": "Konfiguráció", "config": "Konfiguráció",
"users": "Felhasználók",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",

View file

@ -0,0 +1,21 @@
{
"roles": {
"title": "Felhasználók és szerepek",
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
"empty": "Még nincs felhasználó.",
"you": "te",
"admin": "Admin",
"demo": "Demo",
"pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail",
"demoLocked": "A demo fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz",
"makeUser": "Felhasználóvá tesz",
"updated": "Szerep frissítve",
"promoteTitle": "Adminná teszed?",
"demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
"demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz."
}
}

View file

@ -458,6 +458,20 @@ export interface SystemConfigData {
secrets_manageable: boolean; secrets_manageable: boolean;
} }
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
email: string;
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
has_google: boolean;
created_at: string | null;
}
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
@ -574,6 +588,10 @@ export const api = {
req(`/api/admin/config/${key}`, { method: "DELETE" }), req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> => testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }), req("/api/admin/config/test-email", { method: "POST" }),
// --- admin: users & roles ---
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"), schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, { req(`/api/admin/scheduler/jobs/${jobId}`, {

View file

@ -89,6 +89,7 @@ export const PAGES = [
"settings", "settings",
"scheduler", "scheduler",
"config", "config",
"users",
"notifications", "notifications",
] as const; ] as const;