"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 | 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(null); const manageTrigger = useRef(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(null); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [profiles, setProfiles] = useState([]); 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(null); const [lineage, setLineage] = useState(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) => ({ 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
Loading user...
; } return ( } >
{error &&
{error}
} {actionStatus &&
{actionStatus}
} {!user ? (
No user data found.
) : (
{user.username} {user.is_blocked ? "Blocked" : "Active"} {user.is_expired ? "Expired" : user.expires_at ? "Expiry set" : "No expiry"}

User identity, access state, and request history for this account.

Email {user.email || "Not set"}
Seerr ID {user.jellyseerr_user_id ?? "Not linked"}
Role {user.role}
Login type {user.auth_provider || "local"}
Assigned profile {user.profile_id ?? "None"}
Invited by {lineage?.invited_by || "Direct / unknown"}
Invite code used {lineage?.invite_code || user.invited_by_code || "None"}
Last login {formatDateTime(user.last_login_at)}
Account expiry {user.expires_at ? formatDateTime(user.expires_at) : "Never"}

Request statistics

Snapshot of request states and recent activity for this user.

Total {stats?.total ?? 0}
Ready {stats?.ready ?? 0}
Pending {stats?.pending ?? 0}
Approved {stats?.approved ?? 0}
Working {stats?.working ?? 0}
Partial {stats?.partial ?? 0}
Declined {stats?.declined ?? 0}
In progress {stats?.in_progress ?? 0}
Last request {formatDateTime(stats?.last_request_at)}
setManageOpen(false)} onClose={() => setManageOpen(false)} >

Manage {user.username}

Feature access, account settings and account restrictions.

{error && (

{error}

)} {actionStatus && (

{actionStatus}

)}

Review service links & duplicate accounts

{manageOpen && ( void loadUser()} /> )}

Contact email

Used by Magent for account recovery and issue updates.

{ event.preventDefault(); void saveUserEmail(); }} >
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
{user.email && ( )}

Access controls

Role, login access, and auto-download behavior.

{user.role === "admin" && (
Admins always have automatic search/download and all features.
)}

Profile defaults

Assign or clear an invite profile for this user.

Account expiry

Set a specific expiry date/time for this user account.

Restrict access or delete accounts

Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.

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.

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.

)}
); }