Center the user directory and group management tools in a dialog
This commit is contained in:
+54
-94
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import './users.css'
|
||||
|
||||
type AdminUser = {
|
||||
id: number
|
||||
@@ -81,6 +82,11 @@ const normalizeStats = (stats: any): UserStats => ({
|
||||
|
||||
export default function UsersPage() {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -93,6 +99,7 @@ export default function UsersPage() {
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||
|
||||
const loadUsers = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/users/summary`)
|
||||
@@ -139,6 +146,7 @@ export default function UsersPage() {
|
||||
setError('Could not load user list.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +177,7 @@ export default function UsersPage() {
|
||||
|
||||
const resyncJellyseerrUsers = async () => {
|
||||
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
|
||||
setJellyseerrSyncStatus(null)
|
||||
@@ -250,6 +258,22 @@ export default function UsersPage() {
|
||||
void loadUsers()
|
||||
}, [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) {
|
||||
return <main className="card">Loading users...</main>
|
||||
}
|
||||
@@ -328,104 +352,40 @@ export default function UsersPage() {
|
||||
<AdminShell
|
||||
title="Users"
|
||||
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">
|
||||
<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={() => router.push('/admin/identities')}>
|
||||
Confirm user IDs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => router.push('/admin/invites')}
|
||||
>
|
||||
Invite management
|
||||
</button>
|
||||
<button type="button" onClick={loadUsers}>
|
||||
Reload list
|
||||
</button>
|
||||
<dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
|
||||
<div className="user-management-content">
|
||||
<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>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
|
||||
{inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||
<div className="user-management-grid">
|
||||
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
<section className="user-management-panel"><h3>Automatic search & 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>
|
||||
<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>
|
||||
</div>
|
||||
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
|
||||
</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>
|
||||
{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">
|
||||
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>
|
||||
</dialog>
|
||||
<section className="admin-section users-directory-centered">
|
||||
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
|
||||
{!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||
<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.
|
||||
Find an account by username, email, role, login provider or profile ID. Select a user to manage their access.
|
||||
</p>
|
||||
</div>
|
||||
<span className="small-pill">{filteredCountLabel}</span>
|
||||
@@ -437,14 +397,14 @@ export default function UsersPage() {
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search username, login type, role, profile…"
|
||||
placeholder="Search username, email, role, login provider or profile ID…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{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-header">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user