"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth"; import AdminShell from "../ui/AdminShell"; import "./users.css"; import FeatureControls from "./FeatureControls"; import IdentityReviewPanel from "../admin/identities/IdentityReviewPanel"; type AdminUser = { id: number; username: string; email?: string | null; role: string; authProvider?: string | null; lastLoginAt?: string | null; isBlocked?: boolean; autoSearchEnabled?: boolean; inviteManagementEnabled?: boolean; profileId?: number | null; expiresAt?: string | null; isExpired?: boolean; stats?: UserStats; }; type UserStats = { total: number; ready: number; pending: number; approved: number; working: number; partial: number; declined: number; in_progress: number; last_request_at?: string | null; }; const formatLastLogin = (value?: string | null) => { if (!value) return "Never"; const date = new Date(value); if (Number.isNaN(date.valueOf())) return value; return date.toLocaleString(); }; const formatLastRequest = (value?: string | null) => { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.valueOf())) return value; return date.toLocaleString(); }; const formatExpiry = (value?: string | null) => { if (!value) return "Never"; const date = new Date(value); if (Number.isNaN(date.valueOf())) return value; return date.toLocaleString(); }; 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 UsersPage() { const router = useRouter(); const [view, setView] = useState("directory"); useEffect(() => { const update = () => setView(new URLSearchParams(window.location.search).get("view") === "identities" ? "identities" : "directory"); update(); window.addEventListener("popstate", update); return () => window.removeEventListener("popstate", update); }, []); const changeView = (next: string) => { setView(next); window.history.pushState(null, "", next === "identities" ? "/users?view=identities" : "/users"); }; const [controlsOpen, setControlsOpen] = useState(false); const controlsDialog = useRef(null); const controlsTrigger = useRef(null); const controlsClose = useRef(null); const [refreshing, setRefreshing] = useState(false); const [users, setUsers] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(""); const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState(null); const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false); const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false); const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false); const loadUsers = useCallback(async () => { setRefreshing(true); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/users/summary`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } if (response.status === 403) { router.push("/"); return; } throw new Error("Could not load users."); } const data = await response.json(); if (Array.isArray(data?.users)) { setUsers( data.users.map((user: Record) => ({ username: typeof user.username === "string" ? user.username : "Unknown", email: typeof user.email === "string" ? user.email : null, role: typeof user.role === "string" ? user.role : "user", authProvider: typeof user.auth_provider === "string" ? user.auth_provider : "local", lastLoginAt: typeof user.last_login_at === "string" ? user.last_login_at : null, isBlocked: Boolean(user.is_blocked), autoSearchEnabled: Boolean(user.auto_search_enabled ?? true), inviteManagementEnabled: Boolean(user.invite_management_enabled), profileId: user.profile_id == null || Number.isNaN(Number(user.profile_id)) ? null : Number(user.profile_id), expiresAt: typeof user.expires_at === "string" ? user.expires_at : null, isExpired: Boolean(user.is_expired), id: Number(user.id ?? 0), stats: normalizeStats( user.stats && typeof user.stats === "object" ? (user.stats as Record) : null, ), })), ); } else { setUsers([]); } setError(null); } catch (err) { console.error(err); setError("Could not load user list."); } finally { setLoading(false); setRefreshing(false); } }, [router]); const syncJellyseerrUsers = async () => { setJellyseerrSyncStatus(null); setJellyseerrSyncBusy(true); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, { method: "POST", }); if (!response.ok) { const text = await response.text(); throw new Error(text || "Sync failed"); } const data = await response.json(); setJellyseerrSyncStatus( `Checked ${data?.total ?? 0} Seerr records against Jellyfin IDs. Added ${data?.imported ?? 0} users; existing settings retained.`, ); await loadUsers(); } catch (err) { console.error(err); setJellyseerrSyncStatus("Could not sync Seerr users."); } finally { setJellyseerrSyncBusy(false); } }; const resyncJellyseerrUsers = async () => { setJellyseerrSyncStatus(null); setJellyseerrResyncBusy(true); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, { method: "POST", }); if (!response.ok) { const text = await response.text(); throw new Error(text || "Resync failed"); } const data = await response.json(); setJellyseerrSyncStatus( `Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`, ); await loadUsers(); } catch (err) { console.error(err); setJellyseerrSyncStatus("Could not resync Seerr users."); } finally { setJellyseerrResyncBusy(false); } }; const bulkUpdateAutoSearch = async (enabled: boolean) => { setBulkAutoSearchBusy(true); setJellyseerrSyncStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }); if (!response.ok) { const text = await response.text(); throw new Error(text || "Bulk update failed"); } const data = await response.json(); setJellyseerrSyncStatus( `${enabled ? "Enabled" : "Disabled"} auto search/download for ${data?.updated ?? 0} non-admin users.`, ); await loadUsers(); } catch (err) { console.error(err); setError("Could not update auto search/download for all users."); } finally { setBulkAutoSearchBusy(false); } }; useEffect(() => { if (!getToken()) { router.push("/login"); return; } void loadUsers(); }, [loadUsers, router]); useEffect(() => { if (!controlsOpen) return; const dialog = controlsDialog.current; dialog?.showModal(); controlsClose.current?.focus(); const previous = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { dialog?.close(); document.body.style.overflow = previous; controlsTrigger.current?.focus(); }; }, [controlsOpen]); const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy; if (loading) { return
Loading users...
; } const nonAdminUsers = users.filter((user) => user.role !== "admin"); const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length; const blockedCount = users.filter((user) => user.isBlocked).length; const expiredCount = users.filter((user) => user.isExpired).length; const adminCount = users.filter((user) => user.role === "admin").length; const normalizedQuery = query.trim().toLowerCase(); const filteredUsers = normalizedQuery ? users.filter((user) => { const fields = [ user.username, user.email || "", user.role, user.authProvider || "", user.profileId != null ? String(user.profileId) : "", ]; return fields.some((field) => field.toLowerCase().includes(normalizedQuery)); }) : users; const filteredCountLabel = filteredUsers.length === users.length ? `${users.length} users` : `${filteredUsers.length} of ${users.length} users`; const usersRail = (

Directory summary

A quick view of user access and account state.

Total users {users.length}

{adminCount} admin accounts

Auto search {autoSearchEnabledCount}

of {nonAdminUsers.length} non-admin users enabled

Blocked {blockedCount}

{blockedCount ? "Accounts currently blocked" : "No blocked users"}

Expired {expiredCount}

{expiredCount ? "Accounts with expired access" : "No expiries"}

); return ( setControlsOpen(true)} > Manage users } > setControlsOpen(false)} onClose={() => setControlsOpen(false)} >
Directory tools

Manage users

Account links, service sync and permissions for your user directory.

{error && (

{error}

)} {jellyseerrSyncStatus && (

{jellyseerrSyncStatus}

)}

Directory actions

Review linked accounts, manage invitations or refresh the list.

Review account links ↗

Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.

Manage invitations ↗

Create invitations, review issued links and set invitation defaults.

Reload account status and request totals from Magent. Your search stays in place.

Seerr sync

Connect existing Magent accounts to their Seerr request accounts.

Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and available email into Magent. Already-linked users are skipped.

Reconcile service identities

Refreshes Jellyfin and Seerr accounts using their shared Jellyfin ID. Preserves account settings and history. Duplicate or conflicting links stay available for reviewed repair.

Automatic search & download

Allow users to trigger automatic searches and downloads for their requests.

{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled

Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.

void loadUsers()} />
Directory totals {usersRail}
{view === "identities" ? ( ) : (
{!controlsOpen && error && (

{error}

)} {!controlsOpen && jellyseerrSyncStatus && (

{jellyseerrSyncStatus}

)}

Directory search

Find an account by username, email, role, login provider or profile ID. Select a user to manage their access.

{filteredCountLabel}
{filteredUsers.length === 0 ? (
{normalizedQuery ? "No users match your search. Try another name or email." : "No users found yet. Open Manage users to review the directory tools."}
) : (
User Access Requests Activity
{filteredUsers.map((user) => (
{user.username} {user.role}
{user.email || "No email on file"}
Login: {user.authProvider || "local"} • Profile: {user.profileId ?? "None"}
{user.isBlocked ? "Blocked" : "Active"} Auto {user.autoSearchEnabled === false ? "Off" : "On"} {user.expiresAt ? (user.isExpired ? "Expired" : "Expiry set") : "No expiry"}
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : "No account expiry"}
{user.stats?.total ?? 0} total {user.stats?.ready ?? 0} ready {user.stats?.pending ?? 0} pending {user.stats?.in_progress ?? 0} in progress
Last login: {formatLastLogin(user.lastLoginAt)}
Last request: {formatLastRequest(user.stats?.last_request_at)}
))}
)}
)}
); }