'use client' import { 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: any): 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: 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 = 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: any) => ({ 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 = 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) } } 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: any = 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: any = 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() }, [router, idParam]) 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}

} {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.

)}
) }