692 lines
24 KiB
TypeScript
692 lines
24 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
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
|
|
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 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 [savingProfile, setSavingProfile] = useState(false)
|
|
const [savingExpiry, setSavingExpiry] = 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))
|
|
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 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 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
|
|
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(
|
|
`Remove ${user.username} from Magent and external systems? This is destructive.`
|
|
)
|
|
if (!confirmed) return
|
|
}
|
|
if (action === 'ban') {
|
|
const confirmed = window.confirm(
|
|
`Ban ${user.username} across systems and disable invites they created?`
|
|
)
|
|
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>
|
|
}
|
|
>
|
|
<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">
|
|
<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">Jellyseerr ID</span>
|
|
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</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>
|
|
|
|
<div className="user-detail-side-column">
|
|
<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>
|
|
<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.
|
|
</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>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|