495 lines
23 KiB
TypeScript
495 lines
23 KiB
TypeScript
'use client'
|
|
|
|
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'
|
|
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
|
|
|
|
type AdminUser = {
|
|
id: number
|
|
username: string
|
|
email?: string | null
|
|
role: string
|
|
authProvider?: string | null
|
|
lastLoginAt?: string | null
|
|
isBlocked?: boolean
|
|
autoSearchEnabled?: boolean
|
|
inviteManagementEnabled?: boolean
|
|
profileId?: number | null
|
|
expiresAt?: string | null
|
|
isExpired?: boolean
|
|
stats?: UserStats
|
|
}
|
|
|
|
type UserStats = {
|
|
total: number
|
|
ready: number
|
|
pending: number
|
|
approved: number
|
|
working: number
|
|
partial: number
|
|
declined: number
|
|
in_progress: number
|
|
last_request_at?: string | null
|
|
}
|
|
|
|
const formatLastLogin = (value?: string | null) => {
|
|
if (!value) return 'Never'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const formatLastRequest = (value?: string | null) => {
|
|
if (!value) return '—'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const formatExpiry = (value?: string | null) => {
|
|
if (!value) return 'Never'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const emptyStats: UserStats = {
|
|
total: 0,
|
|
ready: 0,
|
|
pending: 0,
|
|
approved: 0,
|
|
working: 0,
|
|
partial: 0,
|
|
declined: 0,
|
|
in_progress: 0,
|
|
last_request_at: null,
|
|
}
|
|
|
|
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 UsersPage() {
|
|
const router = useRouter()
|
|
const [view, setView] = useState('directory')
|
|
useEffect(() => {
|
|
const update = () => setView(new URLSearchParams(window.location.search).get('view') === 'identities' ? 'identities' : 'directory')
|
|
update()
|
|
window.addEventListener('popstate', update)
|
|
return () => window.removeEventListener('popstate', update)
|
|
}, [])
|
|
const changeView = (next: string) => {
|
|
setView(next)
|
|
window.history.pushState(null, '', next === 'identities' ? '/users?view=identities' : '/users')
|
|
}
|
|
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)
|
|
const [query, setQuery] = useState('')
|
|
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
|
|
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)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/admin/users/summary`)
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (response.status === 403) {
|
|
router.push('/')
|
|
return
|
|
}
|
|
throw new Error('Could not load users.')
|
|
}
|
|
const data = await response.json()
|
|
if (Array.isArray(data?.users)) {
|
|
setUsers(
|
|
data.users.map((user: any) => ({
|
|
username: user.username ?? 'Unknown',
|
|
email: user.email ?? null,
|
|
role: user.role ?? 'user',
|
|
authProvider: user.auth_provider ?? 'local',
|
|
lastLoginAt: user.last_login_at ?? null,
|
|
isBlocked: Boolean(user.is_blocked),
|
|
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
|
|
inviteManagementEnabled: Boolean(user.invite_management_enabled),
|
|
profileId:
|
|
user.profile_id == null || Number.isNaN(Number(user.profile_id))
|
|
? null
|
|
: Number(user.profile_id),
|
|
expiresAt: user.expires_at ?? null,
|
|
isExpired: Boolean(user.is_expired),
|
|
id: Number(user.id ?? 0),
|
|
stats: normalizeStats(user.stats ?? emptyStats),
|
|
}))
|
|
)
|
|
} else {
|
|
setUsers([])
|
|
}
|
|
setError(null)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not load user list.')
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
|
|
const syncJellyseerrUsers = async () => {
|
|
setJellyseerrSyncStatus(null)
|
|
setJellyseerrSyncBusy(true)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
|
|
method: 'POST',
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Sync failed')
|
|
}
|
|
const data = await response.json()
|
|
setJellyseerrSyncStatus(
|
|
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
|
|
)
|
|
await loadUsers()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setJellyseerrSyncStatus('Could not sync Seerr users.')
|
|
} finally {
|
|
setJellyseerrSyncBusy(false)
|
|
}
|
|
}
|
|
|
|
const resyncJellyseerrUsers = async () => {
|
|
const confirmed = window.confirm(
|
|
'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)
|
|
setJellyseerrResyncBusy(true)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
|
method: 'POST',
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Resync failed')
|
|
}
|
|
const data = await response.json()
|
|
setJellyseerrSyncStatus(
|
|
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
|
|
)
|
|
await loadUsers()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setJellyseerrSyncStatus('Could not resync Seerr users.')
|
|
} finally {
|
|
setJellyseerrResyncBusy(false)
|
|
}
|
|
}
|
|
|
|
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)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled }),
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Bulk update failed')
|
|
}
|
|
const data = await response.json()
|
|
setJellyseerrSyncStatus(
|
|
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
|
|
)
|
|
await loadUsers()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Could not update auto search/download for all users.')
|
|
} finally {
|
|
setBulkAutoSearchBusy(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
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>
|
|
}
|
|
|
|
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
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
const filteredUsers = normalizedQuery
|
|
? users.filter((user) => {
|
|
const fields = [
|
|
user.username,
|
|
user.email || '',
|
|
user.role,
|
|
user.authProvider || '',
|
|
user.profileId != null ? String(user.profileId) : '',
|
|
]
|
|
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
|
|
})
|
|
: users
|
|
const filteredCountLabel =
|
|
filteredUsers.length === users.length
|
|
? `${users.length} users`
|
|
: `${filteredUsers.length} of ${users.length} users`
|
|
const usersRail = (
|
|
<div className="admin-rail-stack">
|
|
<div className="admin-rail-card users-rail-summary">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Directory summary</h2>
|
|
<p className="lede">A quick view of user access and account state.</p>
|
|
</div>
|
|
</div>
|
|
<div className="users-summary-grid">
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Total users</span>
|
|
<strong className="users-summary-value">{users.length}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">{adminCount} admin accounts</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Auto search</span>
|
|
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Blocked</span>
|
|
<strong className="users-summary-value">{blockedCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">
|
|
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
|
|
</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Expired</span>
|
|
<strong className="users-summary-value">{expiredCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">
|
|
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<AdminShell
|
|
title="User management"
|
|
subtitle="Accounts, access, request activity and verified service links."
|
|
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>}
|
|
>
|
|
<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="/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>
|
|
<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>
|
|
</dialog>
|
|
<nav className="identity-selection" aria-label="User management sections">
|
|
<button type="button" className="ghost-button" aria-pressed={view === 'directory'} onClick={() => changeView('directory')}>User directory</button>
|
|
<button type="button" className="ghost-button" aria-pressed={view === 'identities'} onClick={() => changeView('identities')}>Account links & repairs</button>
|
|
</nav>
|
|
{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>
|
|
<h2>Directory search</h2>
|
|
<p className="lede">
|
|
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>
|
|
</div>
|
|
<div className="user-directory-toolbar">
|
|
<div className="user-directory-search">
|
|
<label>
|
|
<span className="user-bulk-label">Search users</span>
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search username, email, role, login provider or profile ID…"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{filteredUsers.length === 0 ? (
|
|
<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">
|
|
<span>User</span>
|
|
<span>Access</span>
|
|
<span>Requests</span>
|
|
<span>Activity</span>
|
|
</div>
|
|
{filteredUsers.map((user) => (
|
|
<Link
|
|
key={user.username}
|
|
className="user-directory-row"
|
|
href={`/users/${user.id}`}
|
|
>
|
|
<div className="user-directory-cell user-directory-cell--identity">
|
|
<div className="user-directory-title-row">
|
|
<strong>{user.username}</strong>
|
|
<span className="user-grid-meta">{user.role}</span>
|
|
</div>
|
|
<div className="user-directory-subtext">
|
|
{user.email || 'No email on file'}
|
|
</div>
|
|
<div className="user-directory-subtext">
|
|
Login: {user.authProvider || 'local'} • Profile: {user.profileId ?? 'None'}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-pill-row">
|
|
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
|
|
{user.isBlocked ? 'Blocked' : 'Active'}
|
|
</span>
|
|
<span
|
|
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
|
|
>
|
|
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
|
|
</span>
|
|
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
|
|
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
|
|
</span>
|
|
</div>
|
|
<div className="user-directory-subtext">
|
|
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-stats-inline">
|
|
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
|
|
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
|
|
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
|
|
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-subtext">
|
|
Last login: {formatLastLogin(user.lastLoginAt)}
|
|
</div>
|
|
<div className="user-directory-subtext">
|
|
Last request: {formatLastRequest(user.stats?.last_request_at)}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-row-chevron" aria-hidden="true">
|
|
Open
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>}
|
|
</AdminShell>
|
|
)
|
|
}
|