785 lines
30 KiB
TypeScript
785 lines
30 KiB
TypeScript
'use client'
|
|
|
|
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 = {
|
|
total: number
|
|
ready: number
|
|
pending: number
|
|
approved: number
|
|
working: number
|
|
partial: number
|
|
declined: number
|
|
in_progress: number
|
|
last_request_at?: string | null
|
|
}
|
|
|
|
type AdminUser = {
|
|
id?: number
|
|
username: string
|
|
email?: string | null
|
|
role: string
|
|
auth_provider?: string | null
|
|
last_login_at?: string | null
|
|
is_blocked?: boolean
|
|
auto_search_enabled?: boolean
|
|
invite_management_enabled?: boolean
|
|
jellyseerr_user_id?: number | null
|
|
profile_id?: number | null
|
|
expires_at?: string | null
|
|
is_expired?: boolean
|
|
invited_by_code?: string | null
|
|
invited_at?: string | null
|
|
}
|
|
|
|
type UserLineage = {
|
|
invite_code?: string | null
|
|
invited_by?: string | null
|
|
invite?: {
|
|
id?: number
|
|
code?: string
|
|
label?: string | null
|
|
created_by?: string | null
|
|
created_at?: string | null
|
|
enabled?: boolean
|
|
is_usable?: boolean
|
|
} | null
|
|
} | null
|
|
|
|
type UserProfileOption = {
|
|
id: number
|
|
name: string
|
|
is_active?: boolean
|
|
}
|
|
|
|
const formatDateTime = (value?: string | null) => {
|
|
if (!value) return 'Never'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const toLocalDateTimeInput = (value?: string | null) => {
|
|
if (!value) return ''
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return ''
|
|
const offsetMs = date.getTimezoneOffset() * 60_000
|
|
const local = new Date(date.getTime() - offsetMs)
|
|
return local.toISOString().slice(0, 16)
|
|
}
|
|
|
|
const fromLocalDateTimeInput = (value: string) => {
|
|
if (!value.trim()) return null
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return null
|
|
return date.toISOString()
|
|
}
|
|
|
|
const normalizeStats = (stats: any): UserStats => ({
|
|
total: Number(stats?.total ?? 0),
|
|
ready: Number(stats?.ready ?? 0),
|
|
pending: Number(stats?.pending ?? 0),
|
|
approved: Number(stats?.approved ?? 0),
|
|
working: Number(stats?.working ?? 0),
|
|
partial: Number(stats?.partial ?? 0),
|
|
declined: Number(stats?.declined ?? 0),
|
|
in_progress: Number(stats?.in_progress ?? 0),
|
|
last_request_at: stats?.last_request_at ?? null,
|
|
})
|
|
|
|
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
|
|
const [user, setUser] = useState<AdminUser | null>(null)
|
|
const [stats, setStats] = useState<UserStats | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
|
const [profileSelection, setProfileSelection] = useState('')
|
|
const [expiryInput, setExpiryInput] = useState('')
|
|
const [emailInput, setEmailInput] = useState('')
|
|
const [savingProfile, setSavingProfile] = useState(false)
|
|
const [savingExpiry, setSavingExpiry] = useState(false)
|
|
const [savingEmail, setSavingEmail] = useState(false)
|
|
const [systemActionBusy, setSystemActionBusy] = useState(false)
|
|
const [actionStatus, setActionStatus] = useState<string | null>(null)
|
|
const [lineage, setLineage] = useState<UserLineage>(null)
|
|
|
|
const loadProfiles = async () => {
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/admin/profiles`)
|
|
if (!response.ok) {
|
|
return
|
|
}
|
|
const data = await response.json()
|
|
if (!Array.isArray(data?.profiles)) {
|
|
setProfiles([])
|
|
return
|
|
}
|
|
setProfiles(
|
|
data.profiles.map((profile: any) => ({
|
|
id: Number(profile.id ?? 0),
|
|
name: String(profile.name ?? 'Unnamed profile'),
|
|
is_active: Boolean(profile.is_active ?? true),
|
|
}))
|
|
)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const loadUser = async () => {
|
|
if (!idParam) return
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
|
|
)
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (response.status === 403) {
|
|
router.push('/')
|
|
return
|
|
}
|
|
if (response.status === 404) {
|
|
setError('User not found.')
|
|
return
|
|
}
|
|
throw new Error('Could not load user.')
|
|
}
|
|
const data = await response.json()
|
|
const nextUser = data?.user ?? null
|
|
setUser(nextUser)
|
|
setStats(normalizeStats(data?.stats))
|
|
setLineage((data?.lineage ?? null) as UserLineage)
|
|
setProfileSelection(
|
|
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
|
|
? ''
|
|
: String(nextUser.profile_id)
|
|
)
|
|
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
|
|
setEmailInput(nextUser?.email ?? '')
|
|
setError(null)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not load user.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const toggleUserBlock = async (blocked: boolean) => {
|
|
if (!user) return
|
|
try {
|
|
setActionStatus(null)
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
|
|
{ method: 'POST' }
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error('Update failed')
|
|
}
|
|
await loadUser()
|
|
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user access.')
|
|
}
|
|
}
|
|
|
|
const updateUserRole = async (role: string) => {
|
|
if (!user) return
|
|
try {
|
|
setActionStatus(null)
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error('Update failed')
|
|
}
|
|
await loadUser()
|
|
setActionStatus(`Role updated to ${role}.`)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user role.')
|
|
}
|
|
}
|
|
|
|
const saveUserEmail = async (clear = false) => {
|
|
if (!user) return
|
|
const email = clear ? '' : emailInput.trim()
|
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
setError('Enter a valid email address.')
|
|
setActionStatus(null)
|
|
return
|
|
}
|
|
setSavingEmail(true)
|
|
setError(null)
|
|
setActionStatus(null)
|
|
try {
|
|
const response = await authFetch(
|
|
`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email || null }),
|
|
}
|
|
)
|
|
const text = await response.text()
|
|
let data: any = null
|
|
try {
|
|
data = text ? JSON.parse(text) : null
|
|
} catch {
|
|
data = null
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data?.detail || text || 'Email update failed')
|
|
}
|
|
setEmailInput(data?.user?.email ?? '')
|
|
await loadUser()
|
|
setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError(err instanceof Error ? err.message : 'Could not update the contact email.')
|
|
} finally {
|
|
setSavingEmail(false)
|
|
}
|
|
}
|
|
|
|
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
|
if (!user) return
|
|
try {
|
|
setActionStatus(null)
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error('Update failed')
|
|
}
|
|
await loadUser()
|
|
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update auto search access.')
|
|
}
|
|
}
|
|
|
|
const applyProfileToUser = async (profileOverride?: string | null) => {
|
|
if (!user) return
|
|
const profileValue = profileOverride ?? profileSelection
|
|
setSavingProfile(true)
|
|
setError(null)
|
|
setActionStatus(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profile_id: profileValue || null }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Profile update failed')
|
|
}
|
|
await loadUser()
|
|
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user profile.')
|
|
} finally {
|
|
setSavingProfile(false)
|
|
}
|
|
}
|
|
|
|
const saveUserExpiry = async () => {
|
|
if (!user) return
|
|
const expiresAt = fromLocalDateTimeInput(expiryInput)
|
|
if (expiryInput.trim() && !expiresAt) {
|
|
setError('Invalid expiry date/time.')
|
|
return
|
|
}
|
|
setSavingExpiry(true)
|
|
setError(null)
|
|
setActionStatus(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ expires_at: expiresAt }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Expiry update failed')
|
|
}
|
|
await loadUser()
|
|
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update user expiry.')
|
|
} finally {
|
|
setSavingExpiry(false)
|
|
}
|
|
}
|
|
|
|
const clearUserExpiry = async () => {
|
|
if (!user) return
|
|
setSavingExpiry(true)
|
|
setError(null)
|
|
setActionStatus(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ clear: true }),
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Expiry clear failed')
|
|
}
|
|
setExpiryInput('')
|
|
await loadUser()
|
|
setActionStatus('User expiry cleared.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not clear user expiry.')
|
|
} finally {
|
|
setSavingExpiry(false)
|
|
}
|
|
}
|
|
|
|
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
|
|
if (!user) return
|
|
if (action === 'remove') {
|
|
const confirmed = window.confirm(
|
|
`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(
|
|
`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
|
|
}
|
|
setSystemActionBusy(true)
|
|
setError(null)
|
|
setActionStatus(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(
|
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action }),
|
|
}
|
|
)
|
|
const text = await response.text()
|
|
let data: any = null
|
|
try {
|
|
data = text ? JSON.parse(text) : null
|
|
} catch {
|
|
data = null
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data?.detail || text || 'Cross-system action failed')
|
|
}
|
|
const state = data?.status === 'partial' ? 'partial' : 'complete'
|
|
if (action === 'remove') {
|
|
setActionStatus(`User removed (${state}).`)
|
|
router.push('/users')
|
|
return
|
|
}
|
|
await loadUser()
|
|
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
|
|
} finally {
|
|
setSystemActionBusy(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
void loadUser()
|
|
void loadProfiles()
|
|
}, [router, idParam])
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading user...</main>
|
|
}
|
|
|
|
return (
|
|
<AdminShell
|
|
title={user?.username || 'User'}
|
|
subtitle="User overview and request stats."
|
|
actions={
|
|
<><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">
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{actionStatus && <div className="status-banner">{actionStatus}</div>}
|
|
{!user ? (
|
|
<div className="status-banner">No user data found.</div>
|
|
) : (
|
|
<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">
|
|
<div className="user-detail-title-row">
|
|
<strong className="user-detail-name">{user.username}</strong>
|
|
<span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
|
|
{user.is_blocked ? 'Blocked' : 'Active'}
|
|
</span>
|
|
<span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
|
|
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
|
|
</span>
|
|
</div>
|
|
<p className="lede">
|
|
User identity, access state, and request history for this account.
|
|
</p>
|
|
</div>
|
|
<div className="user-detail-meta-grid">
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Email</span>
|
|
<strong>{user.email || 'Not set'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Seerr ID</span>
|
|
<strong>{user.jellyseerr_user_id ?? 'Not linked'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Role</span>
|
|
<strong>{user.role}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Login type</span>
|
|
<strong>{user.auth_provider || 'local'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Assigned profile</span>
|
|
<strong>{user.profile_id ?? 'None'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Invited by</span>
|
|
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Invite code used</span>
|
|
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Last login</span>
|
|
<strong>{formatDateTime(user.last_login_at)}</strong>
|
|
</div>
|
|
<div className="user-detail-meta-item">
|
|
<span className="label">Account expiry</span>
|
|
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Request statistics</h2>
|
|
<p className="lede">Snapshot of request states and recent activity for this user.</p>
|
|
</div>
|
|
<div className="user-detail-grid">
|
|
<div className="user-detail-stat">
|
|
<span className="label">Total</span>
|
|
<span className="value">{stats?.total ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Ready</span>
|
|
<span className="value">{stats?.ready ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Pending</span>
|
|
<span className="value">{stats?.pending ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Approved</span>
|
|
<span className="value">{stats?.approved ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Working</span>
|
|
<span className="value">{stats?.working ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Partial</span>
|
|
<span className="value">{stats?.partial ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">Declined</span>
|
|
<span className="value">{stats?.declined ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat">
|
|
<span className="label">In progress</span>
|
|
<span className="value">{stats?.in_progress ?? 0}</span>
|
|
</div>
|
|
<div className="user-detail-stat user-detail-stat--wide">
|
|
<span className="label">Last request</span>
|
|
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>}
|
|
<p><a className="ghost-button" href={`/users?view=identities&user=${encodeURIComponent(user.username)}`}>Review service links & duplicate accounts</a></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>
|
|
<p className="lede">Used by Magent for account recovery and issue updates.</p>
|
|
</div>
|
|
<form
|
|
className="user-detail-actions user-detail-actions--stacked"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
void saveUserEmail()
|
|
}}
|
|
>
|
|
<label>
|
|
<span className="user-bulk-label">Email address</span>
|
|
<input
|
|
type="email"
|
|
value={emailInput}
|
|
onChange={(event) => setEmailInput(event.target.value)}
|
|
placeholder="person@example.com"
|
|
autoComplete="off"
|
|
disabled={savingEmail}
|
|
/>
|
|
</label>
|
|
<div className="user-detail-helper">
|
|
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
|
|
</div>
|
|
<div className="admin-inline-actions">
|
|
<button
|
|
type="submit"
|
|
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? '')}
|
|
>
|
|
{savingEmail ? 'Saving...' : user.email ? 'Save email' : 'Add email'}
|
|
</button>
|
|
{user.email && (
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void saveUserEmail(true)}
|
|
disabled={savingEmail}
|
|
>
|
|
Remove email
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Access controls</h2>
|
|
<p className="lede">Role, login access, and auto-download behavior.</p>
|
|
</div>
|
|
<div className="user-detail-control-stack">
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={user.role === 'admin'}
|
|
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
|
|
/>
|
|
<span>Make admin</span>
|
|
</label>
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(user.auto_search_enabled ?? true)}
|
|
disabled={user.role === 'admin'}
|
|
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
|
|
/>
|
|
<span>Allow auto search/download</span>
|
|
</label>
|
|
{user.role === 'admin' && (
|
|
<div className="user-detail-helper">
|
|
Admins always have automatic search/download and all features.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Profile defaults</h2>
|
|
<p className="lede">Assign or clear an invite profile for this user.</p>
|
|
</div>
|
|
<div className="user-detail-actions user-detail-actions--stacked">
|
|
<label className="admin-select">
|
|
<span>Assigned profile</span>
|
|
<select
|
|
value={profileSelection}
|
|
onChange={(event) => setProfileSelection(event.target.value)}
|
|
disabled={savingProfile}
|
|
>
|
|
<option value="">None</option>
|
|
{profiles.map((profile) => (
|
|
<option key={profile.id} value={profile.id}>
|
|
{profile.name}
|
|
{profile.is_active === false ? ' (disabled)' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
|
|
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => {
|
|
setProfileSelection('')
|
|
void applyProfileToUser('')
|
|
}}
|
|
disabled={savingProfile}
|
|
>
|
|
Clear profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-panel user-detail-panel">
|
|
<div className="user-detail-panel-header">
|
|
<h2>Account expiry</h2>
|
|
<p className="lede">Set a specific expiry date/time for this user account.</p>
|
|
</div>
|
|
<div className="user-detail-actions user-detail-actions--stacked">
|
|
<label>
|
|
<span className="user-bulk-label">Account expiry</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={expiryInput}
|
|
onChange={(event) => setExpiryInput(event.target.value)}
|
|
disabled={savingExpiry}
|
|
/>
|
|
</label>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
|
|
{savingExpiry ? 'Saving...' : 'Save expiry'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={clearUserExpiry}
|
|
disabled={savingExpiry}
|
|
>
|
|
Clear expiry
|
|
</button>
|
|
</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>
|
|
</AdminShell>
|
|
)
|
|
}
|