Initial commit

This commit is contained in:
2026-01-22 22:49:57 +13:00
commit fe43a81175
67 changed files with 9408 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage'
const ALLOWED_SECTIONS = new Set([
'jellyseerr',
'jellyfin',
'artwork',
'sonarr',
'radarr',
'prowlarr',
'qbittorrent',
'requests',
'cache',
'logs',
'maintenance',
])
type PageProps = {
params: { section: string }
}
export default function AdminSectionPage({ params }: PageProps) {
if (!ALLOWED_SECTIONS.has(params.section)) {
notFound()
}
return <SettingsPage section={params.section} />
}

View File

@@ -0,0 +1,26 @@
'use client'
import { useRouter } from 'next/navigation'
import AdminShell from '../ui/AdminShell'
export default function AdminLandingPage() {
const router = useRouter()
return (
<AdminShell
title="Settings"
subtitle="Choose what you want to manage."
actions={
<button type="button" onClick={() => router.push('/')}>
Back to requests
</button>
}
>
<section className="admin-section">
<div className="status-banner">
Pick a section from the left. Each page explains what it does and how it helps.
</div>
</section>
</AdminShell>
)
}

1482
frontend/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
'use client'
export default function HowItWorksPage() {
return (
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1>
<p className="lede">
Think of Magent as a status tracker. It checks a few helper apps that do different jobs,
then tells you where your request is stuck and what you can safely try next.
</p>
</header>
<section className="how-grid">
<article className="how-card">
<h2>Jellyseerr</h2>
<p className="how-title">The request box</p>
<p>
This is where you ask for a movie or show. It records your request and keeps track of
approvals.
</p>
</article>
<article className="how-card">
<h2>Sonarr / Radarr</h2>
<p className="how-title">The librarian</p>
<p>
These apps add the item to the library, decide what quality to grab, and look for the
files that match your request.
</p>
</article>
<article className="how-card">
<h2>Prowlarr</h2>
<p className="how-title">The search helper</p>
<p>
This one checks your torrent sources and reports back what it found, or if nothing is
available yet.
</p>
</article>
<article className="how-card">
<h2>qBittorrent</h2>
<p className="how-title">The downloader</p>
<p>
If a file is found, this app downloads it. Magent can tell if it is actively
downloading, stalled, or finished.
</p>
</article>
</section>
<section className="how-flow">
<h2>The pipeline in plain English</h2>
<ol className="how-steps">
<li>
<strong>You request a title</strong> in Jellyseerr.
</li>
<li>
<strong>Sonarr/Radarr adds it</strong> to the library list and asks Prowlarr to search.
</li>
<li>
<strong>Prowlarr looks for sources</strong> and sends results back.
</li>
<li>
<strong>qBittorrent downloads</strong> the best match.
</li>
<li>
<strong>Sonarr/Radarr imports</strong> it into your library.
</li>
<li>
<strong>Jellyfin shows it</strong> when it is ready to watch.
</li>
</ol>
</section>
<section className="how-callout">
<h2>Why Magent sometimes says "waiting"</h2>
<p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
This does not mean something is broken. It usually means the release is not available
yet or your search sources do not have it.
</p>
</section>
</main>
)
}

43
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,43 @@
import './globals.css'
import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
export const metadata = {
title: 'Magent',
description: 'Request timeline and AI triage for media requests',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" data-theme="dark">
<body>
<BrandingFavicon />
<div className="page">
<header className="header">
<div className="header-left">
<a className="brand-link" href="/">
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">Find and fix media requests fast.</div>
</div>
</a>
</div>
<div className="header-right">
<HeaderIdentity />
<ThemeToggle />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
{children}
</div>
</body>
</html>
)
}

25
frontend/app/lib/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
export const getToken = () => {
if (typeof window === 'undefined') return null
return window.localStorage.getItem('magent_token')
}
export const setToken = (token: string) => {
if (typeof window === 'undefined') return
window.localStorage.setItem('magent_token', token)
}
export const clearToken = () => {
if (typeof window === 'undefined') return
window.localStorage.removeItem('magent_token')
}
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = getToken()
const headers = new Headers(init?.headers || {})
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault()
setError(null)
setLoading(true)
try {
clearToken()
const baseUrl = getApiBase()
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
const body = new URLSearchParams({ username, password })
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
if (typeof window !== 'undefined') {
window.location.href = '/'
return
}
router.push('/')
return
}
throw new Error('Login failed')
} catch (err) {
console.error(err)
setError('Invalid username or password.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<label>
Username
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
</div>
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
</form>
</main>
)
}

