Files
Magent/frontend/app/admin/invites/page.tsx
T

2446 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import AdminShell from "../../ui/AdminShell";
type AdminUserLite = {
id: number;
username: string;
role: string;
auth_provider?: string | null;
invite_management_enabled?: boolean;
profile_id?: number | null;
expires_at?: string | null;
created_at?: string | null;
invited_by_code?: string | null;
invited_at?: string | null;
};
type Profile = {
id: number;
name: string;
description?: string | null;
role: "user" | "admin";
auto_search_enabled: boolean;
account_expires_days?: number | null;
is_active: boolean;
assigned_users?: number;
assigned_invites?: number;
};
type Invite = {
id: number;
code: string;
code_available?: boolean;
label?: string | null;
description?: string | null;
profile_id?: number | null;
profile?: { id: number; name: string } | null;
role?: "user" | "admin" | null;
max_uses?: number | null;
use_count: number;
remaining_uses?: number | null;
enabled: boolean;
expires_at?: string | null;
recipient_email?: string | null;
is_expired?: boolean;
is_usable?: boolean;
operational_state?: "ready" | "disabled" | "expired" | "exhausted" | "profile_unavailable";
state_label?: string;
attention_reason?: string | null;
created_at?: string | null;
created_by?: string | null;
};
type InviteSummary = {
total: number;
ready: number;
attention: number;
used_signups: number;
with_recipient: number;
};
type InviteForm = {
code: string;
label: string;
description: string;
profile_id: string;
role: "" | "user" | "admin";
max_uses: string;
enabled: boolean;
expires_at: string;
recipient_email: string;
message: string;
};
type InviteDeliveryMethod = "" | "manual" | "email";
type ProfileForm = {
name: string;
description: string;
role: "user" | "admin";
auto_search_enabled: boolean;
account_expires_days: string;
is_active: boolean;
};
type InviteEmailTemplateKey = "invited" | "welcome" | "warning" | "banned";
type InviteManagementTab = "bulk" | "profiles" | "invites" | "trace" | "emails";
type InviteView = "all" | "ready" | "attention" | "used";
type InviteTraceScope = "all" | "invited" | "direct";
type InviteTraceView = "list" | "graph";
type InviteEmailTemplate = {
key: InviteEmailTemplateKey;
label: string;
description: string;
placeholders: string[];
subject: string;
body_text: string;
body_html: string;
};
type InviteEmailSendForm = {
template_key: InviteEmailTemplateKey;
recipient_email: string;
invite_id: string;
username: string;
message: string;
reason: string;
};
type InviteTraceRow = {
username: string;
role: string;
authProvider: string;
level: number;
inviterUsername: string | null;
inviteCode: string | null;
inviteLabel: string | null;
createdAt: string | null;
childCount: number;
isCycle?: boolean;
};
type InvitePolicy = {
master_invite_id?: number | null;
master_invite?: Invite | null;
non_admin_users?: number;
invite_access_enabled_users?: number;
};
const futureInviteExpiry = (days: number) => {
const expires = new Date();
expires.setDate(expires.getDate() + days);
return expires.toISOString();
};
const defaultInviteForm = (profileId = "", safeDefaults = false): InviteForm => ({
code: "",
label: "",
description: "",
profile_id: profileId,
role: "",
max_uses: safeDefaults ? "1" : "",
enabled: true,
expires_at: safeDefaults ? futureInviteExpiry(7) : "",
recipient_email: "",
message: "",
});
const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({
template_key: "invited",
recipient_email: "",
invite_id: "",
username: "",
message: "",
reason: "",
});
const defaultProfileForm = (): ProfileForm => ({
name: "",
description: "",
role: "user",
auto_search_enabled: true,
account_expires_days: "",
is_active: true,
});
const formatDate = (value?: string | null) => {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || "").trim() || String(row.inviteCode || "").trim());
const isInviteOperationallyReady = (invite: Invite) =>
invite.operational_state ? invite.operational_state === "ready" : invite.is_usable !== false;
export default function AdminInviteManagementPage() {
const router = useRouter();
const [invites, setInvites] = useState<Invite[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [users, setUsers] = useState<AdminUserLite[]>([]);
const [jellyfinUsersCount, setJellyfinUsersCount] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [inviteSaving, setInviteSaving] = useState(false);
const [sendingInviteId, setSendingInviteId] = useState<number | null>(null);
const [profileSaving, setProfileSaving] = useState(false);
const [bulkProfileBusy, setBulkProfileBusy] = useState(false);
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false);
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false);
const [invitePolicySaving, setInvitePolicySaving] = useState(false);
const [templateSaving, setTemplateSaving] = useState(false);
const [templateResetting, setTemplateResetting] = useState(false);
const [emailSending, setEmailSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null);
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
const [inviteFlowStep, setInviteFlowStep] = useState(1);
const [useCustomInviteCode, setUseCustomInviteCode] = useState(false);
const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState<InviteDeliveryMethod>("");
const [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null);
const [inviteView, setInviteView] = useState<InviteView>("all");
const [profileEditingId, setProfileEditingId] = useState<number | null>(null);
const [profileForm, setProfileForm] = useState<ProfileForm>(defaultProfileForm());
const [bulkProfileId, setBulkProfileId] = useState("");
const [bulkExpiryDays, setBulkExpiryDays] = useState("");
const [masterInviteSelection, setMasterInviteSelection] = useState("");
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null);
const [activeTab, setActiveTab] = useState<InviteManagementTab>("invites");
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([]);
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null);
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>("invited");
const [templateForm, setTemplateForm] = useState({
subject: "",
body_text: "",
body_html: "",
});
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm());
const [traceFilter, setTraceFilter] = useState("");
const [traceScope, setTraceScope] = useState<InviteTraceScope>("all");
const [traceView, setTraceView] = useState<InviteTraceView>("graph");
const signupBaseUrl = useMemo(() => {
if (typeof window === "undefined") return "/signup";
return `${window.location.origin}/signup`;
}, []);
const loadTemplateEditor = (templateKey: InviteEmailTemplateKey, templates: InviteEmailTemplate[]) => {
const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null;
if (!template) {
setTemplateForm({ subject: "", body_text: "", body_html: "" });
return;
}
setSelectedTemplateKey(template.key);
setTemplateForm({
subject: template.subject ?? "",
body_text: template.body_text ?? "",
body_html: template.body_html ?? "",
});
};
const handleAuthResponse = (response: Response) => {
if (response.status === 401) {
clearToken();
router.push("/login");
return true;
}
if (response.status === 403) {
router.push("/");
return true;
}
return false;
};
const loadData = async () => {
if (!getToken()) {
router.push("/login");
return;
}
setLoading(true);
setError(null);
try {
const baseUrl = getApiBase();
const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`),
authFetch(`${baseUrl}/admin/users`),
authFetch(`${baseUrl}/admin/invites/policy`),
authFetch(`${baseUrl}/admin/invites/email/templates`),
]);
if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return;
throw new Error(`Failed to load invites (${inviteRes.status})`);
}
if (!profileRes.ok) {
if (handleAuthResponse(profileRes)) return;
throw new Error(`Failed to load profiles (${profileRes.status})`);
}
if (!usersRes.ok) {
if (handleAuthResponse(usersRes)) return;
throw new Error(`Failed to load users (${usersRes.status})`);
}
if (!policyRes.ok) {
if (handleAuthResponse(policyRes)) return;
throw new Error(`Failed to load invite policy (${policyRes.status})`);
}
if (!emailTemplateRes.ok) {
if (handleAuthResponse(emailTemplateRes)) return;
throw new Error(`Failed to load email templates (${emailTemplateRes.status})`);
}
const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([
inviteRes.json(),
profileRes.json(),
usersRes.json(),
policyRes.json(),
emailTemplateRes.json(),
]);
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null;
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []);
setInviteSummary((inviteData?.summary ?? null) as InviteSummary | null);
const nextProfiles = Array.isArray(profileData?.profiles) ? profileData.profiles : [];
setProfiles(nextProfiles);
setInviteForm((current) => {
const isPristine =
!current.code && !current.label && !current.description && !current.profile_id && !current.recipient_email;
if (!isPristine) return current;
const defaultProfile = nextProfiles.find((profile: Profile) => profile.is_active !== false);
return defaultInviteForm(defaultProfile ? String(defaultProfile.id) : "", true);
});
setUsers(Array.isArray(usersData?.users) ? usersData.users : []);
setInvitePolicy(nextPolicy);
setMasterInviteSelection(nextPolicy?.master_invite_id == null ? "" : String(nextPolicy.master_invite_id));
const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : [];
setEmailTemplates(nextTemplates);
setEmailConfigured(emailTemplateData?.email ?? null);
loadTemplateEditor(selectedTemplateKey, nextTemplates);
try {
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`);
if (jellyfinRes.ok) {
const jellyfinData = await jellyfinRes.json();
setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0);
} else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) {
if (handleAuthResponse(jellyfinRes)) return;
} else {
setJellyfinUsersCount(null);
}
} catch (jellyfinErr) {
console.warn("Could not load Jellyfin user count for invite overview", jellyfinErr);
setJellyfinUsersCount(null);
}
} catch (err) {
console.error(err);
setError("Could not load invite management data.");
} finally {
setLoading(false);
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: Initial page bootstrap deliberately runs once per mount.
useEffect(() => {
void loadData();
}, []);
const resetInviteEditor = () => {
setInviteEditingId(null);
const defaultProfile = profiles.find((profile) => profile.is_active !== false);
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : "", true));
setInviteFlowStep(1);
setUseCustomInviteCode(false);
setInviteDeliveryMethod("");
};
const editInvite = (invite: Invite) => {
setInviteEditingId(invite.id);
setInviteForm({
code: invite.code ?? "",
label: invite.label ?? "",
description: invite.description ?? "",
profile_id: typeof invite.profile_id === "number" && invite.profile_id > 0 ? String(invite.profile_id) : "",
role: (invite.role ?? "") as "" | "user" | "admin",
max_uses: typeof invite.max_uses === "number" ? String(invite.max_uses) : "",
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? "",
recipient_email: invite.recipient_email ?? "",
message: "",
});
setInviteFlowStep(4);
setUseCustomInviteCode(true);
setInviteDeliveryMethod(invite.recipient_email ? "email" : "manual");
setStatus(null);
setError(null);
};
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault();
const inviteName = inviteForm.label.trim();
if (!inviteName) {
setError("Give this invite a name so you can recognise it later.");
setStatus(null);
return;
}
const recipientEmail = inviteForm.recipient_email.trim();
if (!inviteDeliveryMethod) {
setError("Choose whether to copy the invite link yourself or send it by email.");
setStatus(null);
return;
}
if (inviteDeliveryMethod === "email" && !isValidEmail(recipientEmail)) {
setError("Recipient email must be valid.");
setStatus(null);
return;
}
setInviteSaving(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const payload = {
code: useCustomInviteCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
profile_id: inviteForm.profile_id || null,
role: inviteForm.role || null,
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
recipient_email: inviteDeliveryMethod === "email" ? recipientEmail : null,
send_email: inviteEditingId == null && inviteDeliveryMethod === "email",
message: inviteForm.message || null,
};
const url = inviteEditingId == null ? `${baseUrl}/admin/invites` : `${baseUrl}/admin/invites/${inviteEditingId}`;
const response = await authFetch(url, {
method: inviteEditingId == null ? "POST" : "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Save failed");
}
const data = await response.json();
resetInviteEditor();
if (data?.email?.status === "ok") {
setStatus(
`${inviteEditingId == null ? "Invite created" : "Invite updated"} and email sent to ${data.email.recipient_email}.`,
);
} else if (data?.email?.status === "error") {
setStatus(
`${inviteEditingId == null ? "Invite created" : "Invite updated"}, but email failed: ${data.email.detail}`,
);
} else {
setStatus(
inviteEditingId == null
? inviteDeliveryMethod === "manual"
? "Invite created. Copy the link below when you are ready to share it."
: "Invite created."
: "Invite updated.",
);
}
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not save invite.");
} finally {
setInviteSaving(false);
}
};
const deleteInvite = async (invite: Invite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return;
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, {
method: "DELETE",
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Delete failed");
}
if (inviteEditingId === invite.id) resetInviteEditor();
setStatus(`Deleted invite ${invite.code}.`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not delete invite.");
}
};
const copyInviteLink = async (invite: Invite) => {
try {
let usableInvite = invite;
if (!invite.code_available) {
const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, {
method: "POST",
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
throw new Error((await response.text()) || "Could not generate a replacement link.");
}
const data = await response.json();
usableInvite = data.invite as Invite;
setInvites((current) => current.map((item) => (item.id === invite.id ? usableInvite : item)));
}
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`;
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`);
} else {
window.prompt("Copy invite link", url);
}
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not generate or copy the invite link.");
}
};
const prepareInviteEmail = (invite: Invite) => {
setEmailSendForm({
template_key: "invited",
recipient_email: invite.recipient_email ?? "",
invite_id: String(invite.id),
username: "",
message: "",
reason: "",
});
setActiveTab("emails");
setStatus(
invite.recipient_email
? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.`
: `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`,
);
setError(null);
};
const sendSavedInvite = async (invite: Invite) => {
if (!invite.recipient_email) {
editInvite(invite);
setError("Add a recipient email before sending this invite.");
return;
}
if (!emailConfigured?.configured) {
setActiveTab("emails");
setError(emailConfigured?.detail ?? "Configure SMTP before sending invite emails.");
return;
}
setSendingInviteId(invite.id);
setError(null);
setStatus(null);
try {
const response = await authFetch(`${getApiBase()}/admin/invites/email/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_key: "invited",
invite_id: invite.id,
recipient_email: invite.recipient_email,
}),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const payload = await response.json().catch(() => null);
throw new Error(payload?.detail || `Email delivery failed (${response.status})`);
}
setStatus(`Invite ${invite.code} was sent to ${invite.recipient_email}.`);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not send the invite email.");
} finally {
setSendingInviteId(null);
}
};
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
setSelectedTemplateKey(templateKey);
loadTemplateEditor(templateKey, emailTemplates);
};
const saveEmailTemplate = async () => {
setTemplateSaving(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(templateForm),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Template save failed");
}
setStatus(`Saved ${selectedTemplateKey} email template.`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not save email template.");
} finally {
setTemplateSaving(false);
}
};
const resetEmailTemplate = async () => {
if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return;
setTemplateResetting(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: "DELETE",
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Template reset failed");
}
setStatus(`Reset ${selectedTemplateKey} template to default.`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not reset email template.");
} finally {
setTemplateResetting(false);
}
};
const sendEmailTemplate = async (event: React.FormEvent) => {
event.preventDefault();
setEmailSending(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/invites/email/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_key: emailSendForm.template_key,
recipient_email: emailSendForm.recipient_email || null,
invite_id: emailSendForm.invite_id || null,
username: emailSendForm.username || null,
message: emailSendForm.message || null,
reason: emailSendForm.reason || null,
}),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Email send failed");
}
const data = await response.json();
setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? "recipient"}.`);
if (emailSendForm.template_key === "invited") {
await loadData();
}
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not send email.");
} finally {
setEmailSending(false);
}
};
const resetProfileEditor = () => {
setProfileEditingId(null);
setProfileForm(defaultProfileForm());
};
const editProfile = (profile: Profile) => {
setProfileEditingId(profile.id);
setProfileForm({
name: profile.name ?? "",
description: profile.description ?? "",
role: profile.role ?? "user",
auto_search_enabled: Boolean(profile.auto_search_enabled),
account_expires_days:
typeof profile.account_expires_days === "number" ? String(profile.account_expires_days) : "",
is_active: profile.is_active !== false,
});
setStatus(null);
setError(null);
};
const saveProfile = async (event: React.FormEvent) => {
event.preventDefault();
setProfileSaving(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const payload = {
name: profileForm.name,
description: profileForm.description || null,
role: profileForm.role,
auto_search_enabled: profileForm.auto_search_enabled,
account_expires_days: profileForm.account_expires_days || null,
is_active: profileForm.is_active,
};
const url =
profileEditingId == null ? `${baseUrl}/admin/profiles` : `${baseUrl}/admin/profiles/${profileEditingId}`;
const response = await authFetch(url, {
method: profileEditingId == null ? "POST" : "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Save failed");
}
setStatus(profileEditingId == null ? "Profile created." : "Profile updated.");
resetProfileEditor();
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not save profile.");
} finally {
setProfileSaving(false);
}
};
const deleteProfile = async (profile: Profile) => {
if (!window.confirm(`Delete profile "${profile.name}"?`)) return;
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, {
method: "DELETE",
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Delete failed");
}
if (profileEditingId === profile.id) resetProfileEditor();
if (bulkProfileId === String(profile.id)) setBulkProfileId("");
setStatus(`Deleted profile "${profile.name}".`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not delete profile.");
}
};
const bulkApplyProfile = async () => {
setBulkProfileBusy(true);
setStatus(null);
setError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
profile_id: bulkProfileId || null,
scope: "non-admin-users",
}),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Bulk profile update failed");
}
const data = await response.json();
setStatus(
bulkProfileId
? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.`
: `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.`,
);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not apply profile to all users.");
} finally {
setBulkProfileBusy(false);
}
};
const bulkSetExpiryDays = async () => {
if (!bulkExpiryDays.trim()) {
setError("Enter expiry days before applying bulk expiry.");
return;
}
setBulkExpiryBusy(true);
setStatus(null);
setError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days: bulkExpiryDays, scope: "non-admin-users" }),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Bulk expiry update failed");
}
const data = await response.json();
setStatus(`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not set expiry for all users.");
} finally {
setBulkExpiryBusy(false);
}
};
const bulkClearExpiry = async () => {
setBulkExpiryBusy(true);
setStatus(null);
setError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clear: true, scope: "non-admin-users" }),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Bulk expiry clear failed");
}
const data = await response.json();
setStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not clear expiry for all users.");
} finally {
setBulkExpiryBusy(false);
}
};
const bulkSetInviteAccess = async (enabled: boolean) => {
setBulkInviteAccessBusy(true);
setStatus(null);
setError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Bulk invite access update failed");
}
const data = await response.json();
setStatus(`${enabled ? "Enabled" : "Disabled"} self-service invites for ${data?.updated ?? 0} non-admin users.`);
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not update invite access for all users.");
} finally {
setBulkInviteAccessBusy(false);
}
};
const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => {
const selectedValue = nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || "";
setInvitePolicySaving(true);
setStatus(null);
setError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/invites/policy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ master_invite_id: selectedValue || null }),
});
if (!response.ok) {
if (handleAuthResponse(response)) return;
const text = await response.text();
throw new Error(text || "Invite policy update failed");
}
setStatus(selectedValue ? "Master invite template updated." : "Master invite template cleared.");
await loadData();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not update invite policy.");
} finally {
setInvitePolicySaving(false);
}
};
const nonAdminUsers = users.filter((user) => user.role !== "admin");
const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length;
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length;
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length;
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length;
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || "").trim())).length;
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length;
const masterInvite = invitePolicy?.master_invite ?? null;
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null;
const inviteCodeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
const inviteIdentityReady = Boolean(
inviteForm.label.trim() && (!useCustomInviteCode || inviteCodeCharacters.length >= 6),
);
const inviteAttentionCount =
inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length;
const filteredInvites = useMemo(() => {
if (inviteView === "ready") return invites.filter(isInviteOperationallyReady);
if (inviteView === "attention") return invites.filter((invite) => !isInviteOperationallyReady(invite));
if (inviteView === "used") return invites.filter((invite) => invite.use_count > 0);
return invites;
}, [inviteView, invites]);
const traceUserIds = useMemo(
() => new Map(users.map((user) => [user.username.trim().toLowerCase(), user.id])),
[users],
);
const traceAccountLink = (username: string, className?: string) => {
const id = traceUserIds.get(username.trim().toLowerCase());
return id != null ? (
<Link
className={`invite-trace-account-link ${className || ""}`}
href={`/users/${id}`}
title={`Manage ${username}'s account`}
>
{username}
</Link>
) : (
<span className={className}>{username}</span>
);
};
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>();
invites.forEach((invite) => {
const code = String(invite.code || "").trim();
if (code) inviteByCode.set(code.toLowerCase(), invite);
});
const userByName = new Map<string, AdminUserLite>();
users.forEach((user) => {
const username = String(user.username || "").trim();
if (username) userByName.set(username.toLowerCase(), user);
});
const childrenByInviter = new Map<string, AdminUserLite[]>();
const inviterMetaByUser = new Map<
string,
{ inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null }
>();
users.forEach((user) => {
const username = String(user.username || "").trim();
if (!username) return;
const inviteCodeRaw = String(user.invited_by_code || "").trim();
let inviterUsername: string | null = null;
let inviteLabel: string | null = null;
if (inviteCodeRaw) {
const invite = inviteByCode.get(inviteCodeRaw.toLowerCase());
inviteLabel = (invite?.label as string | undefined) || null;
const createdBy = String(invite?.created_by || "").trim();
if (createdBy) inviterUsername = createdBy;
}
inviterMetaByUser.set(username.toLowerCase(), {
inviterUsername,
inviteCode: inviteCodeRaw || null,
inviteLabel,
});
const key = (inviterUsername || "__root__").toLowerCase();
const bucket = childrenByInviter.get(key) ?? [];
bucket.push(user);
childrenByInviter.set(key, bucket);
});
childrenByInviter.forEach((bucket) => {
bucket.sort((a, b) =>
String(a.username || "").localeCompare(String(b.username || ""), undefined, { sensitivity: "base" }),
);
});
const rows: Array<{
username: string;
role: string;
authProvider: string;
level: number;
inviterUsername: string | null;
inviteCode: string | null;
inviteLabel: string | null;
createdAt: string | null;
childCount: number;
isCycle?: boolean;
}> = [];
const visited = new Set<string>();
const walk = (user: AdminUserLite, level: number, path: Set<string>) => {
const username = String(user.username || "").trim();
const userKey = username.toLowerCase();
if (!username) return;
const meta = inviterMetaByUser.get(userKey) ?? {
inviterUsername: null,
inviteCode: null,
inviteLabel: null,
};
const childCount = (childrenByInviter.get(userKey) ?? []).length;
if (path.has(userKey)) {
rows.push({
username,
role: String(user.role || "user"),
authProvider: String(user.auth_provider || "local"),
level,
inviterUsername: meta.inviterUsername,
inviteCode: meta.inviteCode,
inviteLabel: meta.inviteLabel,
createdAt: (user.created_at as string | null) ?? null,
childCount,
isCycle: true,
});
return;
}
rows.push({
username,
role: String(user.role || "user"),
authProvider: String(user.auth_provider || "local"),
level,
inviterUsername: meta.inviterUsername,
inviteCode: meta.inviteCode,
inviteLabel: meta.inviteLabel,
createdAt: (user.created_at as string | null) ?? null,
childCount,
});
visited.add(userKey);
const nextPath = new Set(path);
nextPath.add(userKey);
(childrenByInviter.get(userKey) ?? []).forEach((child) => {
walk(child, level + 1, nextPath);
});
};
(childrenByInviter.get("__root__") ?? []).forEach((rootUser) => {
walk(rootUser, 0, new Set());
});
users.forEach((user) => {
const key = String(user.username || "").toLowerCase();
if (key && !visited.has(key)) {
walk(user, 0, new Set());
}
});
const filter = traceFilter.trim().toLowerCase();
if (!filter) return rows;
return rows.filter((row) =>
[
row.username,
row.inviterUsername || "",
row.inviteCode || "",
row.inviteLabel || "",
row.role || "",
row.authProvider || "",
]
.join(" ")
.toLowerCase()
.includes(filter),
);
}, [invites, traceFilter, users]);
const scopedInviteTraceRows = useMemo(() => {
if (traceScope === "invited") return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row));
if (traceScope === "direct") return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row));
return inviteTraceRows;
}, [inviteTraceRows, traceScope]);
const traceInvitedCount = useMemo(
() => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length,
[inviteTraceRows],
);
const traceDirectCount = inviteTraceRows.length - traceInvitedCount;
const inviteTraceGraphColumns = useMemo(() => {
if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }>;
const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level));
const grouped = new Map<number, InviteTraceRow[]>();
scopedInviteTraceRows.forEach((row) => {
const level = Math.max(0, row.level - minLevel);
const bucket = grouped.get(level) ?? [];
bucket.push(row);
grouped.set(level, bucket);
});
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map(([level, rows]) => ({
level,
rows: [...rows].sort((a, b) =>
String(a.username || "").localeCompare(String(b.username || ""), undefined, {
sensitivity: "base",
}),
),
}));
}, [scopedInviteTraceRows]);
const inviteManagementRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<span className="admin-rail-eyebrow">Overview</span>
<h2>Invite stats</h2>
<p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>
{inviteSummary?.ready ?? usableInvites} ready {inviteAttentionCount} need attention
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? "—"}</strong>
<span>{jellyfinUsersCount == null ? "Unavailable/not configured" : "Current Jellyfin user objects"}</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: "users enabled • no master template set"}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Email templates</span>
<div className="invite-admin-summary-row__value">
<strong>{emailTemplates.length}</strong>
<span>{invitesWithRecipient} invites with recipient email</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">SMTP email</span>
<div className="invite-admin-summary-row__value">
<strong>{emailConfigured?.configured ? "Ready" : "Needs setup"}</strong>
<span>{emailConfigured?.detail ?? "Email settings unavailable"}</span>
</div>
</div>
</div>
</div>
<div className="admin-rail-card invite-automation-card">
<div>
<span className="admin-rail-eyebrow">Automatic setup</span>
<h2>Readiness checks</h2>
<p>Magent validates each link and applies safer defaults to new invitations.</p>
</div>
<div className="invite-readiness-list">
<button type="button" onClick={() => setActiveTab("invites")}>
<span>New invite defaults</span>
<strong>1 use · 7 days</strong>
</button>
<button type="button" onClick={() => setActiveTab("profiles")}>
<span>Active access profiles</span>
<strong>{activeProfiles > 0 ? `${activeProfiles} ready` : "Needs setup"}</strong>
</button>
<button type="button" onClick={() => setActiveTab("bulk")}>
<span>Self-service policy</span>
<strong>{masterInvite ? "Configured" : "Not configured"}</strong>
</button>
<button type="button" onClick={() => setActiveTab("emails")}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? "Ready" : "Needs setup"}</strong>
</button>
</div>
</div>
</div>
);
return (
<AdminShell
title="Invite policy & access"
subtitle="Manage account defaults, profiles, and invitations across Magent."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<section className="invite-operations-strip" aria-label="Invite operations overview">
<button
type="button"
onClick={() => {
setInviteView("ready");
setActiveTab("invites");
}}
>
<span>Ready</span>
<strong>{inviteSummary?.ready ?? usableInvites}</strong>
<small>can be used now</small>
</button>
<button
type="button"
onClick={() => {
setInviteView("attention");
setActiveTab("invites");
}}
>
<span>Needs attention</span>
<strong>{inviteAttentionCount}</strong>
<small>expired, used, disabled, or misconfigured</small>
</button>
<button
type="button"
onClick={() => {
setInviteView("used");
setActiveTab("invites");
}}
>
<span>Successful sign-ups</span>
<strong>
{inviteSummary?.used_signups ?? invites.reduce((total, invite) => total + invite.use_count, 0)}
</strong>
<small>recorded from invite links</small>
</button>
<button type="button" onClick={() => setActiveTab("emails")}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? "Ready" : "Setup"}</strong>
<small>{emailConfigured?.configured ? "one-click sending available" : "SMTP needs attention"}</small>
</button>
</section>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === "invites"}
className={activeTab === "invites" ? "is-active" : ""}
onClick={() => setActiveTab("invites")}
>
Invite links
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "profiles"}
className={activeTab === "profiles" ? "is-active" : ""}
onClick={() => setActiveTab("profiles")}
>
Profiles
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "bulk"}
className={activeTab === "bulk" ? "is-active" : ""}
onClick={() => setActiveTab("bulk")}
>
Automation
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "emails"}
className={activeTab === "emails" ? "is-active" : ""}
onClick={() => setActiveTab("emails")}
>
Delivery
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "trace"}
className={activeTab === "trace" ? "is-active" : ""}
onClick={() => setActiveTab("trace")}
>
Lineage
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
{loading ? "Loading…" : "Reload"}
</button>
<button type="button" className="ghost-button" onClick={() => router.push("/profile/invites")}>
Create invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor();
setActiveTab("profiles");
}}
>
New profile
</button>
</div>
</div>
{activeTab === "bulk" && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Blanket controls</h2>
<p className="lede">
Apply invite access, master invite template rules, profile defaults, or expiry to all local
non-admin accounts. Individual users can still be edited from their user page.
</p>
</div>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<div className="user-bulk-group-meta">
<strong>Self-service invites</strong>
<span className="meta">Enable or disable the My invites tab for all non-admin users.</span>
</div>
<div className="admin-inline-actions">
<button
type="button"
onClick={() => void bulkSetInviteAccess(true)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? "Working…" : "Enable for all users"}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void bulkSetInviteAccess(false)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? "Working…" : "Disable for all users"}
</button>
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Master invite template</span>
<select
value={masterInviteSelection}
onChange={(e) => setMasterInviteSelection(e.target.value)}
disabled={invitePolicySaving}
>
<option value="">None (users use their own defaults)</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ""}
{invite.enabled === false ? " (disabled)" : ""}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void saveMasterInvitePolicy()} disabled={invitePolicySaving}>
{invitePolicySaving ? "Saving…" : "Save master template"}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setMasterInviteSelection("");
void saveMasterInvitePolicy("");
}}
disabled={invitePolicySaving}
>
{invitePolicySaving ? "Saving…" : "Clear master template"}
</button>
</div>
<div className="user-detail-helper">
{masterInvite
? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ""}. Self-service invites inherit its limits/status/profile.`
: "No master template set. Self-service invites use each users profile/defaults."}
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Profile</span>
<select
value={bulkProfileId}
onChange={(e) => setBulkProfileId(e.target.value)}
disabled={bulkProfileBusy}
>
<option value="">None / clear assignment</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.is_active === false ? " (disabled)" : ""}
</option>
))}
</select>
</label>
<button type="button" onClick={bulkApplyProfile} disabled={bulkProfileBusy}>
{bulkProfileBusy ? "Applying…" : "Apply profile to all users"}
</button>
</div>
<div className="user-bulk-group">
<label>
<span className="user-bulk-label">Expiry days</span>
<input
value={bulkExpiryDays}
onChange={(e) => setBulkExpiryDays(e.target.value)}
inputMode="numeric"
placeholder="e.g. 30"
disabled={bulkExpiryBusy}
/>
</label>
<button type="button" onClick={bulkSetExpiryDays} disabled={bulkExpiryBusy}>
{bulkExpiryBusy ? "Working…" : "Set expiry for all users"}
</button>
<button type="button" className="ghost-button" onClick={bulkClearExpiry} disabled={bulkExpiryBusy}>
{bulkExpiryBusy ? "Working…" : "Clear expiry for all users"}
</button>
</div>
</div>
</div>
</div>
)}
{activeTab === "profiles" && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<h2>Profiles</h2>
<p className="lede">
Assign these to invites or apply them to all users using the blanket controls above.
</p>
{loading ? (
<div className="status-banner">Loading profiles</div>
) : profiles.length === 0 ? (
<div className="status-banner">No profiles created yet.</div>
) : (
<div className="admin-list">
{profiles.map((profile) => (
<div key={profile.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>{profile.name}</strong>
<span className={`small-pill ${profile.is_active ? "" : "is-muted"}`}>
{profile.is_active ? "Active" : "Disabled"}
</span>
<span className="small-pill">{profile.role}</span>
</div>
{profile.description && <p className="admin-list-item-text">{profile.description}</p>}
<div className="admin-meta-row">
<span>Auto search: {profile.auto_search_enabled ? "On" : "Off"}</span>
<span>
Account expiry:{" "}
{typeof profile.account_expires_days === "number"
? `${profile.account_expires_days} days`
: "Never"}
</span>
<span>Users: {profile.assigned_users ?? 0}</span>
<span>Invites: {profile.assigned_invites ?? 0}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => editProfile(profile)}>
Edit
</button>
<button type="button" onClick={() => deleteProfile(profile)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>{profileEditingId == null ? "Create profile" : "Edit profile"}</h2>
<p className="lede">Profiles define defaults applied when a user signs up using an invite.</p>
<form onSubmit={saveProfile} className="admin-form compact-form invite-form-layout profile-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Name and description used to identify the reusable profile.</small>
</div>
<div className="invite-form-row-control">
<label>
<span>Profile name</span>
<input
value={profileForm.name}
onChange={(e) => setProfileForm((current) => ({ ...current, name: e.target.value }))}
placeholder="Standard users"
required
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note to explain when this profile should be used.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={profileForm.description}
onChange={(e) => setProfileForm((current) => ({ ...current, description: e.target.value }))}
placeholder="Default invite settings for normal users"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Defaults</span>
<small>Base role and optional account expiry applied from this profile.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Role</span>
<select
value={profileForm.role}
onChange={(e) =>
setProfileForm((current) => ({
...current,
role: e.target.value as "user" | "admin",
}))
}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<label>
<span>Account expiry (days)</span>
<input
value={profileForm.account_expires_days}
onChange={(e) =>
setProfileForm((current) => ({
...current,
account_expires_days: e.target.value,
}))
}
inputMode="numeric"
placeholder="Blank = no expiry"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Default auto-download behavior and whether the profile is active.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={profileForm.auto_search_enabled}
onChange={(e) =>
setProfileForm((current) => ({
...current,
auto_search_enabled: e.target.checked,
}))
}
/>
Allow auto search/download by default
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={profileForm.is_active}
onChange={(e) => setProfileForm((current) => ({ ...current, is_active: e.target.checked }))}
/>
Profile is active
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={profileSaving}>
{profileSaving ? "Saving…" : profileEditingId == null ? "Create profile" : "Save profile"}
</button>
{profileEditingId != null && (
<button type="button" className="ghost-button" onClick={resetProfileEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
</div>
</div>
)}
{activeTab === "invites" && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="invite-list-heading">
<div>
<h2>Invite links</h2>
<p className="lede">
Magent checks availability, usage, expiry, and assigned profiles automatically.
</p>
</div>
<fieldset className="invite-view-filter">
<legend>Filter invite links</legend>
{(
[
["all", `All ${invites.length}`],
["ready", `Ready ${inviteSummary?.ready ?? usableInvites}`],
["attention", `Attention ${inviteAttentionCount}`],
["used", `Used ${invites.filter((invite) => invite.use_count > 0).length}`],
] as Array<[InviteView, string]>
).map(([value, label]) => (
<button
key={value}
type="button"
className={inviteView === value ? "is-active" : ""}
onClick={() => setInviteView(value)}
>
{label}
</button>
))}
</fieldset>
</div>
{loading ? (
<div className="status-banner">Loading invites</div>
) : filteredInvites.length === 0 ? (
<div className="status-banner">No invite links match this view.</div>
) : (
<div className="admin-list">
{filteredInvites.map((invite) => (
<div
key={invite.id}
className={`admin-list-item invite-list-item is-${invite.operational_state ?? "ready"}`}
>
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>{invite.label || "Unnamed invite"}</strong>
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? "ready"}`}>
{invite.state_label ?? (invite.is_usable ? "Ready to use" : "Unavailable")}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.attention_reason ? (
<p className="invite-attention-reason">{invite.attention_reason}</p>
) : null}
<p className="admin-list-item-text">
<code className="invite-code">{invite.code}</code>
</p>
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>
)}
<div className="admin-meta-row">
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === "number" ? ` / ${invite.max_uses}` : ""}
</span>
<span>Remaining: {invite.remaining_uses ?? "Unlimited"}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Recipient: {invite.recipient_email || "Not set"}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
{invite.code_available ? "Copy link" : "Generate replacement link"}
</button>
<button
type="button"
className="ghost-button"
disabled={sendingInviteId === invite.id || !isInviteOperationallyReady(invite)}
onClick={() => void sendSavedInvite(invite)}
>
{sendingInviteId === invite.id ? "Sending…" : "Send email"}
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Delivery options
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<div className="invite-flow-heading">
<div>
<span className="eyebrow">Invite operations</span>
<h2>
{inviteEditingId == null ? "Client invite workspace" : `Edit ${inviteForm.label || "invite"}`}
</h2>
<p className="lede">Creation is client-facing. Admins can still maintain existing invites here.</p>
</div>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
{inviteEditingId == null ? (
<div className="invite-created-card">
<span className="eyebrow">Invites</span>
<h3>Create invites from the client workspace</h3>
<p>
The guided invite flow now lives in the main navigation where users can create and manage their own
invitations.
</p>
<button type="button" onClick={() => router.push("/profile/invites")}>
Open client invite workspace
</button>
</div>
) : (
<form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress">
{["Identity", "Description", "Access", "Delivery"].map((label, index) => {
const step = index + 1;
return (
<li
key={label}
className={step === inviteFlowStep ? "is-active" : step < inviteFlowStep ? "is-complete" : ""}
>
<span>{String(step).padStart(2, "0")}</span>
<strong>{label}</strong>
</li>
);
})}
</ol>
<section className={`invite-flow-step ${inviteFlowStep > 1 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">01</span>
<div>
<span className="eyebrow">Identity</span>
<h3>Who is this invite for?</h3>
<p>Give it a name that will make sense when you return later.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Invite name</span>
<input
value={inviteForm.label}
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
placeholder="Family, that guy from work, the neighbour"
/>
</label>
<label className="invite-flow-choice-line">
<input
type="checkbox"
checked={useCustomInviteCode}
onChange={(event) => {
setUseCustomInviteCode(event.target.checked);
if (!event.target.checked) {
setInviteForm((current) => ({ ...current, code: "" }));
}
}}
disabled={inviteEditingId != null}
/>
<span>
<strong>Choose a custom invite code</strong>
<small>
The code appears at the end of the sign-up link. Leave this off and Magent will create a
secure code for you.
</small>
</span>
</label>
{useCustomInviteCode && (
<label>
<span>Custom code</span>
<input
value={inviteForm.code}
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
placeholder="At least 6 letters or numbers"
disabled={inviteEditingId != null}
/>
<small>
This becomes <code>/signup?code={inviteForm.code || "YOUR-CODE"}</code>.
</small>
</label>
)}
{inviteFlowStep === 1 && (
<div className="invite-flow-actions">
<button type="button" disabled={!inviteIdentityReady} onClick={() => setInviteFlowStep(2)}>
Continue to description
</button>
</div>
)}
</div>
</section>
{inviteFlowStep >= 2 && (
<section className={`invite-flow-step ${inviteFlowStep > 2 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">02</span>
<div>
<span className="eyebrow">Description</span>
<h3>Add a welcome note</h3>
<p>This optional message is shown to the person on the sign-up page.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Welcome note (optional)</span>
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome! Use this link to create your account."
/>
</label>
{inviteFlowStep === 2 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(1)}>
Back
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setInviteForm((current) => ({ ...current, description: "" }));
setInviteFlowStep(3);
}}
>
Skip
</button>
<button type="button" onClick={() => setInviteFlowStep(3)}>
Continue
</button>
</div>
)}
</div>
</section>
)}
{inviteFlowStep >= 3 && (
<section className={`invite-flow-step ${inviteFlowStep > 3 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">03</span>
<div>
<span className="eyebrow">Access</span>
<h3>Choose their account access</h3>
<p>The selected profile controls their defaults when they sign up.</p>
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-flow-field-grid">
<label>
<span>Profile</span>
<select
value={inviteForm.profile_id}
onChange={(event) =>
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
}
>
<option value="">Use system defaults</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.is_active === false ? " (disabled)" : ""}
</option>
))}
</select>
</label>
<label>
<span>Permission</span>
<select
value={inviteForm.role}
onChange={(event) =>
setInviteForm((current) => ({
...current,
role: event.target.value as "" | "user" | "admin",
}))
}
>
<option value="">Use profile default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<div className="invite-policy-note">
<strong>Invite limits are automatic</strong>
<span>
Magent applies the configured usage and expiry rules. Those controls will live in Config.
</span>
</div>
{inviteFlowStep === 3 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(2)}>
Back
</button>
<button type="button" onClick={() => setInviteFlowStep(4)}>
Continue to delivery
</button>
</div>
)}
</div>
</section>
)}
{inviteFlowStep >= 4 && (
<section className="invite-flow-step is-active">
<header>
<span className="invite-flow-number">04</span>
<div>
<span className="eyebrow">Delivery</span>
<h3>How will they receive it?</h3>
<p>Copy the link yourself, or let Magent email it directly.</p>
</div>
</header>
<div className="invite-flow-fields">
<InviteDeliveryChoice
value={inviteDeliveryMethod}
onChange={(method) => {
setInviteDeliveryMethod(method);
if (method === "manual")
setInviteForm((current) => ({ ...current, recipient_email: "", message: "" }));
}}
/>
{inviteDeliveryMethod === "manual" && (
<div className="invite-delivery-summary">
<strong>Your link will appear as soon as the invite is created.</strong>
<span>No email address is required and Magent will not send a message.</span>
</div>
)}
{inviteDeliveryMethod === "email" && (
<div className="invite-flow-field-grid invite-delivery-fields">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Email note (optional)</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="A short personal message"
/>
</label>
</div>
)}
{inviteEditingId != null && (
<label className="invite-status-control">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<span>
<strong>{inviteForm.enabled ? "Invite enabled" : "Invite disabled"}</strong>
<small>Disable this existing invite to stop its link from accepting sign-ups.</small>
</span>
</label>
)}
{inviteDeliveryMethod === "email" && emailConfigured?.configured === false && (
<div className="status-banner">
{emailConfigured.detail || "Configure SMTP before sending an invite by email."}
</div>
)}
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(3)}>
Back
</button>
<button
type="submit"
disabled={
inviteSaving ||
!inviteDeliveryMethod ||
(inviteDeliveryMethod === "email" &&
(!isValidEmail(inviteForm.recipient_email) || emailConfigured?.configured === false))
}
>
{inviteSaving
? "Creating invite…"
: inviteEditingId != null
? "Save invite"
: inviteDeliveryMethod === "email"
? "Create and email invite"
: "Create invite link"}
</button>
</div>
</div>
</section>
)}
</form>
)}
</div>
</div>
)}
{activeTab === "emails" && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Email templates</h2>
<p className="lede">
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
</p>
</div>
</div>
{!emailConfigured?.configured && (
<div className="status-banner">
{emailConfigured?.detail ?? "Configure SMTP under Notifications before sending invite emails."}
</div>
)}
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
{emailTemplates.map((template) => (
<button
key={template.key}
type="button"
className={selectedTemplateKey === template.key ? "is-active" : ""}
onClick={() => selectEmailTemplate(template.key)}
>
{template.label}
</button>
))}
</div>
{selectedTemplate ? (
<div className="invite-email-template-meta">
<h3>{selectedTemplate.label}</h3>
<p className="lede">{selectedTemplate.description}</p>
</div>
) : null}
<form
className="admin-form compact-form invite-form-layout invite-email-template-form"
onSubmit={(event) => {
event.preventDefault();
void saveEmailTemplate();
}}
>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Subject</span>
<small>Rendered with the same placeholder variables as the body.</small>
</div>
<div className="invite-form-row-control">
<input
value={templateForm.subject}
onChange={(event) => setTemplateForm((current) => ({ ...current, subject: event.target.value }))}
placeholder="Email subject"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Plain text body</span>
<small>Used for mail clients that prefer text only.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={12}
value={templateForm.body_text}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
}
placeholder="Plain text email body"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>HTML body</span>
<small>Optional rich HTML version. Basic HTML is supported.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={14}
value={templateForm.body_html}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
}
placeholder="<h1>Hello</h1><p>HTML email body</p>"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Placeholders</span>
<small>Use these anywhere in the subject or body.</small>
</div>
<div className="invite-form-row-control invite-email-placeholder-list">
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
<code key={placeholder}>{`{{${placeholder}}}`}</code>
))}
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={templateSaving}>
{templateSaving ? "Saving…" : "Save template"}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void resetEmailTemplate()}
disabled={templateResetting}
>
{templateResetting ? "Resetting…" : "Reset to default"}
</button>
</div>
</form>
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>Send email</h2>
<p className="lede">
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email
address.
</p>
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Template</span>
<small>Select which lifecycle email to send.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Template</span>
<select
value={emailSendForm.template_key}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
template_key: event.target.value as InviteEmailTemplateKey,
}))
}
>
{emailTemplates.map((template) => (
<option key={template.key} value={template.key}>
{template.label}
</option>
))}
</select>
</label>
<label>
<span>Recipient email</span>
<input
type="email"
value={emailSendForm.recipient_email}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Optional if invite/user already has one"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Context</span>
<small>Link the email to an invite or username to fill placeholders automatically.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Invite</span>
<select
value={emailSendForm.invite_id}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
invite_id: event.target.value,
}))
}
>
<option value="">None</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ""}
</option>
))}
</select>
</label>
<label>
<span>Username</span>
<input
value={emailSendForm.username}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
username: event.target.value,
}))
}
placeholder="Optional user lookup"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Reason / note</span>
<small>Used by warning and banned templates, and appended to other emails.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Reason</span>
<input
value={emailSendForm.reason}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
}
placeholder="Optional reason"
/>
</label>
<label>
<span>Message</span>
<textarea
rows={6}
value={emailSendForm.message}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="Optional message"
/>
</label>
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
{emailSending ? "Sending…" : "Send email"}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === "trace" && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite trace map</h2>
<p className="lede">
Visual lineage of who invited who, including the invite code used for each sign-up.
</p>
</div>
</div>
<div className="invite-trace-toolbar">
<label className="invite-trace-filter">
<span>Find user / inviter / code</span>
<input
type="search"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-controls">
<label className="invite-trace-scope">
<span>Scope</span>
<select value={traceScope} onChange={(e) => setTraceScope(e.target.value as InviteTraceScope)}>
<option value="all">All users</option>
<option value="invited">Invited only</option>
<option value="direct">Direct / root only</option>
</select>
</label>
<fieldset className="invite-trace-view-toggle">
<legend>Trace view mode</legend>
<button
type="button"
className={traceView === "graph" ? "is-active" : ""}
onClick={() => setTraceView("graph")}
>
Graph
</button>
<button
type="button"
className={traceView === "list" ? "is-active" : ""}
onClick={() => setTraceView("list")}
>
List
</button>
</fieldset>
</div>
<div className="invite-trace-summary">
<span>{scopedInviteTraceRows.length} rows shown</span>
<span>{traceInvitedCount} invited</span>
<span>{traceDirectCount} direct/root</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : scopedInviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : traceView === "graph" ? (
<div className="invite-trace-graph">
{inviteTraceGraphColumns.map((column) => (
<section key={`trace-level-${column.level}`} className="invite-trace-column">
<header className="invite-trace-column-header">
<span>Level {column.level}</span>
<strong>{column.rows.length}</strong>
</header>
<div className="invite-trace-column-body">
{column.rows.map((row) => (
<article
key={`${row.username}-${column.level}-${row.inviteCode || "direct"}`}
className="invite-trace-node"
>
<div className="invite-trace-node-main">
<div className="invite-trace-node-title">
{traceAccountLink(row.username, "invite-trace-user")}
<span className={`small-pill ${row.role === "admin" ? "" : "is-muted"}`}>
{row.role}
</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<p className={`invite-trace-node-arrow ${isInviteTraceRowInvited(row) ? "" : "is-root"}`}>
{row.inviterUsername ? (
<> Invited by {traceAccountLink(row.inviterUsername)}</>
) : row.inviteCode ? (
`\u2190 Invited via code ${row.inviteCode}`
) : (
"Direct/root account"
)}
</p>
</div>
<div className="invite-trace-node-meta">
<span className="invite-trace-node-meta-item">
<span className="label">Invite code</span>
<strong>{row.inviteCode || "None"}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || "None"}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
) : (
<div className="invite-trace-map">
{scopedInviteTraceRows.map((row) => (
<div
key={`${row.username}-${row.level}-${row.inviteCode || "direct"}`}
className="invite-trace-row"
>
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" />
{traceAccountLink(row.username, "invite-trace-user")}
<span className={`small-pill ${row.role === "admin" ? "" : "is-muted"}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<div className="invite-trace-row-meta">
<span className="invite-trace-meta-item">
<span className="label">Invited by</span>
<strong>{row.inviterUsername ? traceAccountLink(row.inviterUsername) : "Root/direct"}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Via code</span>
<strong>{row.inviteCode || "None"}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || "None"}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</section>
</AdminShell>
);
}