admin docs and layout refresh, build 2702261314
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
211
frontend/app/admin/system/page.tsx
Normal file
211
frontend/app/admin/system/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "waiting"</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0202261541",
|
||||
"version": "2702261314",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
Reference in New Issue
Block a user