372
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,372 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
statusLabel?: string
artwork?: { poster_url?: string }
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
>(null)
const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const response = await authFetch(
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(
data.results
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' &&
rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
}
})
)
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays])
useEffect(() => {
if (!authReady) {
return
}
const load = async () => {
setServicesLoading(true)
setServicesError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`)
}
const data = await response.json()
setServicesStatus(data)
} catch (error) {
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
setServicesLoading(false)
}
}
load()
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, router])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
return (
<main className="card">
<div className="layout-grid">
<section className="recent centerpiece">
<div className="system-status">
<div className="system-header">
<h2>System status</h2>
<span
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
>
{servicesLoading
? 'Checking services...'
: servicesError
? 'Status not available yet'
: servicesStatus?.overall === 'up'
? 'Services are up and running'
: servicesStatus?.overall === 'down'
? 'Something is down'
: 'Some services need attention'}
</span>
</div>
<div className="system-list">
{(() => {
const order = [
'Jellyseerr',
'Sonarr',
'Radarr',
'Prowlarr',
'qBittorrent',
'Jellyfin',
]
const items = servicesStatus?.services ?? []
return order.map((name) => {
const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown'
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<span className="system-name">{name}</span>
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
</div>
)
})
})()}
</div>
</div>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show last</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
)}
</div>
<div className="recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span>
</div>
) : recentError ? (
<button type="button" disabled>
{recentError}
</button>
) : recent.length === 0 ? (
<button type="button" disabled>
No recent requests found
</button>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url && (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
</span>
</span>
</button>
))
)}
</div>
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Find my request</h1>
<p className="lede">
Search by title + year, paste a request number, or pick from your recent requests.
</p>
</div>
<div className="find-controls">
<form onSubmit={submit} className="search search-row">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="e.g. Dune 2021 or 1289"
/>
<button type="submit">Check status</button>
</form>
<div className="filters filters-compact">
<div className="filter">
<span>Type</span>
<div className="pill-group">
<button type="button">TV</button>
<button type="button">Movie</button>
</div>
</div>
<div className="filter">
<span>Status</span>
<div className="pill-group">
<button type="button">Pending</button>
<button type="button">Approved</button>
<button type="button">Processing</button>
<button type="button">Failed</button>
<button type="button">Available</button>
</div>
</div>
</div>
</div>
<section className="recent results-panel">
<h2>Search results</h2>
<div className="recent-grid">
{searchError ? (
<button type="button" disabled>
{searchError}
</button>
) : searchResults.length === 0 ? (
<button type="button" disabled>
No matches yet
</button>
) : (
searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: ''}
</button>
))
)}
</div>
</section>
</section>
</aside>
</div>
</main>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
username: string
role: string
auth_provider: string
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
setProfile({
username: data?.username ?? 'Unknown',
role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local',
})
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setStatus(null)
if (!currentPassword || !newPassword) {
setStatus('Enter your current password and a new password.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setCurrentPassword('')
setNewPassword('')
setStatus('Password updated.')
} catch (err) {
console.error(err)
setStatus('Could not update password. Check your current password.')
}
}
if (loading) {
return <main className="card">Loading profile...</main>
}
return (
<main className="card">
<h1>My profile</h1>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
)}
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.
</div>
) : (
<form onSubmit={submit} className="auth-form">
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Update password</button>
</div>
</form>
)}
</main>
)
}

View File

@@ -0,0 +1,666 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type TimelineHop = {
service: string
status: string
details?: Record<string, any>
timestamp?: string
}
type Snapshot = {
request_id: string
title: string
year?: number
request_type: string
state: string
state_reason?: string
timeline: TimelineHop[]
actions: { id: string; label: string; risk: string; requires_confirmation: boolean }[]
artwork?: { poster_url?: string; backdrop_url?: string }
raw?: Record<string, any>
}
type ReleaseOption = {
title?: string
indexer?: string
indexerId?: number
guid?: string
size?: number
seeders?: number
leechers?: number
protocol?: string
infoUrl?: string
}
type SnapshotHistory = {
request_id: string
state: string
state_reason?: string
created_at: string
}
type ActionHistory = {
request_id: string
action_id: string
label: string
status: string
message?: string
created_at: string
}
const percentFromTorrent = (torrent: Record<string, any>) => {
const progress = Number(torrent.progress)
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) {
return Math.round(progress * 100)
}
const size = Number(torrent.size)
const left = Number(torrent.amount_left)
if (!Number.isNaN(size) && size > 0 && !Number.isNaN(left)) {
return Math.round(((size - left) / size) * 100)
}
return null
}
const formatBytes = (value?: number) => {
if (!value || Number.isNaN(value)) return 'n/a'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let idx = 0
while (size >= 1024 && idx < units.length - 1) {
size /= 1024
idx += 1
}
return `${size.toFixed(1)} ${units[idx]}`
}
type SeasonStat = {
seasonNumber: number
available: number
missing: number
}
const seasonStatsFromSeries = (series: Record<string, any>): SeasonStat[] => {
const dateValue = series?.previousAiring ?? series?.firstAired
const airedAt = dateValue ? new Date(dateValue) : null
if (!airedAt || Number.isNaN(airedAt.valueOf()) || airedAt > new Date()) {
return []
}
const seasons = Array.isArray(series?.seasons) ? series.seasons : []
return seasons
.filter((season: Record<string, any>) => season?.monitored === true)
.map((season: Record<string, any>) => {
const stats = season.statistics
const available = stats && typeof stats === 'object' ? Number(stats.episodeFileCount) : NaN
const aired = stats && typeof stats === 'object' ? Number(stats.episodeCount) : NaN
const fallbackTotal =
stats && typeof stats === 'object' ? Number(stats.totalEpisodeCount) : NaN
const total = !Number.isNaN(aired) && aired > 0 ? aired : fallbackTotal
const seasonDateValue = stats?.previousAiring ?? stats?.firstAired ?? null
const seasonAiredAt = seasonDateValue ? new Date(seasonDateValue) : null
if (
!Number.isNaN(available) &&
!Number.isNaN(total) &&
total > 0 &&
(!seasonAiredAt || Number.isNaN(seasonAiredAt.valueOf()) || seasonAiredAt <= new Date())
) {
return {
seasonNumber: season.seasonNumber,
available,
missing: Math.max(0, total - available),
}
}
return null
})
.filter((season): season is SeasonStat => season !== null && season.missing > 0)
}
const friendlyState = (value: string) => {
const map: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved and queued',
NEEDS_ADD: 'Needs adding to the library',
ADDED_TO_ARR: 'Added to the library queue',
SEARCHING: 'Searching for releases',
GRABBED: 'Download queued',
DOWNLOADING: 'Downloading',
IMPORTING: 'Adding to your library',
COMPLETED: 'Ready to watch',
AVAILABLE: 'Ready to watch',
FAILED: 'Needs attention',
UNKNOWN: 'Status not available yet',
}
return map[value] ?? value.replaceAll('_', ' ').toLowerCase()
}
const friendlyTimelineStatus = (service: string, status: string) => {
if (service === 'Jellyseerr') {
const map: Record<string, string> = {
Pending: 'Waiting for approval',
Approved: 'Approved',
Declined: 'Declined',
Available: 'Ready to watch',
Processing: 'Working on it',
'Partially Available': 'Partially ready',
'Waiting for approval': 'Waiting for approval',
'Working on it': 'Working on it',
'Partially ready': 'Partially ready',
'Ready to watch': 'Ready to watch',
}
return map[status] ?? status
}
if (service === 'Sonarr/Radarr') {
const map: Record<string, string> = {
missing: 'Not added yet',
added: 'Added to the library queue',
searching: 'Searching for releases',
available: 'Ready to watch',
error: 'Needs attention',
unknown: 'Checking…',
}
return map[status] ?? status
}
if (service === 'Prowlarr') {
const map: Record<string, string> = {
ok: 'Search sources OK',
issues: 'Search sources need attention',
error: 'Search sources unavailable',
}
return map[status] ?? status
}
if (service === 'qBittorrent') {
const map: Record<string, string> = {
downloading: 'Downloading',
paused: 'Paused',
completed: 'Content downloaded',
idle: 'No active downloads',
error: 'Downloader error',
}
return map[status] ?? status
}
if (service === 'Jellyfin') {
const map: Record<string, string> = {
available: 'Ready to watch',
missing: 'Not in Jellyfin yet',
error: 'Jellyfin unavailable',
}
return map[status] ?? status
}
return status
}
export default function RequestTimelinePage({ params }: { params: { id: string } }) {
const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true)
const [showDetails, setShowDetails] = useState(false)
const [actionMessage, setActionMessage] = useState<string | null>(null)
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
const [searchRan, setSearchRan] = useState(false)
const [modalMessage, setModalMessage] = useState<string | null>(null)
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
useEffect(() => {
const load = async () => {
try {
if (!getToken()) {
router.push('/login')
return
}
const baseUrl = getApiBase()
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${params.id}/snapshot`),
authFetch(`${baseUrl}/requests/${params.id}/history?limit=5`),
authFetch(`${baseUrl}/requests/${params.id}/actions?limit=5`),
])
if (snapshotResponse.status === 401) {
clearToken()
router.push('/login')
return
}
const snapshotData = await snapshotResponse.json()
setSnapshot(snapshotData)
setReleaseOptions([])
setSearchRan(false)
setModalMessage(null)
if (historyResponse.ok) {
const historyData = await historyResponse.json()
if (Array.isArray(historyData.snapshots)) {
setHistorySnapshots(historyData.snapshots)
}
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json()
if (Array.isArray(actionsData.actions)) {
setHistoryActions(actionsData.actions)
}
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
load()
}, [params.id])
if (loading) {
return (
<main className="card">
<div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" />
<div className="loading-text">Loading request timeline...</div>
</div>
</main>
)
}
if (!snapshot) {
return <main className="card">Could not load that request.</main>
}
const summary =
snapshot.state_reason ??
`This request is currently ${snapshot.state.replaceAll('_', ' ').toLowerCase()}.`
const downloadHop = snapshot.timeline.find((hop) => hop.service === 'qBittorrent')
const downloadState = downloadHop?.details?.summary ?? downloadHop?.status ?? 'Unknown'
const jellyfinAvailable = Boolean(snapshot.raw?.jellyfin?.available)
const pipelineSteps = [
{ key: 'Jellyseerr', label: 'Jellyseerr' },
{ key: 'Sonarr/Radarr', label: 'Library queue' },
{ key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' },
{ key: 'Jellyfin', label: 'Jellyfin' },
]
const stageFromState = (state: string) => {
if (jellyfinAvailable || state === 'COMPLETED' || state === 'AVAILABLE') return 4
if (state === 'DOWNLOADING' || state === 'IMPORTING') return 3
if (state === 'GRABBED') return 2
if (state === 'SEARCHING' || state === 'ADDED_TO_ARR' || state === 'NEEDS_ADD') return 1
if (state === 'APPROVED' || state === 'REQUESTED') return 0
return 1
}
const activeStage = stageFromState(snapshot.state)
const extendedTimeline: TimelineHop[] = [
...snapshot.timeline,
{
service: 'Jellyfin',
status: jellyfinAvailable ? 'available' : 'missing',
details: snapshot.raw?.jellyfin ?? {},
},
]
const jellyfinLink = snapshot.raw?.jellyfin?.link
const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster =
posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
return (
<main className="card">
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<img
className="request-poster"
src={resolvedPoster}
alt={`${snapshot.title} poster`}
loading="lazy"
/>
)}
<div>
<h1>{snapshot.title}</h1>
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
</div>
</div>
{jellyfinAvailable && jellyfinLink && (
<a className="ghost-button" href={jellyfinLink} target="_blank" rel="noreferrer">
Open in Jellyfin
</a>
)}
</div>
<section className="status-box">
<div>
<h2>Status</h2>
<p className="status-text">{friendlyState(snapshot.state)}</p>
</div>
<div>
<h2>What this means</h2>
<p>{summary}</p>
</div>
{(actionMessage || (searchRan && releaseOptions.length === 0)) && (
<div>
<h2>Last action</h2>
{actionMessage && <p>{actionMessage}</p>}
{searchRan && releaseOptions.length === 0 && (
<p>Nothing to grab yet. We did not find a match on your torrent providers.</p>
)}
</div>
)}
<div>
<h2>Current download state</h2>
<p>{downloadState}</p>
</div>
<div>
<h2>Next step</h2>
<p>
{snapshot.actions.length === 0
? 'Nothing to do right now.'
: snapshot.actions[0].label}
</p>
<p className="helper">
Use the buttons below if you want to run a safe retry or a fix.
</p>
</div>
</section>
<div className="details-toggle">
<button type="button" onClick={() => setShowDetails((value) => !value)}>
{showDetails ? 'Hide details' : 'Show details (advanced)'}
</button>
</div>
<section className="pipeline-map">
<h2>Pipeline location</h2>
<div className="pipeline-steps">
{pipelineSteps.map((step, index) => (
<div
key={step.key}
className={`pipeline-step ${
index === activeStage ? 'is-active' : index < activeStage ? 'is-complete' : ''
}`}
>
<div className="pipeline-dot" />
<span>{step.label}</span>
</div>
))}
</div>
<p className="pipeline-hint">The glowing light shows where your request is right now.</p>
</section>
<section className="timeline">
{extendedTimeline.map((hop, index) => (
<div
key={`${hop.service}-${index}`}
className={`timeline-item ${
hop.service === pipelineSteps[activeStage]?.key ? 'is-active' : ''
}`}
>
<div className="timeline-marker" />
<div className="timeline-card">
<div className="timeline-title">
<strong>{hop.service}</strong>
<span>{friendlyTimelineStatus(hop.service, hop.status)}</span>
</div>
{hop.service === 'Sonarr/Radarr' && hop.details?.series && (() => {
const seasons = seasonStatsFromSeries(hop.details.series)
if (seasons.length === 0) {
return <div className="meta">Up to date</div>
}
return (
<div className="timeline-sublist">
<div className="meta">Seasons available vs missing</div>
<ul>
{seasons.map((season) => (
<li key={season.seasonNumber}>
<span>Season {season.seasonNumber}</span>
<span>{season.available} available / {season.missing} missing</span>
</li>
))}
</ul>
</div>
)
})()}
{hop.service === 'Sonarr/Radarr' && hop.details?.missingEpisodes && (
<div className="timeline-sublist">
<div className="meta">Missing episodes</div>
<ul>
{Object.entries(hop.details.missingEpisodes as Record<string, number[]>).map(
([seasonNumber, episodes]) => (
<li key={seasonNumber}>
<span>Season {seasonNumber}</span>
<span>
{episodes.length
? episodes.map((ep) => `E${ep}`).join(', ')
: 'Episode numbers unavailable'}
</span>
</li>
)
)}
</ul>
</div>
)}
{hop.service === 'Sonarr/Radarr' && hop.details?.note && (
<div className="meta">{hop.details.note}</div>
)}
{hop.service === 'qBittorrent' &&
Array.isArray(hop.details?.torrents) &&
hop.details.torrents.length > 0 && (
<div className="timeline-sublist">
<div className="meta">Downloads in qBittorrent</div>
<ul>
{hop.details.torrents.map((torrent: Record<string, any>) => {
const percent = percentFromTorrent(torrent)
return (
<li key={torrent.hash ?? torrent.name}>
<span>{torrent.name ?? 'Unknown item'}</span>
<span>{percent === null ? 'n/a' : `${percent}%`}</span>
</li>
)
})}
</ul>
</div>
)}
{showDetails && hop.details && (
<pre>{JSON.stringify(hop.details, null, 2)}</pre>
)}
</div>
</div>
))}
</section>
<section className="actions">
<h2>Try a safe fix</h2>
{actionMessage && <div className="action-message">{actionMessage}</div>}
<div className="action-grid">
{snapshot.actions.map((action) => (
<button
key={action.id}
type="button"
onClick={async () => {
if (!snapshot) return
if (action.requires_confirmation) {
const ok = window.confirm(
`Run "${action.label}"? This action may change system state.`
)
if (!ok) return
}
const baseUrl = getApiBase()
const actionMap: Record<string, string> = {
search: 'actions/search',
resume_torrent: 'actions/qbit/resume',
readd_to_arr: 'actions/readd',
}
const path = actionMap[action.id]
if (!path) {
setActionMessage('This action is not wired yet.')
return
}
if (action.id === 'search') {
setActionMessage(null)
setReleaseOptions([])
setSearchRan(false)
setModalMessage(null)
}
try {
const response = await authFetch(`${baseUrl}/requests/${snapshot.request_id}/${path}`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
const data = await response.json()
if (action.id === 'search') {
if (Array.isArray(data.releases)) {
setReleaseOptions(data.releases)
}
setSearchRan(true)
if (!Array.isArray(data.releases) || data.releases.length === 0) {
setModalMessage(
'Nothing to grab yet. We searched your torrent providers but found no matches.'
)
} else {
setModalMessage('Search complete. Pick an option below if you want to download.')
}
setActionMessage(`${action.label} started.`)
} else {
const message = data?.message ?? `${action.label} started.`
setActionMessage(message)
setModalMessage(message)
}
} catch (error) {
console.error(error)
const message = `${action.label} failed. Check the backend logs.`
setActionMessage(message)
setModalMessage(message)
}
}}
>
{action.label}
<span>
{action.risk === 'low'
? 'safe'
: action.risk === 'medium'
? 'caution'
: action.risk === 'high'
? 'high impact'
: action.risk}
</span>
</button>
))}
</div>
{releaseOptions.length > 0 && (
<div className="timeline-sublist">
<div className="meta">Download options found</div>
<ul>
{releaseOptions.map((release) => (
<li key={`${release.guid ?? release.title}`}>
<span>
{release.title ?? 'Unknown option'}{' '}
<small>{release.indexer ? `(${release.indexer})` : ''}</small>
</span>
<span>{release.seeders ?? 0} seeders · {formatBytes(release.size)}</span>
<button
type="button"
onClick={async () => {
if (!snapshot || !release.guid || !release.indexerId) {
setActionMessage('Missing details to start the download.')
setModalMessage('Missing details to start the download.')
return
}
const ok = window.confirm(`Download "${release.title}"?`)
if (!ok) return
const baseUrl = getApiBase()
try {
const response = await authFetch(
`${baseUrl}/requests/${snapshot.request_id}/actions/grab`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guid: release.guid,
indexerId: release.indexerId,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to Sonarr/Radarr.')
} catch (error) {
console.error(error)
const message = 'Download failed. Check the logs.'
setActionMessage(message)
setModalMessage(message)
}
}}
>
Download
</button>
</li>
))}
</ul>
</div>
)}
</section>
<section className="history">
<h2>History</h2>
<div className="history-grid">
<div className="summary-card">
<h3>Recent status changes</h3>
<ul>
{historySnapshots.length === 0 ? (
<li>No history recorded yet.</li>
) : (
historySnapshots.map((entry) => (
<li key={`${entry.created_at}-${entry.state}`}>
<span>{entry.state.replaceAll('_', ' ')}</span>
<span>{entry.state_reason ?? 'No reason provided.'}</span>
</li>
))
)}
</ul>
</div>
<div className="summary-card">
<h3>Recent actions</h3>
<ul>
{historyActions.length === 0 ? (
<li>No actions recorded yet.</li>
) : (
historyActions.map((entry) => (
<li key={`${entry.created_at}-${entry.action_id}`}>
<span>{entry.label}</span>
<span>{entry.message ?? entry.status}</span>
</li>
))
)}
</ul>
</div>
</div>
</section>
{modalMessage && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal-card">
<h2>Update</h2>
<p>{modalMessage}</p>
<button type="button" onClick={() => setModalMessage(null)}>
Got it
</button>
</div>
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { ReactNode } from 'react'
import AdminSidebar from './AdminSidebar'
type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
children: ReactNode
}
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
<main className="card admin-card">
<div className="admin-header">
<div>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],
},
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Admin',
items: [
{ href: '/users', label: 'Users' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],
},
]
export default function AdminSidebar() {
const pathname = usePathname()
return (
<nav className="admin-sidebar">
<div className="admin-sidebar-title">Settings</div>
{NAV_GROUPS.map((group) => (
<div key={group.title} className="admin-nav-group">
<span className="admin-nav-title">{group.title}</span>
<div className="admin-nav-links">
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
{item.label}
</a>
)
})}
</div>
</div>
))}
</nav>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { useEffect } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
export default function BrandingFavicon() {
useEffect(() => {
const baseUrl = getApiBase()
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
const href = `${baseUrl}/branding/favicon.ico${versionSuffix}`
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
}, [])
return null
}

