Add user feature permissions and unified account management
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user