Center the user directory and group management tools in a dialog
Magent CI/CD / verify (push) Canceled after 9m23s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-10 13:36:37 +12:00
parent b0f8c89db7
commit 747a330b19
2 changed files with 87 additions and 94 deletions
+54 -94
View File
@@ -1,10 +1,11 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
import './users.css'
type AdminUser = { type AdminUser = {
id: number id: number
@@ -81,6 +82,11 @@ const normalizeStats = (stats: any): UserStats => ({
export default function UsersPage() { export default function UsersPage() {
const router = useRouter() const router = useRouter()
const [controlsOpen, setControlsOpen] = useState(false)
const controlsDialog = useRef<HTMLDialogElement>(null)
const controlsTrigger = useRef<HTMLButtonElement>(null)
const controlsClose = useRef<HTMLButtonElement>(null)
const [refreshing, setRefreshing] = useState(false)
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -93,6 +99,7 @@ export default function UsersPage() {
const [inviteStatus, setInviteStatus] = useState<string | null>(null) const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const loadUsers = async () => { const loadUsers = async () => {
setRefreshing(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/summary`) const response = await authFetch(`${baseUrl}/admin/users/summary`)
@@ -139,6 +146,7 @@ export default function UsersPage() {
setError('Could not load user list.') setError('Could not load user list.')
} finally { } finally {
setLoading(false) setLoading(false)
setRefreshing(false)
} }
} }
@@ -169,7 +177,7 @@ export default function UsersPage() {
const resyncJellyseerrUsers = async () => { const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm( const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Seerr. Continue?' 'Rebuild the Magent directory from Seerr? This deletes all existing non-admin Magent accounts and creates accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Continue?'
) )
if (!confirmed) return if (!confirmed) return
setJellyseerrSyncStatus(null) setJellyseerrSyncStatus(null)
@@ -250,6 +258,22 @@ export default function UsersPage() {
void loadUsers() void loadUsers()
}, [router]) }, [router])
useEffect(() => {
if (!controlsOpen) return
const dialog = controlsDialog.current
dialog?.showModal()
controlsClose.current?.focus()
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
dialog?.close()
document.body.style.overflow = previous
controlsTrigger.current?.focus()
}
}, [controlsOpen])
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy || bulkInvitesBusy
if (loading) { if (loading) {
return <main className="card">Loading users...</main> return <main className="card">Loading users...</main>
} }
@@ -328,104 +352,40 @@ export default function UsersPage() {
<AdminShell <AdminShell
title="Users" title="Users"
subtitle="Directory, access status, and request activity." subtitle="Directory, access status, and request activity."
rail={usersRail} actions={<button ref={controlsTrigger} type="button" className="ghost-button" aria-haspopup="dialog" aria-expanded={controlsOpen} aria-controls="user-management-dialog" onClick={() => setControlsOpen(true)}>Manage users</button>}
> >
<section className="admin-section"> <dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
<div className="admin-panel users-page-toolbar"> <div className="user-management-content">
<div className="users-page-toolbar-grid"> <header className="user-management-heading"><div><span className="users-page-toolbar-label">Directory tools</span><h2 id="user-management-title">Manage users</h2><p>Account links, service sync and permissions for your user directory.</p></div><button ref={controlsClose} type="button" className="ghost-button" onClick={() => setControlsOpen(false)}>Close</button></header>
<div className="users-page-toolbar-group"> {error && <p className="error-banner" role="alert">{error}</p>}
<span className="users-page-toolbar-label">Directory actions</span> {jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
<div className="users-page-toolbar-actions"> {inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
<button type="button" className="ghost-button" onClick={() => router.push('/admin/identities')}> <div className="user-management-grid">
Confirm user IDs <section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
</button> <div className="user-management-action"><Link className="ghost-button" href="/admin/identities" aria-describedby="identity-help">Review account links </Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
<button <div className="user-management-action"><Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">Manage invitations ↗</Link><p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p></div>
type="button" <div className="user-management-action"><button type="button" className="ghost-button" onClick={() => void loadUsers()} disabled={controlsBusy} aria-describedby="reload-help">{refreshing ? 'Refreshing' : 'Refresh user list'}</button><p id="reload-help">Reload account status and request totals from Magent. Your search stays in place.</p></div>
className="ghost-button" </section>
onClick={() => router.push('/admin/invites')} <section className="user-management-panel"><h3>Seerr sync</h3><p>Connect existing Magent accounts to their Seerr request accounts.</p>
> <div className="user-management-action"><button type="button" onClick={() => void syncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="sync-help">{jellyseerrSyncBusy ? 'Matching accounts' : 'Match unlinked Seerr accounts'}</button><p id="sync-help">Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and available email into Magent. Already-linked users are skipped.</p></div>
Invite management <details className="user-management-advanced"><summary>Advanced: rebuild from Seerr</summary><p id="resync-help">Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Rebuilding directory' : 'Rebuild directory from Seerr'}</button></details>
</button> </section>
<button type="button" onClick={loadUsers}> <section className="user-management-panel"><h3>Automatic search &amp; download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section>
Reload list <section className="user-management-panel"><h3>Invite access</h3><p>Let users create and manage invitations for other people to join.</p><span className="user-management-count">{inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="invite-access-help">Grants invite access to every existing non-admin account. Each user's invite limits still apply, and account blocks and expiry dates stay in effect. Administrators already have access.</p><button type="button" onClick={() => void enableInvitesForEveryone()} disabled={controlsBusy || !nonAdminUsers.length || inviteEnabledCount === nonAdminUsers.length} aria-describedby="invite-access-help">{bulkInvitesBusy ? 'Enabling invites' : inviteEnabledCount === nonAdminUsers.length && nonAdminUsers.length > 0 ? 'Invites enabled for everyone' : 'Enable invites for all non-admin users'}</button></section>
</button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Seerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
</button>
<button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
</button>
</div>
</div>
</div> </div>
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
</div> </div>
{error && <div className="error-banner">{error}</div>} </dialog>
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>} <section className="admin-section users-directory-centered">
<div className="admin-panel user-directory-bulk-panel"> {!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
<div className="user-directory-panel-header"> {!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
<div> {!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
<h2>Bulk controls</h2>
<p className="lede">
Manage permissions for all existing non-admin users, not just the current search results.
</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-bulk-panel">
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Invite access</strong>
<span>{inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span>
<span>Admins already have access. Existing invite limits, blocked accounts and expiry dates stay unchanged.</span>
</div>
<div className="user-bulk-actions">
<button type="button" onClick={enableInvitesForEveryone} disabled={bulkInvitesBusy || nonAdminUsers.length === 0 || inviteEnabledCount === nonAdminUsers.length}>
{bulkInvitesBusy ? 'Enabling invites...' : inviteEnabledCount === nonAdminUsers.length && nonAdminUsers.length > 0 ? 'Invites enabled for everyone' : 'Enable invites for all users'}
</button>
</div>
</div>
{inviteStatus && <p role="status">{inviteStatus}</p>}
</div>
<div className="admin-panel user-directory-search-panel"> <div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
<h2>Directory search</h2> <h2>Directory search</h2>
<p className="lede"> <p className="lede">
Filter by username, role, login provider, or assigned profile. Find an account by username, email, role, login provider or profile ID. Select a user to manage their access.
</p> </p>
</div> </div>
<span className="small-pill">{filteredCountLabel}</span> <span className="small-pill">{filteredCountLabel}</span>
@@ -437,14 +397,14 @@ export default function UsersPage() {
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…" placeholder="Search username, email, role, login provider or profile ID…"
/> />
</label> </label>
</div> </div>
</div> </div>
</div> </div>
{filteredUsers.length === 0 ? ( {filteredUsers.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner" role="status">{normalizedQuery ? 'No users match your search. Try another name or email.' : 'No users found yet. Open Manage users to review the directory tools.'}</div>
) : ( ) : (
<div className="user-directory-list"> <div className="user-directory-list">
<div className="user-directory-header"> <div className="user-directory-header">
+33
View File
@@ -0,0 +1,33 @@
.users-directory-centered { width: 100%; max-width: 1280px; margin: 0 auto; min-width: 0; }
.user-management-dialog { position: fixed; inset: 0; width: min(980px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); overflow: auto; padding: 0; margin: auto; border: 1px solid var(--ops-line); border-radius: 16px; background: var(--ops-panel, #1c1b1d); color: var(--ops-text, #eee8f2); box-shadow: 0 24px 90px #0009; }
.user-management-dialog::backdrop { background: #000a; backdrop-filter: blur(4px); }
.user-management-content { padding: 28px; }
.user-management-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; padding-bottom: 12px; background: var(--ops-panel, #1c1b1d); box-shadow: 0 -28px 0 var(--ops-panel, #1c1b1d); }
.user-management-heading h2 { margin: 8px 0; font-size: 26px; }
.user-management-heading > button { flex-shrink: 0; }
.user-management-dialog p { color: var(--ops-muted, #bdb6c3); font-size: 13px; line-height: 1.7; margin: 8px 0 16px; }
.user-management-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.user-management-panel { min-width: 0; padding: 22px; border: 1px solid var(--ops-line); border-radius: 12px; background: #ffffff02; }
.user-management-panel h3 { margin: 0 0 10px; font-size: 17px; }
.user-management-action + .user-management-action { border-top: 1px solid var(--ops-line); padding-top: 16px; }
.user-management-action p { margin-top: 10px; font-size: 12px; }
.user-management-action a { display: inline-flex; text-decoration: none; }
.user-management-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.user-management-dialog button, .user-management-dialog a.ghost-button { max-width: 100%; white-space: normal; line-height: 1.5; }
.user-management-count { display: block; font-size: 12px; color: #c7bdff; margin: 14px 0; }
.user-management-advanced { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--ops-line); }
.user-management-advanced summary, .user-management-summary > summary { cursor: pointer; font-size: 13px; color: var(--ops-text); padding: 8px 0; }
.user-management-advanced button { border-color: #d4a38f70; color: #edc7b8; }
.user-management-summary { border-top: 1px solid var(--ops-line); margin-top: 24px; padding-top: 12px; }
.user-management-summary .admin-rail-stack { margin-top: 14px; }
@media (min-width: 1000px) {
.users-directory-centered .user-directory-header, .users-directory-centered .user-directory-row { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, .9fr) minmax(0, 1.1fr) auto; }
.users-directory-centered .user-directory-row-chevron { justify-self: end; }
}
@media (max-width: 700px) {
.user-management-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }
.user-management-content { padding: 20px 16px; }
.user-management-grid { grid-template-columns: 1fr; }
.user-management-panel { padding: 18px; }
.user-management-heading h2 { font-size: 22px; }
}