235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
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
|
|
role: string
|
|
auth_provider?: string | null
|
|
last_login_at?: string | null
|
|
is_blocked?: boolean
|
|
jellyseerr_user_id?: number | null
|
|
}
|
|
|
|
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 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 params = useParams()
|
|
const router = useRouter()
|
|
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
|
|
const [user, setUser] = useState<AdminUser | null>(null)
|
|
const [stats, setStats] = useState<UserStats | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
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()
|
|
setUser(data?.user ?? null)
|
|
setStats(normalizeStats(data?.stats))
|
|
setError(null)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not load user.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const toggleUserBlock = async (blocked: boolean) => {
|
|
if (!user) return
|
|
try {
|
|
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()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user access.')
|
|
}
|
|
}
|
|
|
|
const updateUserRole = async (role: string) => {
|
|
if (!user) return
|
|
try {
|
|
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()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user role.')
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
void loadUser()
|
|
}, [router, idParam])
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading user...</main>
|
|
}
|
|
|
|
return (
|
|
<AdminShell
|
|
title={user?.username || 'User'}
|
|
subtitle="User overview and request stats."
|
|
actions={
|
|
<button type="button" onClick={() => router.push('/users')}>
|
|
Back to users
|
|
</button>
|
|
}
|
|
>
|
|
<section className="admin-section">
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{!user ? (
|
|
<div className="status-banner">No user data found.</div>
|
|
) : (
|
|
<>
|
|
<div className="user-detail-card">
|
|
<div className="user-detail-header">
|
|
<div>
|
|
<strong>{user.username}</strong>
|
|
<div className="user-detail-meta">
|
|
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span>
|
|
<span className="meta">Role: {user.role}</span>
|
|
<span className="meta">Login type: {user.auth_provider || 'local'}</span>
|
|
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="user-actions">
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={user.role === 'admin'}
|
|
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
|
|
/>
|
|
<span>Make admin</span>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => toggleUserBlock(!user.is_blocked)}
|
|
>
|
|
{user.is_blocked ? 'Allow access' : 'Block access'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="user-detail-grid">
|
|
<div>
|
|
<span className="label">Total</span>
|
|
<span className="value">{stats?.total ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Ready</span>
|
|
<span className="value">{stats?.ready ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Pending</span>
|
|
<span className="value">{stats?.pending ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Approved</span>
|
|
<span className="value">{stats?.approved ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Working</span>
|
|
<span className="value">{stats?.working ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Partial</span>
|
|
<span className="value">{stats?.partial ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Declined</span>
|
|
<span className="value">{stats?.declined ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">In progress</span>
|
|
<span className="value">{stats?.in_progress ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Last request</span>
|
|
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|