'use client' import { useEffect, 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' type AdminUser = { id: number username: string role: string authProvider?: string | null lastLoginAt?: string | null isBlocked?: boolean autoSearchEnabled?: 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 [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 () => { 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', 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), 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) } } 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( 'This will remove all non-admin users and re-import from Seerr. 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]) 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.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 (
Directory actions
Seerr sync
{error &&
{error}
} {jellyseerrSyncStatus &&
{jellyseerrSyncStatus}
}

Bulk controls

Auto search/download can be enabled or disabled for all non-admin users.

Auto search/download {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled

Directory search

Filter by username, role, login provider, or assigned profile.

{filteredCountLabel}
{filteredUsers.length === 0 ? (
No users found yet.
) : (
User Access Requests Activity
{filteredUsers.map((user) => (
{user.username} {user.role}
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)}
))}
)}
) }