/** * Component: Admin Users Management Page * Documentation: documentation/admin-dashboard.md */ 'use client'; import { useState, useEffect } from 'react'; import useSWR from 'swr'; import Link from 'next/link'; import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api'; import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; import { GlobalUserSettingsModal } from '@/components/admin/users/GlobalUserSettingsModal'; import { UserPermissionsModal } from '@/components/admin/users/UserPermissionsModal'; interface User { id: string; plexId: string; plexUsername: string; plexEmail: string; role: 'user' | 'admin'; isSetupAdmin: boolean; authProvider: string | null; avatarUrl: string | null; createdAt: string; updatedAt: string; lastLoginAt: string | null; autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; hasLoginToken: boolean; _count: { requests: number; }; } interface PendingUser { id: string; plexUsername: string; plexEmail: string | null; authProvider: string; createdAt: string; } // Tinted-dot status badge following admin design system function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) { if (isSetupAdmin) { return ( Setup Admin ); } if (role === 'admin') { return ( Admin ); } return ( User ); } function PermissionBadge({ user, globalAutoApprove, onClick, }: { user: User; globalAutoApprove: boolean; onClick: () => void; }) { let badge: React.ReactNode; if (user.role === 'admin') { badge = ( Full Access ); } else if (globalAutoApprove) { badge = ( Global Default ); } else if (user.autoApproveRequests ?? false) { badge = ( Auto-Approve ); } else { badge = ( Manual ); } return ( ); } function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) { if (user.isSetupAdmin) { return ( Protected ); } if (user.authProvider === 'oidc') { return ( OIDC Managed ); } if (user.authProvider === 'local') { return (
); } // plex or other return ( ); } function AdminUsersPageContent() { const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher); const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR( '/api/admin/users/pending', authenticatedFetcher ); const { data: globalAutoApproveData, error: globalAutoApproveError, mutate: mutateGlobalAutoApprove } = useSWR( '/api/admin/settings/auto-approve', authenticatedFetcher ); const { data: globalInteractiveSearchData, mutate: mutateGlobalInteractiveSearch } = useSWR( '/api/admin/settings/interactive-search', authenticatedFetcher ); const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR( '/api/admin/settings/download-access', authenticatedFetcher ); const [editDialog, setEditDialog] = useState<{ isOpen: boolean; user: User | null; }>({ isOpen: false, user: null }); const [editRole, setEditRole] = useState<'user' | 'admin'>('user'); const [saving, setSaving] = useState(false); const [processingUserId, setProcessingUserId] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean; type: 'approve' | 'reject' | null; user: PendingUser | null; }>({ isOpen: false, type: null, user: null }); const [deleteDialog, setDeleteDialog] = useState<{ isOpen: boolean; user: User | null; }>({ isOpen: false, user: null }); const [deleting, setDeleting] = useState(false); const [globalAutoApprove, setGlobalAutoApprove] = useState(false); const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState(true); const [globalDownloadAccess, setGlobalDownloadAccess] = useState(true); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [permissionsUserId, setPermissionsUserId] = useState(null); const [generatedToken, setGeneratedToken] = useState(null); const toast = useToast(); const isLoading = !data && !error; const pendingUsers: PendingUser[] = pendingData?.users || []; // Sync global auto-approve state (default to true if not set) useEffect(() => { if (globalAutoApproveData?.autoApproveRequests !== undefined) { setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests); } else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) { setGlobalAutoApprove(true); } }, [globalAutoApproveData]); // Sync global interactive search state (default to true if not set) useEffect(() => { if (globalInteractiveSearchData?.interactiveSearchAccess !== undefined) { setGlobalInteractiveSearch(globalInteractiveSearchData.interactiveSearchAccess); } else if (globalInteractiveSearchData !== undefined && globalInteractiveSearchData.interactiveSearchAccess === undefined) { setGlobalInteractiveSearch(true); } }, [globalInteractiveSearchData]); // Sync global download access state (default to true if not set) useEffect(() => { if (globalDownloadAccessData?.downloadAccess !== undefined) { setGlobalDownloadAccess(globalDownloadAccessData.downloadAccess); } else if (globalDownloadAccessData !== undefined && globalDownloadAccessData.downloadAccess === undefined) { setGlobalDownloadAccess(true); } }, [globalDownloadAccessData]); const handleGlobalAutoApproveToggle = async (newValue: boolean) => { setGlobalAutoApprove(newValue); try { await fetchJSON('/api/admin/settings/auto-approve', { method: 'PATCH', body: JSON.stringify({ autoApproveRequests: newValue }), }); toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`); mutateGlobalAutoApprove(); mutate(); } catch (err) { setGlobalAutoApprove(!newValue); const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting'; toast.error(errorMsg); } }; const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => { setGlobalInteractiveSearch(newValue); try { await fetchJSON('/api/admin/settings/interactive-search', { method: 'PATCH', body: JSON.stringify({ interactiveSearchAccess: newValue }), }); toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`); mutateGlobalInteractiveSearch(); mutate(); } catch (err) { setGlobalInteractiveSearch(!newValue); const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting'; toast.error(errorMsg); } }; const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => { const previousUsers = data?.users || []; const optimisticUsers = previousUsers.map((u: User) => u.id === user.id ? { ...u, autoApproveRequests: newValue } : u ); mutate({ users: optimisticUsers }, false); try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }), }); toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); mutate(); } catch (err) { mutate({ users: previousUsers }, false); const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting'; toast.error(errorMsg); } }; const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => { const previousUsers = data?.users || []; const optimisticUsers = previousUsers.map((u: User) => u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u ); mutate({ users: optimisticUsers }, false); try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }), }); toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); mutate(); } catch (err) { mutate({ users: previousUsers }, false); const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting'; toast.error(errorMsg); } }; const handleGlobalDownloadAccessToggle = async (newValue: boolean) => { setGlobalDownloadAccess(newValue); try { await fetchJSON('/api/admin/settings/download-access', { method: 'PATCH', body: JSON.stringify({ downloadAccess: newValue }), }); toast.success(`Global download access ${newValue ? 'enabled' : 'disabled'}`); mutateGlobalDownloadAccess(); mutate(); } catch (err) { setGlobalDownloadAccess(!newValue); const errorMsg = err instanceof Error ? err.message : 'Failed to update download access setting'; toast.error(errorMsg); } }; const handleUserDownloadAccessToggle = async (user: User, newValue: boolean) => { const previousUsers = data?.users || []; const optimisticUsers = previousUsers.map((u: User) => u.id === user.id ? { ...u, downloadAccess: newValue } : u ); mutate({ users: optimisticUsers }, false); try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', body: JSON.stringify({ role: user.role, downloadAccess: newValue }), }); toast.success(`Download access ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); mutate(); } catch (err) { mutate({ users: previousUsers }, false); const errorMsg = err instanceof Error ? err.message : 'Failed to update user download access setting'; toast.error(errorMsg); } }; const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => { try { if (newValue) { const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' }); setGeneratedToken(result.fullToken); toast.success(`Login token generated for ${user.plexUsername}`); } else { await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' }); setGeneratedToken(null); toast.success(`Login token revoked for ${user.plexUsername}`); } mutate(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to update login token'; toast.error(errorMsg); } }; const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); }; const hideEditDialog = () => { setEditDialog({ isOpen: false, user: null }); }; const saveUserRole = async () => { if (!editDialog.user) return; try { setSaving(true); await fetchJSON(`/api/admin/users/${editDialog.user.id}`, { method: 'PUT', body: JSON.stringify({ role: editRole }), }); toast.success(`User "${editDialog.user.plexUsername}" updated successfully`); hideEditDialog(); mutate(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to update user'; toast.error(errorMsg); } finally { setSaving(false); } }; const showApproveDialog = (user: PendingUser) => { setConfirmDialog({ isOpen: true, type: 'approve', user }); }; const showRejectDialog = (user: PendingUser) => { setConfirmDialog({ isOpen: true, type: 'reject', user }); }; const closeConfirmDialog = () => { if (processingUserId) return; setConfirmDialog({ isOpen: false, type: null, user: null }); }; const handleConfirmAction = async () => { if (!confirmDialog.user) return; const isApprove = confirmDialog.type === 'approve'; try { setProcessingUserId(confirmDialog.user.id); await fetchJSON(`/api/admin/users/${confirmDialog.user.id}/approve`, { method: 'POST', body: JSON.stringify({ approve: isApprove }), }); toast.success( isApprove ? `User "${confirmDialog.user.plexUsername}" has been approved` : `User "${confirmDialog.user.plexUsername}" has been rejected` ); mutatePending(); if (isApprove) mutate(); closeConfirmDialog(); } catch (err) { const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`; toast.error(errorMsg); } finally { setProcessingUserId(null); } }; const showDeleteDialog = (user: User) => { setDeleteDialog({ isOpen: true, user }); }; const closeDeleteDialog = () => { if (deleting) return; setDeleteDialog({ isOpen: false, user: null }); }; const handleDeleteUser = async () => { if (!deleteDialog.user) return; try { setDeleting(true); const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, { method: 'DELETE', }); toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`); mutate(); closeDeleteDialog(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to delete user'; toast.error(errorMsg); } finally { setDeleting(false); } }; const copyToClipboard = async (text: string, label: string) => { try { await navigator.clipboard.writeText(text); toast.success(`${label} copied to clipboard`); } catch (err) { toast.error('Failed to copy to clipboard'); } }; if (isLoading) { return (
); } if (error) { return (

Error Loading Users

{error?.message || 'Failed to load users'}

); } const users: User[] = data?.users || []; const permissionsUser = permissionsUserId ? users.find((u) => u.id === permissionsUserId) ?? null : null; return (
{/* Header — stacks on mobile, row on sm+ */}

User Management

Manage user roles and permissions

Back
{/* Pending Users Section */} {pendingUsers.length > 0 && (

Pending Registrations ({pendingUsers.length})

The following users are awaiting approval to access the system.

{pendingUsers.map((user) => (
{/* Pending card — info */}
{user.plexUsername}
{user.plexEmail || 'No email'}
Registered: {new Date(user.createdAt).toLocaleString()} · Provider: {user.authProvider}
{/* Pending card — actions, full-width on mobile */}
))}
)} {/* Users — Mobile card list (sm:hidden) */}
{users.map((user) => (
{/* Card header — avatar + name + role badge */}
{user.avatarUrl ? ( {user.plexUsername} ) : (
)}
{user.plexUsername}
{user.plexEmail || 'No email'}
{/* Card body — labeled fields */}
Permissions
setPermissionsUserId(user.id)} />
Requests
{user._count.requests}
Last Login
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
User ID
{/* Card actions */}
))} {users.length === 0 && (

No users found

)}
{/* Users Table — hidden on mobile, visible on sm+ */}
{users.map((user) => ( ))}
User Email Role Permissions Requests Last Login Actions
{user.avatarUrl ? ( {user.plexUsername} ) : (
)}
{user.plexUsername}
copyToClipboard(user.plexId, 'User ID')} > ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId}
{user.plexEmail || 'N/A'}
setPermissionsUserId(user.id)} /> {user._count.requests} {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
{users.length === 0 && (

No users found

)}
{/* Info Box */}

About User Management

  • User: Can request audiobooks, view own requests, and search the catalog
  • Admin: Full system access including settings, user management, and all requests
  • Setup Admin: The initial admin account — protected, cannot be changed or deleted
  • Permissions: Click a user's permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.
  • OIDC Users: Role management is handled by the identity provider. Cannot be deleted.
  • Plex Users: Role can be changed, but cannot be deleted (access managed by Plex).
  • Local Users: Can have roles freely assigned. Can be deleted (requests are preserved).
  • • You cannot change your own role or delete yourself for security reasons
{/* Edit User Dialog — bottom sheet on mobile */} {editDialog.isOpen && editDialog.user && (
{/* Dialog header */}

Edit User Role

{/* User Info */}
{editDialog.user.avatarUrl ? ( {editDialog.user.plexUsername} ) : (
)}
{editDialog.user.plexUsername}
{editDialog.user.plexEmail || 'No email'}
{/* Role Selection */}
{/* Dialog footer */}
)} {/* Confirm Approve/Reject Dialog */} {/* Delete User Dialog */} {/* Global User Settings Modal */} setGlobalSettingsOpen(false)} globalAutoApprove={globalAutoApprove} onToggleAutoApprove={handleGlobalAutoApproveToggle} globalInteractiveSearch={globalInteractiveSearch} onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle} globalDownloadAccess={globalDownloadAccess} onToggleDownloadAccess={handleGlobalDownloadAccessToggle} /> {/* User Permissions Modal */} { setPermissionsUserId(null); setGeneratedToken(null); }} user={permissionsUser} globalAutoApprove={globalAutoApprove} globalInteractiveSearch={globalInteractiveSearch} globalDownloadAccess={globalDownloadAccess} generatedToken={generatedToken} onToggleAutoApprove={(user, newValue) => { handleUserAutoApproveToggle(user as User, newValue); }} onToggleInteractiveSearch={(user, newValue) => { handleUserInteractiveSearchToggle(user as User, newValue); }} onToggleDownloadAccess={(user, newValue) => { handleUserDownloadAccessToggle(user as User, newValue); }} onToggleToken={(user, newValue) => { handleToggleToken(user, newValue); }} />
); } export default function AdminUsersPage() { return ( ); }