296 lines
8.9 KiB
TypeScript
296 lines
8.9 KiB
TypeScript
'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
|
|
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 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<AdminUser[]>([])
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
|
|
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
|
|
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = 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),
|
|
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 toggleUserBlock = async (username: string, blocked: boolean) => {
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
|
|
{ method: 'POST' }
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error('Update failed')
|
|
}
|
|
await loadUsers()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user access.')
|
|
}
|
|
}
|
|
|
|
const updateUserRole = async (username: string, role: string) => {
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error('Update failed')
|
|
}
|
|
await loadUsers()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user role.')
|
|
}
|
|
}
|
|
|
|
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 Jellyseerr users.')
|
|
} finally {
|
|
setJellyseerrSyncBusy(false)
|
|
}
|
|
}
|
|
|
|
const resyncJellyseerrUsers = async () => {
|
|
const confirmed = window.confirm(
|
|
'This will remove all non-admin users and re-import from Jellyseerr. 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 Jellyseerr users.')
|
|
} finally {
|
|
setJellyseerrResyncBusy(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
void loadUsers()
|
|
}, [router])
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading users...</main>
|
|
}
|
|
|
|
return (
|
|
<AdminShell
|
|
title="Users"
|
|
subtitle="Manage who can use Magent."
|
|
actions={
|
|
<>
|
|
<button type="button" onClick={loadUsers}>
|
|
Reload list
|
|
</button>
|
|
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
|
|
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
|
|
</button>
|
|
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}>
|
|
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<section className="admin-section">
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
|
|
{users.length === 0 ? (
|
|
<div className="status-banner">No users found yet.</div>
|
|
) : (
|
|
<div className="user-grid">
|
|
{users.map((user) => (
|
|
<Link
|
|
key={user.username}
|
|
className="user-grid-card"
|
|
href={`/users/${user.id}`}
|
|
>
|
|
<div className="user-grid-header">
|
|
<div>
|
|
<strong>{user.username}</strong>
|
|
<span className="user-grid-meta">{user.role}</span>
|
|
</div>
|
|
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
|
|
{user.isBlocked ? 'Blocked' : 'Active'}
|
|
</span>
|
|
</div>
|
|
<div className="user-grid-stats">
|
|
<div>
|
|
<span className="label">Total</span>
|
|
<span className="value">{user.stats?.total ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Ready</span>
|
|
<span className="value">{user.stats?.ready ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">Pending</span>
|
|
<span className="value">{user.stats?.pending ?? 0}</span>
|
|
</div>
|
|
<div>
|
|
<span className="label">In progress</span>
|
|
<span className="value">{user.stats?.in_progress ?? 0}</span>
|
|
</div>
|
|
</div>
|
|
<div className="user-grid-footer">
|
|
<span className="meta">Login: {user.authProvider || 'local'}</span>
|
|
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
|
|
<span className="meta">
|
|
Last request: {formatLastRequest(user.stats?.last_request_at)}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|