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 (
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
);
}
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 (
{children}
);
}
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 (
{t("users.roles.intro")}
{rows.length === 0 ? (
{t("users.roles.empty")}
) : (
{rows.map((u) => {
const self = u.id === me.id;
return (
{u.display_name ?? u.email.split("@")[0]}
{self && · {t("users.roles.you")} }
{u.email}
{u.role === "admin" &&
{t("users.roles.admin")} }
{u.is_demo &&
{t("users.roles.demo")} }
{!u.is_active &&
{t("users.roles.pending")} }
{u.is_active && !u.email_verified && (
{t("users.roles.unverified")}
)}
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"
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
);
})}
)}
);
}
// --- 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 (
{pending.length === 0 ? (
{t("settings.invites.noPending")}
) : (
{pending.map((i) => (
approve.mutate(i.id)}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>
))}
)}
{decided.length > 0 && (
{t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => (
{i.email}
{i.status}
))}
)}
);
}
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 (
{t("settings.demo.intro")}
{rows.length > 0 && (
{rows.map((r) => (
{r.email}
{r.added_by && (
{t("settings.demo.addedBy", { who: r.added_by })}
)}
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")}
>
))}
)}
{
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"
>
{t("settings.demo.reset")}
);
}
function InviteRow({
inv,
onApprove,
onDeny,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
{inv.email}
{inv.requested_at && (
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
)}
);
}