'use client' import { 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 emptyStats: UserStats = { total: 0, ready: 0, pending: 0, approved: 0, working: 0, partial: 0, declined: 0, in_progress: 0, last_request_at: null, } 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 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 = 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: any) => ({ username: user.username ?? 'Unknown', email: user.email ?? null, role: user.role ?? 'user', authProvider: user.auth_provider ?? 'local', lastLoginAt: 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: user.expires_at ?? null, isExpired: Boolean(user.is_expired), id: Number(user.id ?? 0), stats: normalizeStats(user.stats ?? emptyStats), })) ) } else { setUsers([]) } setError(null) } catch (err) { console.error(err) setError('Could not load user list.') } finally { setLoading(false) setRefreshing(false) } } 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( `Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.` ) await loadUsers() } catch (err) { console.error(err) setJellyseerrSyncStatus('Could not sync Seerr users.') } finally { setJellyseerrSyncBusy(false) } } const resyncJellyseerrUsers = async () => { const confirmed = window.confirm( 'Rebuild the Magent directory from Seerr? This deletes all existing non-admin Magent accounts and creates accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Continue?' ) if (!confirmed) return 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( `Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.` ) 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() }, [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.

Advanced: rebuild from Seerr

Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.

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)}
))}
)}
}
) }