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
+1 -8
View File
@@ -56,7 +56,6 @@ const BOOL_SETTINGS = new Set([
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'site_nav_show_requests',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
@@ -280,12 +279,6 @@ const SITE_SECTION_GROUPS: Array<{
'site_login_show_signup_link',
],
},
{
key: 'site-navigation',
title: 'Navigation',
description: 'Control new requests in the navigation.',
keys: ['site_nav_show_requests'],
},
]
const STANDARD_SECTION_GROUPS: Record<
@@ -839,7 +832,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog', 'site_build_number'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const hiddenSettingKeys = new Set(['site_nav_show_requests', ...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
+2 -1
View File
@@ -6,6 +6,7 @@ import './workspace.css'
import './portal/issue-flow.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
import FeatureGate from './ui/FeatureGate'
import ApplicationChrome from './ui/ApplicationChrome'
export const metadata = {
@@ -20,7 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<BrandingFavicon />
<div className="page">
<ApplicationChrome />
{children}
<FeatureGate>{children}</FeatureGate>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
export const FEATURES = [
{ key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' },
{ key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' },
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
] as const
export type Feature = typeof FEATURES[number]['key']
export type FeatureAccess = Record<Feature, boolean>
export function featureForPath(path: string): Feature | undefined {
if (path === '/insights' || path.startsWith('/insights/')) return 'stats'
if (path === '/' || path.startsWith('/requests/')) return 'requests'
if (path === '/new-requests') return 'new_requests'
if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues'
if (path.startsWith('/profile/invites')) return 'invites'
if (path === '/portal/requests') return 'requests'
}
export function canAccess(user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null, feature?: Feature) {
if (!feature) return true
if (!user) return false
if (user.role === 'admin') return true
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : true)
}
+1 -1
View File
@@ -618,7 +618,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const loadOverview = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/overview`)
const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
+3 -1
View File
@@ -1,5 +1,6 @@
'use client'
import { canAccess, type FeatureAccess } from '../lib/features'
import PageHeading from '../ui/PageHeading'
import MonthlyRecapPreference from './MonthlyRecapPreference'
import NewsletterPreference from './NewsletterPreference'
@@ -9,6 +10,7 @@ import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
features?: FeatureAccess
username: string
email?: string | null
role: string
@@ -199,7 +201,7 @@ export default function ProfilePage() {
</div>
</form>
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
<MonthlyRecapPreference key={user.email || 'no-email'} />
{canAccess(user, 'stats') && <MonthlyRecapPreference key={user.email || 'no-email'} />}
<NewsletterPreference key={`newsletter-${user.email || 'no-email'}`} />
</section>
+37
View File
@@ -0,0 +1,37 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState, type ReactNode } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import { canAccess, featureForPath, type FeatureAccess } from '../lib/features'
export function useFeatureUser() {
const pathname = usePathname()
const [state, setState] = useState<{ path: string; user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null }>({ path: '', user: null })
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) { if (active) setState({ path: pathname, user: null }); return }
try {
const response = await authFetch(`${getApiBase()}/auth/me`)
const user = response.ok ? await response.json() : null
if (active) setState({ path: pathname, user })
} catch { if (active) setState({ path: pathname, user: null }) }
}
void load()
window.addEventListener('focus', load)
return () => { active = false; window.removeEventListener('focus', load) }
}, [pathname])
return { user: state.user, ready: state.path === pathname }
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname()
const { user, ready } = useFeatureUser()
const feature = featureForPath(pathname)
if (!feature) return children
if (!ready) return <main className="card">Loading account access...</main>
if (!getToken()) return children
if (!canAccess(user, feature)) return <main className="card"><h1>Feature unavailable</h1><p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p><a href="/profile">Go to my profile</a></main>
return children
}
+7 -46
View File
@@ -1,54 +1,15 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const [showRequestsNav, setShowRequestsNav] = useState(true)
const pathname = usePathname()
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
setShowRequestsNav(true)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const [response, siteResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
fetch(`${baseUrl}/site/public`).catch(() => null),
])
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
if (siteResponse?.ok) {
const siteData = await siteResponse.json()
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
} else {
setShowRequestsNav(true)
}
} catch (err) {
console.error(err)
setShowRequestsNav(true)
}
}
void load()
}, [])
if (!signedIn) {
return null
}
const { user, ready } = useFeatureUser()
const role = user?.role ?? null
const showRequestsNav = canAccess(user, 'new_requests')
if (!ready || !user) return null
const roleItems =
role === null
@@ -104,7 +65,7 @@ export default function HeaderActions() {
const items = [
...commonItems,
...roleItems,
]
].filter((item) => canAccess(user, featureForPath(item.href)))
return (
<nav className="header-actions" aria-label="Primary">
+6 -26
View File
@@ -1,8 +1,9 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import { getToken } from '../lib/auth'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
type NavigationItem = {
href: string
@@ -38,36 +39,16 @@ function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
export default function WorkspaceNavigation() {
const pathname = usePathname()
const [role, setRole] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [showRequestsNav, setShowRequestsNav] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
setReady(true)
return
}
Promise.all([
authFetch(`${getApiBase()}/auth/me`),
fetch(`${getApiBase()}/site/public`).catch(() => null),
])
.then(async ([response, siteResponse]) => {
if (response.ok) setRole((await response.json())?.role ?? 'user')
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
})
.catch(() => undefined)
.finally(() => setReady(true))
}, [])
const { user, ready } = useFeatureUser()
const role = user?.role
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null
}
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
return (
<>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
@@ -75,6 +56,5 @@ export default function WorkspaceNavigation() {
</a>
))}
</nav>
</>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { FEATURES, type Feature, type FeatureAccess } from '../lib/features'
type Account = { username: string; role: string; features: FeatureAccess }
export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [changes, setChanges] = useState<Partial<FeatureAccess>>({})
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const load = async () => {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : 'summary'}`)
if (!response.ok) throw new Error('Could not load feature permissions.')
const data = await response.json()
setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== 'admin'))
}
useEffect(() => { void load().catch((err) => setError(err.message)) }, [username])
const save = async () => {
setBusy(true); setError(''); setMessage('')
try {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : 'features/bulk'}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes),
})
if (!response.ok) throw new Error((await response.json()).detail || 'Could not save permissions.')
const result = await response.json()
setChanges({})
setMessage(username ? 'Feature access saved.' : `Feature access saved for ${result.updated} non-admin accounts.`)
await load(); onSaved()
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save permissions.') }
finally { setBusy(false) }
}
const admin = accounts?.some((account) => account.role === 'admin')
return <section className="user-management-panel feature-controls">
<h3>Feature access</h3>
<p>{username ? 'Choose which features this person can use in Magent.' : 'Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied.'}</p>
<p>{admin ? 'Administrators always have access to all features.' : 'Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions.'}</p>
{!accounts && !error && <p>Loading permissions...</p>}
{FEATURES.map(({ key, label, description }) => {
const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0
const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length
const changed = Object.hasOwn(changes, key)
return <label key={key} className="feature-access-row">
<input type="checkbox" ref={(input) => { if (input) input.indeterminate = !changed && mixed }}
checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
disabled={busy || !accounts?.length || admin}
onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))} />
<span><strong>{label}</strong><small>{description}</small>{!username && <small>{enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? ' · Mixed access' : ''}{changed ? ` · Will ${changes[key] ? 'enable' : 'disable'} for everyone` : ''}</small>}</span>
</label>
})}
{error && <p className="error-banner" role="alert">{error}</p>}
{message && <p className="status-banner" role="status">{message}</p>}
<div className="admin-inline-actions"><button type="button" disabled={busy || !Object.keys(changes).length} onClick={() => void save()}>{busy ? 'Saving...' : username ? 'Save feature access' : 'Apply changed features to all users'}</button><button type="button" className="ghost-button" disabled={busy || !Object.keys(changes).length} onClick={() => setChanges({})}>Reset changes</button></div>
</section>
}
+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>
+3 -26
View File
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import './users.css'
import FeatureControls from './FeatureControls'
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
type AdminUser = {
@@ -107,8 +108,6 @@ export default function UsersPage() {
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const [bulkInvitesBusy, setBulkInvitesBusy] = useState(false)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const loadUsers = async () => {
setRefreshing(true)
@@ -216,25 +215,6 @@ export default function UsersPage() {
}
}
const enableInvitesForEveryone = async () => {
if (bulkInvitesBusy || !window.confirm('Enable invite access for all existing non-admin users, including users outside the current search? Existing invite limits, account blocks and expiry dates will not change.')) return
setBulkInvitesBusy(true)
setInviteStatus(null)
try {
const response = await authFetch(`${getApiBase()}/admin/users/invite-access/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
})
if (!response.ok) throw new Error('Invite access update failed')
const data = await response.json()
setInviteStatus(`Invite access enabled for ${data.updated ?? 0} non-admin accounts. Existing limits are unchanged.`)
await loadUsers()
} catch {
setInviteStatus('Could not enable invites. Please reload the list to check the current permissions, then try again.')
} finally { setBulkInvitesBusy(false) }
}
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
@@ -284,7 +264,7 @@ export default function UsersPage() {
}
}, [controlsOpen])
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy || bulkInvitesBusy
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy
if (loading) {
return <main className="card">Loading users...</main>
@@ -292,7 +272,6 @@ export default function UsersPage() {
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const inviteEnabledCount = nonAdminUsers.filter((user) => user.inviteManagementEnabled).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
@@ -371,7 +350,6 @@ export default function UsersPage() {
<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="/users?view=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>
@@ -383,7 +361,7 @@ export default function UsersPage() {
<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 &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>
<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>
<FeatureControls onSaved={() => void loadUsers()} />
</div>
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
</div>
@@ -395,7 +373,6 @@ export default function UsersPage() {
{view === 'identities' ? <IdentityReviewPanel /> : <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>
+19
View File
@@ -31,3 +31,22 @@
.user-management-panel { padding: 18px; }
.user-management-heading h2 { font-size: 22px; }
}
/* Individual profiles use the directory's modal management pattern. */
.user-detail-page-grid.user-detail-centered { display: block; width: min(100%, 1100px); margin-inline: auto; }
.user-detail-centered .user-detail-main-column { display: flex; flex-direction: column; gap: 24px; }
.user-detail-centered .user-detail-main-column > :nth-child(2) { order: -1; }
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.feature-controls { margin-bottom: 20px; }
.feature-access-row { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--border, #34343c); cursor: pointer; }
.feature-access-row input { flex: 0 0 auto; margin-top: 4px; width: 18px; height: 18px; accent-color: #c4b5fd; }
.feature-access-row span { display: grid; gap: 5px; }
.feature-access-row small { color: var(--text-muted, #a9a9ba); line-height: 1.5; }
.feature-controls .admin-inline-actions { margin-top: 20px; }
.user-management-panel.user-management-danger { margin-top: 24px; border: 1px solid #a84049; background: #321b2080; }
.user-management-danger h3 { color: #ff9ca6; }
.user-management-danger button { border-color: #a84049; color: #ffb6bd; background: #441e27; }
.user-management-danger p { margin-block: 16px; line-height: 1.6; }
@media (max-width: 640px) {
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}