View File

@@ -0,0 +1,36 @@
'use client'
import { useEffect, useState } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
type BrandingLogoProps = {
className?: string
alt?: string
}
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
const baseUrl = getApiBase()
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
setSrc(`${baseUrl}/branding/logo.png${versionSuffix}`)
}, [])
if (!src) {
return null
}
return (
<img
className={className}
src={src}
alt={alt}
onError={() => setSrc(null)}
/>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
} catch (err) {
console.error(err)
}
}
void load()
}, [])
const signOut = () => {
clearToken()
setSignedIn(false)
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
return (
<div className="header-actions">
<a href="/">Requests</a>
<a href="/how-it-works">How it works</a>
{signedIn && <a href="/profile">My profile</a>}
{role === 'admin' && <a href="/admin">Settings</a>}
{signedIn ? (
<button type="button" className="header-link" onClick={signOut}>
Sign out
</button>
) : (
<a href="/login">Sign in</a>
)}
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<string | null>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
const token = getToken()
if (!token) {
setIdentity(null)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setIdentity(null)
return
}
const data = await response.json()
if (data?.username) {
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
}
} catch (err) {
console.error(err)
setIdentity(null)
}
}
void load()
}, [])
if (!identity) {
return null
}
return (
<div className="signed-in-menu">
<button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}>
Signed in as {identity}
</button>
{open && (
<div className="signed-in-dropdown">
<a href="/profile">My profile</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'magent_theme'
const getPreferredTheme = () => {
if (typeof window === 'undefined') return 'dark'
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (theme: string) => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const preferred = getPreferredTheme()
setTheme(preferred)
applyTheme(preferred)
}, [])
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
</svg>
)}
</button>
)
}

