Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -153,7 +153,7 @@ export default function IdentityReviewPanel() {
|
||||
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
||||
</dl>
|
||||
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
||||
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
||||
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
||||
</article>)}
|
||||
|
||||
@@ -7602,4 +7602,4 @@ textarea {
|
||||
.request-language-notice h3, .request-language-notice p { margin: 0; }
|
||||
.request-language-notice p, .request-language-notice small { line-height: 1.6; }
|
||||
.request-language-notice label { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.request-language-notice input[type=checkbox] { flex: 0 0 20px; width: 20px; height: 20px; margin-top: 2px; }
|
||||
.request-language-notice input[type=checkbox], .request-language-notice input[type=radio] { flex: 0 0 20px; width: 20px; height: 20px; margin-top: 2px; }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
let locks = 0
|
||||
let previous = ''
|
||||
|
||||
export function lockBodyScroll() {
|
||||
if (locks++ === 0) {
|
||||
previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
if (--locks === 0) document.body.style.overflow = previous
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export default function NewRequestClient() {
|
||||
const [options, setOptions] = useState<RequestOptions | null>(null)
|
||||
const [loadingOptions, setLoadingOptions] = useState(false)
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(false)
|
||||
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -134,7 +134,7 @@ export default function NewRequestClient() {
|
||||
const changeTitle = () => {
|
||||
setSelected(null)
|
||||
setOptions(null)
|
||||
setAcceptOriginalLanguage(false)
|
||||
setAcceptOriginalLanguage(null)
|
||||
setSelectedSeasons([])
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
@@ -149,7 +149,7 @@ export default function NewRequestClient() {
|
||||
setSearchAttempted(false)
|
||||
setSelected(null)
|
||||
setOptions(null)
|
||||
setAcceptOriginalLanguage(false)
|
||||
setAcceptOriginalLanguage(null)
|
||||
setSelectedSeasons([])
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
@@ -169,7 +169,7 @@ export default function NewRequestClient() {
|
||||
setSearchAttempted(true)
|
||||
setSelected(null)
|
||||
setOptions(null)
|
||||
setAcceptOriginalLanguage(false)
|
||||
setAcceptOriginalLanguage(null)
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
@@ -212,7 +212,7 @@ export default function NewRequestClient() {
|
||||
const selectResult = async (item: DiscoveryResult) => {
|
||||
setSelected(item)
|
||||
setOptions(null)
|
||||
setAcceptOriginalLanguage(false)
|
||||
setAcceptOriginalLanguage(null)
|
||||
setSelectedSeasons([])
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
@@ -270,6 +270,7 @@ export default function NewRequestClient() {
|
||||
|
||||
const submitRequest = async () => {
|
||||
if (!selected || !options) return
|
||||
if (options.media.originalLanguage && acceptOriginalLanguage === null) { setError('Choose an audio language option before requesting.'); return }
|
||||
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
||||
setError('Select at least one season.')
|
||||
return
|
||||
@@ -291,7 +292,7 @@ export default function NewRequestClient() {
|
||||
body: JSON.stringify({
|
||||
mediaType: selected.type,
|
||||
tmdbId: selected.tmdbId,
|
||||
acceptOriginalLanguage,
|
||||
acceptOriginalLanguage: acceptOriginalLanguage === true,
|
||||
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
||||
}),
|
||||
})
|
||||
@@ -497,14 +498,15 @@ export default function NewRequestClient() {
|
||||
)}
|
||||
|
||||
{options.media.originalLanguage && <div className="request-language-notice">
|
||||
<h3>Check the audio language</h3>
|
||||
<h3>Choose your audio language</h3>
|
||||
<p>This title’s original language is <strong>{new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code) || options.media.originalLanguage.code}</strong>. An English audio track may not be available. Title metadata does not confirm the audio or subtitles in a download.</p>
|
||||
<label><input type="checkbox" checked={acceptOriginalLanguage} onChange={(event) => setAcceptOriginalLanguage(event.target.checked)} disabled={submitting} /><span>I’m happy to watch in the original language.</span></label>
|
||||
<small>{acceptOriginalLanguage ? (selected.type === 'movie' ? 'Search for original-language audio using the same quality requirements.' : 'Continue with your selected seasons and the configured TV quality requirements.') : 'Leave this unchecked to keep the standard request settings. An English-only profile may leave this title waiting for a suitable release.'}</small>
|
||||
<label><input type="radio" name="request-audio" checked={acceptOriginalLanguage === true} onChange={() => setAcceptOriginalLanguage(true)} disabled={submitting} /><span>Original {new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code)} audio — I’m happy to watch in the original language.</span></label>
|
||||
<label><input type="radio" name="request-audio" checked={acceptOriginalLanguage === false} onChange={() => setAcceptOriginalLanguage(false)} disabled={submitting} /><span>Keep standard audio requirements. This title may remain waiting for an English release.</span></label>
|
||||
<small>{acceptOriginalLanguage ? (selected.type === 'movie' ? 'Search for original-language audio using the same quality requirements.' : 'Continue with your selected seasons and the configured TV quality requirements.') : 'Choose an option to continue. An English-only profile may leave this title waiting for a suitable release.'}</small>
|
||||
</div>}
|
||||
<div className="request-submit-bar">
|
||||
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Your request uses the default quality set by your administrator.</small></div>
|
||||
<button type="button" onClick={() => void submitRequest()} disabled={submitting || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
||||
<button type="button" onClick={() => void submitRequest()} disabled={submitting || (Boolean(options.media.originalLanguage) && acceptOriginalLanguage === null) || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
||||
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './latest-activity.css'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
|
||||
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
|
||||
type Operation = { id: string; label: string; status: string; events: Event[] }
|
||||
@@ -11,21 +12,21 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [open, setOpen] = useState(true)
|
||||
useEffect(() => { setOpen(true) }, [operation.id])
|
||||
const latest = [...operation.events].sort((a, b) =>
|
||||
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
|
||||
).at(-1)
|
||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
|
||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : operation.status === 'searching' ? 'Search still running' : 'Needs attention'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const element = dialog.current
|
||||
element?.showModal()
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const unlock = lockBodyScroll()
|
||||
return () => {
|
||||
element?.close()
|
||||
document.body.style.overflow = previous
|
||||
unlock()
|
||||
trigger.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
@@ -37,13 +38,14 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
||||
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
|
||||
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true">↗</span></span>
|
||||
</button>
|
||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
|
||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)}>
|
||||
<div className="activity-dialog-content">
|
||||
<header>
|
||||
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
|
||||
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>Close</button>
|
||||
</header>
|
||||
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
|
||||
{operation.status === 'running' && <p role="status" className="activity-working">Working on your request. This can take a minute while the media services search.</p>}
|
||||
<ol aria-live="polite" className="activity-dialog-events" aria-label="All activity, oldest first">
|
||||
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
|
||||
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
|
||||
<div><strong>{event.service}</strong><p>{event.message}</p></div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
|
||||
type AudioChoice = { language: { code: string } | null; originalEnabled?: boolean; canChange?: boolean; profileLanguage?: string }
|
||||
|
||||
export default function RequestLanguage({ requestId, disabled, onApply }: {
|
||||
requestId: string; disabled: boolean; onApply: (code: string) => Promise<void>
|
||||
}) {
|
||||
const [choice, setChoice] = useState<AudioChoice | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
|
||||
.then(async response => { if (!response.ok) throw new Error('Could not check the audio settings. Reload the request to try again.'); return response.json() })
|
||||
.then(data => { if (!controller.signal.aborted) setChoice(data) })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e.message) })
|
||||
return () => controller.abort()
|
||||
}, [requestId, revision])
|
||||
if (error && !choice) return <p role="alert">{error}</p>
|
||||
if (!choice?.language) return null
|
||||
const code = choice.language.code
|
||||
const name = new Intl.DisplayNames(['en'], { type: 'language' }).of(code) || code
|
||||
return <section className="request-language-notice" aria-label="Audio language">
|
||||
<h2>{name} audio {choice.originalEnabled ? 'enabled' : 'may need your approval'}</h2>
|
||||
<p>This movie was originally made in {name}. An English dub may not exist. {choice.originalEnabled ? 'Radarr is set to accept its original audio.' : `The current audio requirement is ${choice.profileLanguage || 'set by the library'}. This can leave the request waiting even when an original-language release exists.`}</p>
|
||||
<p>Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not guaranteed by title metadata.</p>
|
||||
{choice.canChange && !choice.originalEnabled && <button type="button" disabled={disabled} onClick={async () => {
|
||||
setError(null)
|
||||
try { await onApply(code); setRevision(v => v + 1) } catch (e) { setError(e instanceof Error ? e.message : 'The audio choice could not be saved.') }
|
||||
}}>Use {name} audio & search</button>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../../ui/PageHeading'
|
||||
import RequestLanguage from './RequestLanguage'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
import LatestActivity from './LatestActivity'
|
||||
|
||||
import Image from 'next/image'
|
||||
@@ -339,14 +341,13 @@ export default function RequestTimelinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!releasePickerOpen) return
|
||||
const previousOverflow = document.body.style.overflow
|
||||
const unlock = lockBodyScroll()
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeReleasePicker()
|
||||
}
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
unlock()
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [releasePickerOpen, busyAction])
|
||||
@@ -588,14 +589,16 @@ export default function RequestTimelinePage() {
|
||||
const timer = window.setInterval(() => void refreshProgress(), 650)
|
||||
try {
|
||||
const response = await request
|
||||
const finalProgress = await refreshProgress()
|
||||
if (!finalProgress || finalProgress.status === 'running') {
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
...current, status: response.ok ? 'complete' : 'error',
|
||||
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
|
||||
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
|
||||
} : current)
|
||||
}
|
||||
await refreshProgress()
|
||||
let result: any = null
|
||||
try { result = await response.clone().json() } catch { /* Non-JSON error is handled below. */ }
|
||||
const needsAttention = !response.ok || result?.status === 'attention' || result?.outcome === 'attention'
|
||||
const finalState = needsAttention ? 'error' : result?.status === 'searching' ? 'searching' : 'complete'
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
...current, status: finalState,
|
||||
events: [...current.events.map(event => event.state === 'active' ? { ...event, state: 'complete' as const } : event), { id: 'result', service: 'Magent', state: needsAttention ? 'error' : 'complete',
|
||||
message: result?.message || (typeof result?.detail === 'string' ? result.detail : response.ok ? 'Action completed. The pipeline will update as the media services report progress.' : 'The action failed. Recheck the request before trying again.') }],
|
||||
} : current)
|
||||
return response
|
||||
} catch (error) {
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
@@ -660,7 +663,7 @@ export default function RequestTimelinePage() {
|
||||
setReleaseOptions([])
|
||||
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||
setReleaseSearchMessage(null)
|
||||
setReleasePickerOpen(true)
|
||||
setReleasePickerOpen(false)
|
||||
}
|
||||
setBusyAction(action.id)
|
||||
setActionError(null)
|
||||
@@ -680,6 +683,7 @@ export default function RequestTimelinePage() {
|
||||
if (action.id === 'search_releases') {
|
||||
const releases = Array.isArray(data.releases) ? data.releases : []
|
||||
setReleaseOptions(releases)
|
||||
setReleasePickerOpen(true)
|
||||
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||
setReleaseSearchMessage(
|
||||
data?.message ??
|
||||
@@ -744,6 +748,16 @@ export default function RequestTimelinePage() {
|
||||
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
|
||||
/>
|
||||
|
||||
<RequestLanguage requestId={snapshot.request_id} disabled={Boolean(busyAction)} onApply={async (code) => {
|
||||
setBusyAction('language')
|
||||
try {
|
||||
const response = await trackedPost('Use original audio and search', `${getApiBase()}/requests/${snapshot.request_id}/actions/language`, {
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }),
|
||||
})
|
||||
if (!response.ok) throw new Error(await readApiError(response, 'The audio choice could not be saved.'))
|
||||
} finally { setBusyAction(null) }
|
||||
}} />
|
||||
|
||||
<section className="request-overview" aria-labelledby="request-status-heading">
|
||||
<div className="request-overview-block request-overview-status">
|
||||
<span className="request-overview-label" id="request-status-heading">Status</span>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function UsersPage() {
|
||||
}
|
||||
const data = await response.json()
|
||||
setJellyseerrSyncStatus(
|
||||
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
|
||||
`Checked ${data?.total ?? 0} Seerr records against Jellyfin IDs. Added ${data?.imported ?? 0} users; existing settings retained.`
|
||||
)
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
@@ -187,10 +187,6 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -204,7 +200,7 @@ export default function UsersPage() {
|
||||
}
|
||||
const data = await response.json()
|
||||
setJellyseerrSyncStatus(
|
||||
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
|
||||
`Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`
|
||||
)
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
@@ -358,7 +354,7 @@ export default function UsersPage() {
|
||||
</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>
|
||||
<details className="user-management-advanced"><summary>Reconcile service identities</summary><p id="resync-help">Refreshes Jellyfin and Seerr accounts using their shared Jellyfin ID. Preserves account settings and history. Duplicate or conflicting links stay available for reviewed repair.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Reconciling identities…' : 'Reconcile Jellyfin and 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>
|
||||
<FeatureControls onSaved={() => void loadUsers()} />
|
||||
|
||||
Reference in New Issue
Block a user