admin docs and layout refresh, build 2702261314

This commit is contained in:
2026-02-27 13:17:50 +13:00
parent b84c27c698
commit 05a3d1e3b0
10 changed files with 1400 additions and 274 deletions

View File

@@ -1,4 +1,4 @@
BUILD_NUMBER = "2702261153"
BUILD_NUMBER = "2702261314"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -1262,6 +1262,86 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}
const cacheSourceLabel =
formValues.requests_data_source === 'always_js'
? 'Jellyseerr direct'
: formValues.requests_data_source === 'prefer_cache'
? 'Saved requests only'
: 'Saved requests only'
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
const cacheRail = showCacheExtras ? (
<div className="admin-rail-stack">
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Cache control</span>
<h2>Saved requests</h2>
<p>Load and inspect cached request entries from the right rail.</p>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Data source</span>
<strong>{cacheSourceLabel}</strong>
</div>
<div className="cache-rail-metric">
<span>Refresh TTL</span>
<strong>{cacheTtlLabel} min</strong>
</div>
<div className="cache-rail-metric">
<span>Rows loaded</span>
<strong>{cacheRows.length}</strong>
</div>
<div className="cache-rail-metric">
<span>Live updates</span>
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
</div>
</div>
<label className="cache-rail-limit">
<span>Rows to load</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
</div>
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Artwork</span>
<h2>Cache stats</h2>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Missing artwork</span>
<strong>{artworkSummary?.missing_artwork ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Cache size</span>
<strong>{formatBytes(artworkSummary?.cache_bytes)}</strong>
</div>
<div className="cache-rail-metric">
<span>Cached files</span>
<strong>{artworkSummary?.cache_files ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Mode</span>
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
</div>
</div>
</div>
</div>
) : undefined
if (loading) {
return <main className="card">Loading admin settings...</main>
}
@@ -1270,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell
title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={cacheRail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
@@ -1893,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<section className="admin-section" id="cache">
<div className="section-header">
<h2>Saved requests (cache)</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Rows to show</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
</div>
</div>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<div className="cache-table">
<div className="cache-row cache-head">
<span>Request</span>

View File

@@ -70,6 +70,21 @@ type ProfileForm = {
}
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
type InviteTraceRow = {
username: string
role: string
authProvider: string
level: number
inviterUsername: string | null
inviteCode: string | null
inviteLabel: string | null
createdAt: string | null
childCount: number
isCycle?: boolean
}
type InvitePolicy = {
master_invite_id?: number | null
@@ -105,6 +120,9 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString()
}
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
@@ -135,6 +153,8 @@ export default function AdminInviteManagementPage() {
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
@@ -698,74 +718,116 @@ export default function AdminInviteManagementPage() {
)
}, [invites, traceFilter, users])
const scopedInviteTraceRows = useMemo(() => {
if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row))
if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row))
return inviteTraceRows
}, [inviteTraceRows, traceScope])
const traceInvitedCount = useMemo(
() => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length,
[inviteTraceRows]
)
const traceDirectCount = inviteTraceRows.length - traceInvitedCount
const inviteTraceGraphColumns = useMemo(() => {
if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }>
const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level))
const grouped = new Map<number, InviteTraceRow[]>()
scopedInviteTraceRows.forEach((row) => {
const level = Math.max(0, row.level - minLevel)
const bucket = grouped.get(level) ?? []
bucket.push(row)
grouped.set(level, bucket)
})
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map(([level, rows]) => ({
level,
rows: [...rows].sort((a, b) =>
String(a.username || '').localeCompare(String(b.username || ''), undefined, {
sensitivity: 'base',
})
),
}))
}, [scopedInviteTraceRows])
const inviteManagementRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<span className="admin-rail-eyebrow">Overview</span>
<h2>Invite stats</h2>
<p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>
{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-panel invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<h2>Overview</h2>
<p className="lede">
Quick counts for invite links, profiles, and managed user defaults.
</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
</div>
</div>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
@@ -833,8 +895,8 @@ export default function AdminInviteManagementPage() {
</div>
{activeTab === 'bulk' && (
<div className="admin-split-grid invite-admin-bulk-grid">
<div className="admin-panel">
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Blanket controls</h2>
@@ -843,13 +905,6 @@ export default function AdminInviteManagementPage() {
</p>
</div>
</div>
<div className="admin-meta-row">
<span>Local non-admin users: {nonAdminUsers.length}</span>
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
<span>Profile assigned: {profiledUsers}</span>
<span>Custom expiry set: {expiringUsers}</span>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<div className="user-bulk-group-meta">
@@ -961,46 +1016,6 @@ export default function AdminInviteManagementPage() {
</div>
</div>
</div>
<div className="admin-panel">
<div className="user-directory-panel-header">
<div>
<h2>How this page is organized</h2>
<p className="lede">Use tabs to switch between blanket controls, reusable profiles, and invite links.</p>
</div>
</div>
<div className="admin-list">
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Profiles</strong>
</div>
<p className="admin-list-item-text">
Create reusable account defaults and apply them to invite links or existing users.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('profiles')}>
Open
</button>
</div>
</div>
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Invites</strong>
</div>
<p className="admin-list-item-text">
Create and manage signup links, assign profiles, and copy shareable URLs.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('invites')}>
Open
</button>
</div>
</div>
</div>
</div>
</div>
)}
@@ -1410,19 +1425,105 @@ export default function AdminInviteManagementPage() {
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-controls">
<label className="invite-trace-scope">
<span>Scope</span>
<select
value={traceScope}
onChange={(e) =>
setTraceScope(e.target.value as InviteTraceScope)
}
>
<option value="all">All users</option>
<option value="invited">Invited only</option>
<option value="direct">Direct / root only</option>
</select>
</label>
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
<button
type="button"
className={traceView === 'graph' ? 'is-active' : ''}
onClick={() => setTraceView('graph')}
>
Graph
</button>
<button
type="button"
className={traceView === 'list' ? 'is-active' : ''}
onClick={() => setTraceView('list')}
>
List
</button>
</div>
</div>
<div className="invite-trace-summary">
<span>{inviteTraceRows.length} rows shown</span>
<span>{scopedInviteTraceRows.length} rows shown</span>
<span>{traceInvitedCount} invited</span>
<span>{traceDirectCount} direct/root</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? (
) : scopedInviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : traceView === 'graph' ? (
<div className="invite-trace-graph">
{inviteTraceGraphColumns.map((column) => (
<section key={`trace-level-${column.level}`} className="invite-trace-column">
<header className="invite-trace-column-header">
<span>Level {column.level}</span>
<strong>{column.rows.length}</strong>
</header>
<div className="invite-trace-column-body">
{column.rows.map((row) => (
<article
key={`${row.username}-${column.level}-${row.inviteCode || 'direct'}`}
className="invite-trace-node"
>
<div className="invite-trace-node-main">
<div className="invite-trace-node-title">
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<p className={`invite-trace-node-arrow ${isInviteTraceRowInvited(row) ? '' : 'is-root'}`}>
{row.inviterUsername
? `\u2190 Invited by ${row.inviterUsername}`
: row.inviteCode
? `\u2190 Invited via code ${row.inviteCode}`
: 'Direct/root account'}
</p>
</div>
<div className="invite-trace-node-meta">
<span className="invite-trace-node-meta-item">
<span className="label">Invite code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
) : (
<div className="invite-trace-map">
{inviteTraceRows.map((row) => (
{scopedInviteTraceRows.map((row) => (
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" />

View File

@@ -0,0 +1,211 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type FlowStage = {
title: string
input: string
action: string
output: string
}
const REQUEST_FLOW: FlowStage[] = [
{
title: 'Identity + access',
input: 'Jellyfin/local login',
action: 'Magent validates credentials and role',
output: 'JWT token + user scope',
},
{
title: 'Request intake',
input: 'Jellyseerr request ID',
action: 'Magent snapshots request + media metadata',
output: 'Unified request state',
},
{
title: 'Queue orchestration',
input: 'Approved request',
action: 'Sonarr/Radarr add/search operations',
output: 'Grab decision',
},
{
title: 'Download execution',
input: 'Selected release',
action: 'qBittorrent downloads + reports progress',
output: 'Import-ready payload',
},
{
title: 'Library import',
input: 'Completed download',
action: 'Sonarr/Radarr import and finalize',
output: 'Available media object',
},
{
title: 'Playback availability',
input: 'Imported media',
action: 'Jellyfin refresh + link resolution',
output: 'Ready-to-watch state',
},
]
export default function AdminSystemGuidePage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
router.push('/')
return
}
const me = await response.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
}
setAuthorized(true)
} catch (error) {
console.error(error)
router.push('/')
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
if (loading) {
return <main className="card">Loading system guide...</main>
}
if (!authorized) {
return null
}
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Guide map</span>
<h2>Quick path</h2>
<p>Identity Intake Queue Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
)
return (
<AdminShell
title="System guide"
subtitle="Admin-only architecture and operational flow for Magent."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the exact runtime path for request processing and availability in the current build.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment">
<article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
</div>
<div className="system-flow-card-row">
<span>Action</span>
<strong>{stage.action}</strong>
</div>
<div className="system-flow-card-row">
<span>Output</span>
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>}
</div>
))}
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>General</h3>
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
</article>
<article className="system-guide-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
</article>
<article className="system-guide-card">
<h3>Users</h3>
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Live request page</h3>
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
<li>
Request approved but not in Arr queue <span></span> run <strong>Re-add to Arr</strong>.
</li>
<li>
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li>
<li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>.
</li>
<li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
</li>
<li>
Imported but not visible to user <span></span> validate Jellyfin visibility/link from request page.
</li>
</ol>
</div>
</section>
</AdminShell>
)
}

View File

@@ -4193,7 +4193,7 @@ button:hover:not(:disabled) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
align-items: end;
align-items: flex-end;
}
.user-directory-search {
@@ -4531,19 +4531,22 @@ button:hover:not(:disabled) {
.invite-admin-tabbar {
display: grid;
grid-template-columns: 1fr;
align-items: start;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px 12px;
margin-bottom: 12px;
}
.invite-admin-tabbar .admin-segmented {
margin-bottom: 0;
width: max-content;
max-width: 100%;
}
.invite-admin-tab-actions {
width: 100%;
width: auto;
justify-content: flex-end;
align-self: center;
}
.invite-admin-stack {
@@ -4551,6 +4554,11 @@ button:hover:not(:disabled) {
gap: 12px;
}
.invite-admin-bulk-panel .user-bulk-groups {
display: grid;
gap: 10px;
}
.invite-admin-list-panel,
.invite-admin-form-panel {
width: 100%;
@@ -4709,6 +4717,7 @@ button:hover:not(:disabled) {
}
.invite-admin-tabbar {
grid-template-columns: 1fr;
align-items: stretch;
}
@@ -4761,12 +4770,16 @@ textarea {
.invite-trace-toolbar {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
grid-template-areas:
'filter controls'
'summary summary';
gap: 10px 14px;
align-items: end;
align-items: flex-end;
margin-bottom: 10px;
}
.invite-trace-filter {
grid-area: filter;
display: grid;
gap: 6px;
}
@@ -4779,6 +4792,7 @@ textarea {
}
.invite-trace-summary {
grid-area: summary;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
@@ -4794,11 +4808,164 @@ textarea {
border-radius: 5px;
}
.invite-trace-controls {
grid-area: controls;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-end;
gap: 10px;
}
.invite-trace-scope {
display: grid;
gap: 6px;
min-width: 190px;
}
.invite-trace-scope > span {
color: #9ea7b6;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-view-toggle {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
border-radius: 5px;
overflow: hidden;
}
.invite-trace-view-toggle button {
min-width: 90px;
border: 0;
border-right: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0;
background: transparent;
color: #aeb7c4;
padding: 7px 12px;
}
.invite-trace-view-toggle button:last-child {
border-right: 0;
}
.invite-trace-view-toggle button.is-active {
background: rgba(111, 148, 224, 0.22);
color: #eef3fb;
font-weight: 700;
}
.invite-trace-map {
display: grid;
gap: 8px;
}
.invite-trace-graph {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(260px, 1fr);
align-items: start;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.invite-trace-column {
display: grid;
gap: 8px;
align-content: start;
min-width: 260px;
}
.invite-trace-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.015);
border-radius: 5px;
color: #b5c0d2;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-column-header strong {
color: #edf2f8;
font-size: 0.86rem;
}
.invite-trace-column-body {
display: grid;
gap: 8px;
}
.invite-trace-node {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.018);
border-radius: 6px;
}
.invite-trace-node-main {
display: grid;
gap: 6px;
}
.invite-trace-node-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.invite-trace-node-arrow {
margin: 0;
color: #c4cfdd;
font-size: 0.82rem;
letter-spacing: 0.01em;
}
.invite-trace-node-arrow.is-root {
color: #95a2b5;
}
.invite-trace-node-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.invite-trace-node-meta-item {
display: grid;
gap: 2px;
align-content: start;
min-height: 44px;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.01);
border-radius: 5px;
}
.invite-trace-node-meta-item .label {
color: #8f9aac;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-node-meta-item strong {
color: #e7edf6;
font-size: 0.81rem;
word-break: break-word;
}
.invite-trace-row {
display: grid;
grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
@@ -4876,13 +5043,30 @@ textarea {
@media (max-width: 1180px) {
.invite-trace-toolbar {
grid-template-columns: 1fr;
grid-template-areas:
'filter'
'controls'
'summary';
align-items: stretch;
}
.invite-trace-controls {
justify-content: flex-start;
}
.invite-trace-summary {
justify-content: flex-start;
}
.invite-trace-graph {
grid-auto-flow: row;
grid-auto-columns: 1fr;
}
.invite-trace-column {
min-width: 0;
}
.invite-trace-row {
grid-template-columns: 1fr;
}
@@ -4893,6 +5077,10 @@ textarea {
}
@media (max-width: 720px) {
.invite-trace-node-meta {
grid-template-columns: 1fr;
}
.invite-trace-row-meta {
grid-template-columns: 1fr;
}
@@ -4969,6 +5157,119 @@ textarea {
color: #e7edf6;
}
/* Admin system guide */
.system-guide {
display: grid;
gap: 12px;
}
.system-flow-track {
display: grid;
gap: 10px;
margin-top: 8px;
}
.system-flow-segment {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.system-flow-card {
display: grid;
gap: 7px;
padding: 11px 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
}
.system-flow-card-title {
color: #edf2f8;
font-weight: 700;
letter-spacing: 0.01em;
}
.system-flow-card-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.system-flow-card-row span {
color: #8f9aac;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.system-flow-card-row strong {
color: #dfe8f5;
font-size: 0.86rem;
font-weight: 600;
}
.system-flow-arrow {
color: #7ea1d8;
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
}
.system-guide-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
}
.system-guide-card {
padding: 11px 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
display: grid;
gap: 6px;
}
.system-guide-card h3 {
color: #eef3fb;
font-size: 0.97rem;
}
.system-guide-card p {
color: #a7b2c2;
margin: 0;
}
.system-decision-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: grid;
gap: 7px;
}
.system-decision-list li {
padding: 9px 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 6px;
color: #d3dce9;
}
.system-decision-list li span {
color: #7ea1d8;
font-weight: 700;
margin: 0 5px;
}
.system-decision-list li strong {
color: #eff5ff;
}
@media (max-width: 980px) {
.profile-invites-layout {
grid-template-columns: 1fr;
@@ -4979,6 +5280,287 @@ textarea {
grid-column: 1 / -1;
}
/* Admin shell right rail */
.admin-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
gap: 22px;
align-items: start;
}
.admin-shell-nav {
grid-column: 1;
}
.admin-card {
grid-column: 2;
min-width: 0;
}
.admin-shell-rail {
grid-column: 3;
position: sticky;
top: 20px;
align-self: start;
display: grid;
gap: 10px;
min-width: 0;
}
.admin-rail-stack {
display: grid;
gap: 10px;
}
.admin-rail-card {
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.016);
border-radius: 8px;
padding: 12px;
display: grid;
gap: 8px;
min-width: 0;
}
.admin-rail-card h2 {
margin: 0;
font-size: 1rem;
}
.admin-rail-card p {
margin: 0;
color: #9ba5b5;
}
.admin-rail-eyebrow {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #9ba5b5;
font-weight: 700;
}
.admin-shell-rail .invite-admin-summary-row {
grid-template-columns: 1fr;
align-items: start;
}
.admin-shell-rail .invite-admin-summary-row__value {
justify-content: space-between;
}
.cache-rail-card {
gap: 10px;
}
.cache-rail-metrics {
display: grid;
gap: 8px;
}
.cache-rail-metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.012);
padding: 8px 10px;
border-radius: 6px;
}
.cache-rail-metric span {
color: #9aa4b4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cache-rail-metric strong {
color: #eef3f9;
font-size: 0.92rem;
text-align: right;
overflow-wrap: anywhere;
}
.cache-rail-limit {
display: grid;
gap: 6px;
}
.cache-rail-limit > span {
color: #9aa4b4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Users page streamline pass */
.users-page-toolbar {
margin-bottom: 12px;
}
.users-page-toolbar-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.users-page-toolbar-group {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.016);
border-radius: 6px;
}
.users-page-toolbar-label {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #9ba5b5;
font-weight: 700;
}
.users-page-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.users-page-toolbar-actions button {
white-space: nowrap;
}
.users-page-overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 12px;
margin: 12px 0;
align-items: start;
}
.users-summary-panel {
display: grid;
gap: 10px;
}
.users-rail-summary .users-summary-grid {
grid-template-columns: 1fr;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.users-summary-card {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.014);
border-radius: 6px;
}
.users-summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.users-summary-label {
color: #a9b3c2;
font-size: 0.85rem;
font-weight: 600;
}
.users-summary-value {
color: #edf3fb;
font-size: 1.12rem;
line-height: 1;
font-weight: 700;
}
.users-summary-meta {
margin: 0;
color: #98a3b4;
font-size: 0.78rem;
line-height: 1.35;
}
.user-directory-search-panel {
margin-bottom: 12px;
}
.user-directory-bulk-panel .user-bulk-toolbar {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 10px;
}
.user-directory-bulk-panel .user-bulk-summary {
display: grid;
gap: 4px;
align-content: start;
min-width: 0;
}
.user-directory-bulk-panel .user-bulk-summary strong {
line-height: 1.32;
overflow-wrap: anywhere;
}
.user-directory-bulk-panel .user-bulk-actions {
align-self: start;
justify-content: flex-start;
}
.user-directory-bulk-panel .user-bulk-actions button {
min-width: 190px;
}
@media (max-width: 1400px) {
.admin-shell {
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr) minmax(270px, 320px);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
grid-column: 1;
}
.users-page-toolbar-grid,
.users-summary-grid {
grid-template-columns: 1fr;
}
.users-page-toolbar-actions button {
flex: 1 1 220px;
}
.user-directory-bulk-panel .user-bulk-actions {
width: 100%;
}
.user-directory-bulk-panel .user-bulk-actions button {
width: 100%;
min-width: 0;
}
}
/* Final header account menu stacking override (must be last) */
.page,
.header,
@@ -5022,3 +5604,46 @@ textarea {
position: absolute !important;
z-index: 5000 !important;
}
/* Final width scaling */
.page {
width: min(1680px, calc(100vw - 32px));
max-width: 1680px;
padding-inline: 16px;
}
@media (max-width: 1280px) {
.page {
width: min(1480px, calc(100vw - 24px));
max-width: 1480px;
padding-inline: 12px;
}
.admin-shell {
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
}
.admin-shell-rail {
grid-column: 2;
position: static;
top: auto;
}
}
@media (max-width: 980px) {
.page {
width: min(100%, calc(100vw - 12px));
max-width: none;
padding-inline: 6px;
}
.admin-shell {
grid-template-columns: 1fr;
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
grid-column: 1;
}
}

View File

@@ -5,10 +5,10 @@ export default function HowItWorksPage() {
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1>
<h1>How Magent works now</h1>
<p className="lede">
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
your request is and what you can safely do next.
End-to-end request flow, live status updates, and the exact tools available to users and
admins.
</p>
</header>
@@ -52,90 +52,172 @@ export default function HowItWorksPage() {
</section>
<section className="how-flow">
<h2>The pipeline in plain English</h2>
<h2>The pipeline (request to ready)</h2>
<ol className="how-steps">
<li>
<strong>You request a title</strong> in Jellyseerr.
<strong>Request created</strong> in Jellyseerr.
</li>
<li>
<strong>Sonarr/Radarr adds it</strong> to the library list.
<strong>Approved</strong> and sent to Sonarr/Radarr.
</li>
<li>
<strong>Prowlarr looks for sources</strong> and sends results back.
<strong>Search runs</strong> against indexers via Prowlarr.
</li>
<li>
<strong>qBittorrent downloads</strong> the match.
<strong>Grabbed</strong> and downloaded by qBittorrent.
</li>
<li>
<strong>Sonarr/Radarr imports</strong> it into your library.
<strong>Imported</strong> by Sonarr/Radarr.
</li>
<li>
<strong>Jellyfin shows it</strong> when it is ready to watch.
<strong>Available</strong> in Jellyfin.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Steps and fixes (simple and visual)</h2>
<h2>Live updates (no refresh needed)</h2>
<div className="how-step-grid">
<article className="how-step-card step-arr">
<div className="step-badge">1</div>
<h3>Request page updates in real time</h3>
<p className="step-note">
Status, timeline hops, and action history update automatically while you are viewing
the request.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Download progress updates live</h3>
<p className="step-note">
Torrent progress, queue state, and downloader details refresh automatically so users
do not need to hard refresh.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as import finishes</h3>
<p className="step-note">
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
shows it as ready.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<div className="how-step-grid">
<article className="how-step-card step-jellyseerr">
<div className="step-badge">1</div>
<h3>Request sent</h3>
<p className="step-note">Jellyseerr holds your request and approval.</p>
<div className="step-fix-title">Fixes you can try</div>
<h3>Re-add to Arr</h3>
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Add to library queue (if it was approved but never added)</li>
<li>Missing NEEDS_ADD / ADDED state transitions</li>
<li>Queue repair after Arr-side cleanup</li>
</ul>
</article>
<article className="how-step-card step-arr">
<div className="step-badge">2</div>
<h3>Added to the library list</h3>
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
<div className="step-fix-title">Fixes you can try</div>
<h3>Search releases</h3>
<p className="step-note">Runs a search and shows concrete release options.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Search for releases (see options)</li>
<li>Search and auto-download (let it pick for you)</li>
<li>Manual selection of a specific release/indexer</li>
<li>Checking whether results currently exist</li>
</ul>
</article>
<article className="how-step-card step-prowlarr">
<div className="step-badge">3</div>
<h3>Searching for sources</h3>
<p className="step-note">Prowlarr checks your torrent providers.</p>
<div className="step-fix-title">Fixes you can try</div>
<h3>Search + auto-download</h3>
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Search for releases (show a list to choose)</li>
<li>Fast recovery when users have auto-search access</li>
<li>Hands-off retry of stalled requests</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">4</div>
<h3>Downloading the file</h3>
<p className="step-note">qBittorrent downloads the selected match.</p>
<div className="step-fix-title">Fixes you can try</div>
<h3>Resume download</h3>
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Resume download (only if it already exists there)</li>
<li>Paused queue entries</li>
<li>Downloader restarts</li>
</ul>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">5</div>
<h3>Ready to watch</h3>
<p className="step-note">Jellyfin shows it in your library.</p>
<div className="step-fix-title">What to do next</div>
<h3>Open in Jellyfin</h3>
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Open in Jellyfin (watch it)</li>
<li>Immediate playback confirmation</li>
<li>User handoff from request tracking to watching</li>
</ul>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invite and account flow</h2>
<ol className="how-steps">
<li>
<strong>Invite created</strong> by admin or eligible user.
</li>
<li>
<strong>User signs up</strong> and Magent creates/links the account.
</li>
<li>
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
</li>
<li>
<strong>Admin trace map</strong> can show inviter invited lineage.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Admin controls available</h2>
<div className="how-grid">
<article className="how-card">
<h3>General</h3>
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
</article>
<article className="how-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
</article>
<article className="how-card">
<h3>Users</h3>
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
</article>
<article className="how-card">
<h3>Invite management</h3>
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
</article>
<article className="how-card">
<h3>Request sync + cache</h3>
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
</article>
<article className="how-card">
<h3>Maintenance + logs</h3>
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
</article>
</div>
</section>
<section className="how-callout">
<h2>Why Magent sometimes says &quot;waiting&quot;</h2>
<h2>Why a request can still wait</h2>
<p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
That does not mean it is broken. It usually means the release is not available yet.
If indexers do not return a valid release yet, Magent will show waiting/search states.
That usually means content availability is the blocker, not a broken pipeline.
</p>
</section>
</main>

View File

@@ -7,10 +7,11 @@ type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
rail?: ReactNode
children: ReactNode
}
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-shell-nav">
@@ -26,6 +27,16 @@ export default function AdminShell({ title, subtitle, actions, children }: Admin
</div>
{children}
</main>
<aside className="admin-shell-rail">
{rail ?? (
<div className="admin-rail-card admin-rail-card--placeholder">
<span className="admin-rail-eyebrow">Insights</span>
<h2>Stats rail</h2>
<p>Use this column for counters, live status, and quick metrics for this page.</p>
<span className="small-pill">{title}</span>
</div>
)}
</aside>
</div>
)
}

View File

@@ -6,7 +6,7 @@ const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/magent', label: 'Magent' },
{ href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
@@ -26,8 +26,8 @@ const NAV_GROUPS = [
{
title: 'Admin',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'System guide' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },

View File

@@ -250,112 +250,152 @@ export default function UsersPage() {
filteredUsers.length === users.length
? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users`
const usersRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card users-rail-summary">
<div className="user-directory-panel-header">
<div>
<h2>Directory summary</h2>
<p className="lede">A quick view of user access and account state.</p>
</div>
</div>
<div className="users-summary-grid">
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Total users</span>
<strong className="users-summary-value">{users.length}</strong>
</div>
<p className="users-summary-meta">{adminCount} admin accounts</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Auto search</span>
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
</div>
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Blocked</span>
<strong className="users-summary-value">{blockedCount}</strong>
</div>
<p className="users-summary-meta">
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Expired</span>
<strong className="users-summary-value">{expiredCount}</strong>
</div>
<p className="users-summary-meta">
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
</p>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Users"
subtitle="Directory, access status, and request activity."
actions={
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/admin/invites')}>
Invite management
</button>
<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>
</div>
}
rail={usersRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-summary-grid user-summary-grid">
<div className="admin-summary-tile">
<span className="label">Total users</span>
<strong>{users.length}</strong>
<small>{adminCount} admin</small>
</div>
<div className="admin-summary-tile">
<span className="label">Auto search</span>
<strong>{autoSearchEnabledCount}</strong>
<small>of {nonAdminUsers.length} non-admin users</small>
</div>
<div className="admin-summary-tile">
<span className="label">Blocked</span>
<strong>{blockedCount}</strong>
<small>{blockedCount ? 'Needs review' : 'No blocked users'}</small>
</div>
<div className="admin-summary-tile">
<span className="label">Expired</span>
<strong>{expiredCount}</strong>
<small>{expiredCount ? 'Access expired' : 'No expiries'}</small>
</div>
</div>
<div className="user-directory-control-grid">
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Auto search/download can be enabled or disabled for all non-admin users.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<div className="admin-panel users-page-toolbar">
<div className="users-page-toolbar-grid">
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
onClick={() => router.push('/admin/invites')}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
Invite management
</button>
<button type="button" onClick={loadUsers}>
Reload list
</button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Jellyseerr sync</span>
<div className="users-page-toolbar-actions">
<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>
</div>
</div>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Auto search/download can be enabled or disabled for all non-admin users.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
</button>
</div>
</div>
</div>
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
{filteredUsers.length === 0 ? (

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0202261541",
"version": "2702261314",
"scripts": {
"dev": "next dev",
"build": "next build",