228
frontend/app/users/page.tsx Normal file
View File

@@ -0,0 +1,228 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminUser = {
username: string
role: string
authProvider?: string | null
lastLoginAt?: string | null
isBlocked?: boolean
}
const formatLastLogin = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
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 [passwordInputs, setPasswordInputs] = useState<Record<string, string>>({})
const [passwordStatus, setPasswordStatus] = useState<Record<string, string>>({})
const loadUsers = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users`)
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),
}))
)
} 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 updateUserPassword = async (username: string) => {
const newPassword = passwordInputs[username] || ''
if (!newPassword || newPassword.length < 8) {
setPasswordStatus((current) => ({
...current,
[username]: 'Password must be at least 8 characters.',
}))
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setPasswordInputs((current) => ({ ...current, [username]: '' }))
setPasswordStatus((current) => ({
...current,
[username]: 'Password updated.',
}))
} catch (err) {
console.error(err)
setPasswordStatus((current) => ({
...current,
[username]: 'Could not update password.',
}))
}
}
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>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="admin-grid">
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<strong>{user.username}</strong>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) =>
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
>
{user.isBlocked ? 'Allow access' : 'Block access'}
</button>
</div>
{user.authProvider === 'local' && (
<div className="user-actions">
<input
type="password"
placeholder="New password (min 8 chars)"
value={passwordInputs[user.username] || ''}
onChange={(event) =>
setPasswordInputs((current) => ({
...current,
[user.username]: event.target.value,
}))
}
/>
<button type="button" onClick={() => updateUserPassword(user.username)}>
Set password
</button>
</div>
)}
{passwordStatus[user.username] && (
<div className="meta">{passwordStatus[user.username]}</div>
)}
</div>
))}
</div>
)}
</section>
</AdminShell>
)
}