Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
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-11 12:31:25 +12:00
parent e2be8b3872
commit ec0a866ef3
32 changed files with 650 additions and 214 deletions
+70 -74
View File
@@ -1,8 +1,10 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import FeatureControls from '../FeatureControls'
import '../users.css'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
@@ -91,6 +93,22 @@ const normalizeStats = (stats: any): UserStats => ({
})
export default function UserDetailPage() {
const [manageOpen, setManageOpen] = useState(false)
const managementDialog = useRef<HTMLDialogElement>(null)
const manageTrigger = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!manageOpen) return
const dialog = managementDialog.current
if (!dialog) return
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.showModal()
return () => {
dialog.close()
document.body.style.overflow = overflow
manageTrigger.current?.focus()
}
}, [manageOpen])
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
@@ -286,30 +304,6 @@ export default function UserDetailPage() {
}
}
const updateInviteManagementEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
} catch (err) {
console.error(err)
setError('Could not update invite access.')
}
}
const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return
const profileValue = profileOverride ?? profileSelection
@@ -408,13 +402,13 @@ export default function UserDetailPage() {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
`Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
`Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`
)
if (!confirmed) return
}
@@ -475,9 +469,8 @@ export default function UserDetailPage() {
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
<><button type="button" onClick={() => router.push('/users')}>Back to users</button>
<button ref={manageTrigger} type="button" disabled={!user} aria-haspopup="dialog" onClick={() => setManageOpen(true)}>Manage this user</button></>
}
>
<section className="admin-section">
@@ -486,7 +479,7 @@ export default function UserDetailPage() {
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<div className="user-detail-page-grid">
<div className="user-detail-page-grid user-detail-centered">
<div className="user-detail-main-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
@@ -510,7 +503,7 @@ export default function UserDetailPage() {
</div>
<div className="user-detail-meta-item">
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
<strong>{user.jellyseerr_user_id ?? 'Not linked'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
@@ -589,7 +582,13 @@ export default function UserDetailPage() {
</div>
</div>
<div className="user-detail-side-column">
<dialog ref={managementDialog} className="user-management-dialog" aria-labelledby="manage-this-user-title" onCancel={() => setManageOpen(false)} onClose={() => setManageOpen(false)}>
<div className="user-management-content">
<header className="user-management-heading"><div><h2 id="manage-this-user-title">Manage {user.username}</h2><p>Feature access, account settings and account restrictions.</p></div><button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>Close</button></header>
{error && <p className="error-banner" role="alert">{error}</p>}
{actionStatus && <p className="status-banner" role="status">{actionStatus}</p>}
{manageOpen && <FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />}
<div className="user-management-grid">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Contact email</h2>
@@ -660,48 +659,9 @@ export default function UserDetailPage() {
/>
<span>Allow auto search/download</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.invite_management_enabled ?? false)}
disabled={user.role === 'admin'}
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
/>
<span>Allow self-service invites</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download and invite-management access.
Admins always have automatic search/download and all features.
</div>
)}
</div>
@@ -778,7 +738,43 @@ export default function UserDetailPage() {
</div>
</div>
</div>
</div>
</div>
<section className="user-management-panel user-management-danger"><h3>Restrict access or delete accounts</h3><p>Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.</p>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy || user.role === 'admin'}
>
{user.is_blocked ? 'Restore Magent access' : 'Block Magent access'}
</button>
<p>Disable access also disables invitations this user created and attempts an account notification email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.</p>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy || user.role === 'admin'}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Restore Magent and Jellyfin access'
: 'Disable Magent and Jellyfin access'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy || user.role === 'admin'}
>
Delete Magent, Jellyfin and Seerr accounts
</button>
</div>
<p>Deletion removes the Magent account and local login activity, attempts to delete the same-name Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone here. Media files and Jellystat history are not deleted. External actions can partially fail.</p>
</section>
</div>
</dialog>
</div>
)}
</section>