810 lines
31 KiB
TypeScript
810 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
|
import FeatureControls from "../FeatureControls";
|
|
import "../users.css";
|
|
import AdminShell from "../../ui/AdminShell";
|
|
|
|
type UserStats = {
|
|
total: number;
|
|
ready: number;
|
|
pending: number;
|
|
approved: number;
|
|
working: number;
|
|
partial: number;
|
|
declined: number;
|
|
in_progress: number;
|
|
last_request_at?: string | null;
|
|
};
|
|
|
|
type AdminUser = {
|
|
id?: number;
|
|
username: string;
|
|
email?: string | null;
|
|
role: string;
|
|
auth_provider?: string | null;
|
|
last_login_at?: string | null;
|
|
is_blocked?: boolean;
|
|
auto_search_enabled?: boolean;
|
|
invite_management_enabled?: boolean;
|
|
jellyseerr_user_id?: number | null;
|
|
profile_id?: number | null;
|
|
expires_at?: string | null;
|
|
is_expired?: boolean;
|
|
invited_by_code?: string | null;
|
|
invited_at?: string | null;
|
|
};
|
|
|
|
type UserLineage = {
|
|
invite_code?: string | null;
|
|
invited_by?: string | null;
|
|
invite?: {
|
|
id?: number;
|
|
code?: string;
|
|
label?: string | null;
|
|
created_by?: string | null;
|
|
created_at?: string | null;
|
|
enabled?: boolean;
|
|
is_usable?: boolean;
|
|
} | null;
|
|
} | null;
|
|
|
|
type UserProfileOption = {
|
|
id: number;
|
|
name: string;
|
|
is_active?: boolean;
|
|
};
|
|
|
|
const formatDateTime = (value?: string | null) => {
|
|
if (!value) return "Never";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return value;
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const toLocalDateTimeInput = (value?: string | null) => {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return "";
|
|
const offsetMs = date.getTimezoneOffset() * 60_000;
|
|
const local = new Date(date.getTime() - offsetMs);
|
|
return local.toISOString().slice(0, 16);
|
|
};
|
|
|
|
const fromLocalDateTimeInput = (value: string) => {
|
|
if (!value.trim()) return null;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return null;
|
|
return date.toISOString();
|
|
};
|
|
|
|
const normalizeStats = (stats: Record<string, unknown> | null | undefined): UserStats => ({
|
|
total: Number(stats?.total ?? 0),
|
|
ready: Number(stats?.ready ?? 0),
|
|
pending: Number(stats?.pending ?? 0),
|
|
approved: Number(stats?.approved ?? 0),
|
|
working: Number(stats?.working ?? 0),
|
|
partial: Number(stats?.partial ?? 0),
|
|
declined: Number(stats?.declined ?? 0),
|
|
in_progress: Number(stats?.in_progress ?? 0),
|
|
last_request_at: typeof stats?.last_request_at === "string" ? stats.last_request_at : null,
|
|
});
|
|
|
|
export default function UserDetailPage() {
|
|
const [manageOpen, setManageOpen] = useState(false);
|
|
const managementDialog = useRef<HTMLDialogElement>(null);
|
|
const manageTrigger = useRef<HTMLButtonElement>(null);
|
|
useEffect(() => {
|
|
if (!manageOpen) return;
|
|
const dialog = managementDialog.current;
|
|
if (!dialog) return;
|
|
const overflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
dialog.showModal();
|
|
return () => {
|
|
dialog.close();
|
|
document.body.style.overflow = overflow;
|
|
manageTrigger.current?.focus();
|
|
};
|
|
}, [manageOpen]);
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
|
const [user, setUser] = useState<AdminUser | null>(null);
|
|
const [stats, setStats] = useState<UserStats | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [profiles, setProfiles] = useState<UserProfileOption[]>([]);
|
|
const [profileSelection, setProfileSelection] = useState("");
|
|
const [expiryInput, setExpiryInput] = useState("");
|
|
const [emailInput, setEmailInput] = useState("");
|
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
const [savingExpiry, setSavingExpiry] = useState(false);
|
|
const [savingEmail, setSavingEmail] = useState(false);
|
|
const [systemActionBusy, setSystemActionBusy] = useState(false);
|
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
|
const [lineage, setLineage] = useState<UserLineage>(null);
|
|
|
|
const loadProfiles = useCallback(async () => {
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/profiles`);
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
if (!Array.isArray(data?.profiles)) {
|
|
setProfiles([]);
|
|
return;
|
|
}
|
|
setProfiles(
|
|
data.profiles.map((profile: Record<string, unknown>) => ({
|
|
id: Number(profile.id ?? 0),
|
|
name: String(profile.name ?? "Unnamed profile"),
|
|
is_active: Boolean(profile.is_active ?? true),
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}, []);
|
|
|
|
const loadUser = useCallback(async () => {
|
|
if (!idParam) return;
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`);
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken();
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
router.push("/");
|
|
return;
|
|
}
|
|
if (response.status === 404) {
|
|
setError("User not found.");
|
|
return;
|
|
}
|
|
throw new Error("Could not load user.");
|
|
}
|
|
const data = await response.json();
|
|
const nextUser = data?.user ?? null;
|
|
setUser(nextUser);
|
|
setStats(normalizeStats(data?.stats));
|
|
setLineage((data?.lineage ?? null) as UserLineage);
|
|
setProfileSelection(
|
|
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id)) ? "" : String(nextUser.profile_id),
|
|
);
|
|
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at));
|
|
setEmailInput(nextUser?.email ?? "");
|
|
setError(null);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not load user.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [idParam, router]);
|
|
|
|
const toggleUserBlock = async (blocked: boolean) => {
|
|
if (!user) return;
|
|
try {
|
|
setActionStatus(null);
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? "block" : "unblock"}`,
|
|
{ method: "POST" },
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Update failed");
|
|
}
|
|
await loadUser();
|
|
setActionStatus(blocked ? "User blocked." : "User unblocked.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update user access.");
|
|
}
|
|
};
|
|
|
|
const updateUserRole = async (role: string) => {
|
|
if (!user) return;
|
|
try {
|
|
setActionStatus(null);
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Update failed");
|
|
}
|
|
await loadUser();
|
|
setActionStatus(`Role updated to ${role}.`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update user role.");
|
|
}
|
|
};
|
|
|
|
const saveUserEmail = async (clear = false) => {
|
|
if (!user) return;
|
|
const email = clear ? "" : emailInput.trim();
|
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
setError("Enter a valid email address.");
|
|
setActionStatus(null);
|
|
return;
|
|
}
|
|
setSavingEmail(true);
|
|
setError(null);
|
|
setActionStatus(null);
|
|
try {
|
|
const response = await authFetch(`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email || null }),
|
|
});
|
|
const text = await response.text();
|
|
let data: { detail?: string; user?: { email?: string | null } } | null = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = null;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data?.detail || text || "Email update failed");
|
|
}
|
|
setEmailInput(data?.user?.email ?? "");
|
|
await loadUser();
|
|
setActionStatus(email ? "Contact email saved." : "Contact email removed.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError(err instanceof Error ? err.message : "Could not update the contact email.");
|
|
} finally {
|
|
setSavingEmail(false);
|
|
}
|
|
};
|
|
|
|
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
|
if (!user) return;
|
|
try {
|
|
setActionStatus(null);
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Update failed");
|
|
}
|
|
await loadUser();
|
|
setActionStatus(`Auto search/download ${enabled ? "enabled" : "disabled"}.`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update auto search access.");
|
|
}
|
|
};
|
|
|
|
const applyProfileToUser = async (profileOverride?: string | null) => {
|
|
if (!user) return;
|
|
const profileValue = profileOverride ?? profileSelection;
|
|
setSavingProfile(true);
|
|
setError(null);
|
|
setActionStatus(null);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ profile_id: profileValue || null }),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Profile update failed");
|
|
}
|
|
await loadUser();
|
|
setActionStatus(profileValue ? "Profile applied to user." : "Profile assignment cleared.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update user profile.");
|
|
} finally {
|
|
setSavingProfile(false);
|
|
}
|
|
};
|
|
|
|
const saveUserExpiry = async () => {
|
|
if (!user) return;
|
|
const expiresAt = fromLocalDateTimeInput(expiryInput);
|
|
if (expiryInput.trim() && !expiresAt) {
|
|
setError("Invalid expiry date/time.");
|
|
return;
|
|
}
|
|
setSavingExpiry(true);
|
|
setError(null);
|
|
setActionStatus(null);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ expires_at: expiresAt }),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Expiry update failed");
|
|
}
|
|
await loadUser();
|
|
setActionStatus(expiresAt ? "User expiry updated." : "User expiry cleared.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update user expiry.");
|
|
} finally {
|
|
setSavingExpiry(false);
|
|
}
|
|
};
|
|
|
|
const clearUserExpiry = async () => {
|
|
if (!user) return;
|
|
setSavingExpiry(true);
|
|
setError(null);
|
|
setActionStatus(null);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ clear: true }),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Expiry clear failed");
|
|
}
|
|
setExpiryInput("");
|
|
await loadUser();
|
|
setActionStatus("User expiry cleared.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not clear user expiry.");
|
|
} finally {
|
|
setSavingExpiry(false);
|
|
}
|
|
};
|
|
|
|
const runSystemAction = async (action: "ban" | "unban" | "remove") => {
|
|
if (!user) return;
|
|
if (action === "remove") {
|
|
const confirmed = window.confirm(
|
|
`Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`,
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
if (action === "ban") {
|
|
const confirmed = window.confirm(
|
|
`Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`,
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
setSystemActionBusy(true);
|
|
setError(null);
|
|
setActionStatus(null);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
const text = await response.text();
|
|
let data: { detail?: string; status?: string } | null = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = null;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data?.detail || text || "Cross-system action failed");
|
|
}
|
|
const state = data?.status === "partial" ? "partial" : "complete";
|
|
if (action === "remove") {
|
|
setActionStatus(`User removed (${state}).`);
|
|
router.push("/users");
|
|
return;
|
|
}
|
|
await loadUser();
|
|
setActionStatus(`${action === "ban" ? "Ban" : "Unban"} completed (${state}).`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError(err instanceof Error ? err.message : "Could not run cross-system action.");
|
|
} finally {
|
|
setSystemActionBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
void loadUser();
|
|
void loadProfiles();
|
|
}, [loadProfiles, loadUser, router]);
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading user...</main>;
|
|
}
|
|
|
|
return (
|
|
<AdminShell
|
|
title={user?.username || "User"}
|
|
subtitle="User overview and request stats."
|
|
actions={
|
|
<>
|
|
<button type="button" onClick={() => router.push("/users")}>
|
|
Back to users
|
|
</button>
|
|
<button
|
|
ref={manageTrigger}
|
|
type="button"
|
|
disabled={!user}
|
|
aria-haspopup="dialog"
|
|
onClick={() => setManageOpen(true)}
|
|
>
|
|
Manage this user
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<section className="admin-section">
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{actionStatus && <div className="status-banner">{actionStatus}</div>}
|
|
{!user ? (
|
|
<div className="status-banner">No user data found.</div>
|
|
) : (
|
|
<div className="user-detail-page-grid user-detail-centered">
|
|
<div className="user-detail-main-column">
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<div className="user-detail-title-row">
|
|
<strong className="user-detail-name">{user.username}</strong>
|
|
<span className={`user-grid-pill ${user.is_blocked ? "is-blocked" : ""}`}>
|
|
{user.is_blocked ? "Blocked" : "Active"}
|
|
</span>
|
|
<span className={`user-grid-pill ${user.is_expired ? "is-blocked" : ""}`}>
|
|
{user.is_expired ? "Expired" : user.expires_at ? "Expiry set" : "No expiry"}
|
|
</span>
|
|
</div>
|
|
<p className="lede">User identity, access state, and request history for this account.</p>
|
|
</div>
|
|
<div className="user-detail-meta-grid">
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Email</span>
|
|
<strong>{user.email || "Not set"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Seerr ID</span>
|
|
<strong>{user.jellyseerr_user_id ?? "Not linked"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Role</span>
|
|
<strong>{user.role}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Login type</span>
|
|
<strong>{user.auth_provider || "local"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Assigned profile</span>
|
|
<strong>{user.profile_id ?? "None"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Invited by</span>
|
|
<strong>{lineage?.invited_by || "Direct / unknown"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Invite code used</span>
|
|
<strong>{lineage?.invite_code || user.invited_by_code || "None"}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Last login</span>
|
|
<strong>{formatDateTime(user.last_login_at)}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Account expiry</span>
|
|
<strong>{user.expires_at ? formatDateTime(user.expires_at) : "Never"}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Request statistics</h2>
|
|
<p className="lede">Snapshot of request states and recent activity for this user.</p>
|
|
</div>
|
|
<div className="user-detail-grid">
|
|
<div className="user-detail-stat">
|
|
<span className="label">Total</span>
|
|
<span className="value">{stats?.total ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Ready</span>
|
|
<span className="value">{stats?.ready ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Pending</span>
|
|
<span className="value">{stats?.pending ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Approved</span>
|
|
<span className="value">{stats?.approved ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Working</span>
|
|
<span className="value">{stats?.working ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Partial</span>
|
|
<span className="value">{stats?.partial ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Declined</span>
|
|
<span className="value">{stats?.declined ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">In progress</span>
|
|
<span className="value">{stats?.in_progress ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat user-detail-stat--wide">
|
|
<span className="label">Last request</span>
|
|
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog
|
|
ref={managementDialog}
|
|
className="user-management-dialog"
|
|
aria-labelledby="manage-this-user-title"
|
|
onCancel={() => setManageOpen(false)}
|
|
onClose={() => setManageOpen(false)}
|
|
>
|
|
<div className="user-management-content">
|
|
<header className="user-management-heading">
|
|
<div>
|
|
<h2 id="manage-this-user-title">Manage {user.username}</h2>
|
|
<p>Feature access, account settings and account restrictions.</p>
|
|
</div>
|
|
<button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
{error && (
|
|
<p className="error-banner" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{actionStatus && (
|
|
<p className="status-banner" role="status">
|
|
{actionStatus}
|
|
</p>
|
|
)}
|
|
<p>
|
|
<a className="ghost-button" href={`/users?view=identities&user=${encodeURIComponent(user.username)}`}>
|
|
Review service links & duplicate accounts
|
|
</a>
|
|
</p>
|
|
{manageOpen && (
|
|
<FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />
|
|
)}
|
|
<div className="user-management-grid">
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Contact email</h2>
|
|
<p className="lede">Used by Magent for account recovery and issue updates.</p>
|
|
</div>
|
|
<form
|
|
className="user-detail-actions user-detail-actions--stacked"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void saveUserEmail();
|
|
}}
|
|
>
|
|
<label>
|
|
<span className="user-bulk-label">Email address</span>
|
|
<input
|
|
type="email"
|
|
value={emailInput}
|
|
onChange={(event) => setEmailInput(event.target.value)}
|
|
placeholder="person@example.com"
|
|
autoComplete="off"
|
|
disabled={savingEmail}
|
|
/>
|
|
</label>
|
|
<div className="user-detail-helper">
|
|
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
|
|
</div>
|
|
<div className="admin-inline-actions">
|
|
<button
|
|
type="submit"
|
|
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? "")}
|
|
>
|
|
{savingEmail ? "Saving..." : user.email ? "Save email" : "Add email"}
|
|
</button>
|
|
{user.email && (
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void saveUserEmail(true)}
|
|
disabled={savingEmail}
|
|
>
|
|
Remove email
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Access controls</h2>
|
|
<p className="lede">Role, login access, and auto-download behavior.</p>
|
|
</div>
|
|
<div className="user-detail-control-stack">
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={user.role === "admin"}
|
|
onChange={(event) => updateUserRole(event.target.checked ? "admin" : "user")}
|
|
/>
|
|
<span>Make admin</span>
|
|
</label>
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(user.auto_search_enabled ?? true)}
|
|
disabled={user.role === "admin"}
|
|
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
|
|
/>
|
|
<span>Allow auto search/download</span>
|
|
</label>
|
|
{user.role === "admin" && (
|
|
<div className="user-detail-helper">
|
|
Admins always have automatic search/download and all features.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Profile defaults</h2>
|
|
<p className="lede">Assign or clear an invite profile for this user.</p>
|
|
</div>
|
|
<div className="user-detail-actions user-detail-actions--stacked">
|
|
<label className="admin-select">
|
|
<span>Assigned profile</span>
|
|
<select
|
|
value={profileSelection}
|
|
onChange={(event) => setProfileSelection(event.target.value)}
|
|
disabled={savingProfile}
|
|
>
|
|
<option value="">None</option>
|
|
{profiles.map((profile) => (
|
|
<option key={profile.id} value={profile.id}>
|
|
{profile.name}
|
|
{profile.is_active === false ? " (disabled)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
|
|
{savingProfile ? "Applying..." : "Apply profile defaults"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => {
|
|
setProfileSelection("");
|
|
void applyProfileToUser("");
|
|
}}
|
|
disabled={savingProfile}
|
|
>
|
|
Clear profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Account expiry</h2>
|
|
<p className="lede">Set a specific expiry date/time for this user account.</p>
|
|
</div>
|
|
<div className="user-detail-actions user-detail-actions--stacked">
|
|
<label>
|
|
<span className="user-bulk-label">Account expiry</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={expiryInput}
|
|
onChange={(event) => setExpiryInput(event.target.value)}
|
|
disabled={savingExpiry}
|
|
/>
|
|
</label>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
|
|
{savingExpiry ? "Saving..." : "Save expiry"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={clearUserExpiry}
|
|
disabled={savingExpiry}
|
|
>
|
|
Clear expiry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<section className="user-management-panel user-management-danger">
|
|
<h3>Restrict access or delete accounts</h3>
|
|
<p>
|
|
Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => toggleUserBlock(!user.is_blocked)}
|
|
disabled={systemActionBusy || user.role === "admin"}
|
|
>
|
|
{user.is_blocked ? "Restore Magent access" : "Block Magent access"}
|
|
</button>
|
|
<p>
|
|
Disable access also disables invitations this user created and attempts an account notification
|
|
email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not
|
|
directly banned. Restoring access does not reactivate invitations.
|
|
</p>
|
|
<div className="admin-inline-actions">
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void runSystemAction(user.is_blocked ? "unban" : "ban")}
|
|
disabled={systemActionBusy || user.role === "admin"}
|
|
>
|
|
{systemActionBusy
|
|
? "Working..."
|
|
: user.is_blocked
|
|
? "Restore Magent and Jellyfin access"
|
|
: "Disable Magent and Jellyfin access"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void runSystemAction("remove")}
|
|
disabled={systemActionBusy || user.role === "admin"}
|
|
>
|
|
Delete Magent, Jellyfin and Seerr accounts
|
|
</button>
|
|
</div>
|
|
<p>
|
|
Deletion removes the Magent account and local login activity, attempts to delete the same-name
|
|
Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone
|
|
here. Media files and Jellystat history are not deleted. External actions can partially fail.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
</dialog>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</AdminShell>
|
|
);
|
|
}
|