Add reviewed resolution for missing user identity links
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import type { Row } from './page'
|
||||
|
||||
type Preview = { revision: string; server_id: string; row: Row }
|
||||
|
||||
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
|
||||
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const [chosen, setChosen] = useState('')
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
const overflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
dialog.current?.showModal()
|
||||
return () => {
|
||||
controller.current?.abort()
|
||||
document.body.style.overflow = overflow
|
||||
previous?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const submit = async (confirm: boolean) => {
|
||||
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setError('')
|
||||
if (confirm) setSaving(true)
|
||||
else { setBusy(true); setPreview(null) }
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/identities/resolve/${confirm ? 'confirm' : 'check'}`, {
|
||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, ...(confirm ? { revision: preview?.revision } : {}) }),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
|
||||
if (!abort.signal.aborted) {
|
||||
if (confirm) onSaved()
|
||||
else setPreview(data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abort.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : 'Could not resolve the link.')
|
||||
setPreview(null)
|
||||
}
|
||||
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
||||
}
|
||||
|
||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
||||
<div className="identity-resolve-content">
|
||||
<header><h2 id="resolve-title">Resolve missing link</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
||||
<p>Link <strong>{row.user.username}</strong> (Magent {row.user.id}) to their Jellyfin account. Review the IDs below to confirm this is the same person.</p>
|
||||
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
||||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setChosen(event.target.value)
|
||||
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} — {account.id}</option>)}</select></label>
|
||||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Check selected account'}</button>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
||||
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
||||
<h3>{preview.row.can_confirm ? 'Ready to confirm' : 'This link needs attention'}</h3>
|
||||
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
||||
<dl className="identity-mapping">
|
||||
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
|
||||
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}</dd></div>
|
||||
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div>
|
||||
</dl>
|
||||
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
|
||||
<p>Saving stores the verified Jellyfin and Seerr links in Magent, with your administrator name and confirmation time. All platform IDs and duplicate ownership are checked again before saving.</p>
|
||||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : 'Confirm and save link'}</button>
|
||||
</section>}
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
.identity-review { display: grid; gap: 20px; min-width: 0; }
|
||||
.identity-resolution-entry { display: grid; justify-items: start; gap: 12px; margin-top: 16px; }
|
||||
.identity-resolve-dialog { width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; color: var(--ops-text); background: var(--ops-panel, #1b1b1d); border: 1px solid var(--ops-line); border-radius: 16px; }
|
||||
.identity-resolve-dialog::backdrop { background: #000b; backdrop-filter: blur(4px); }
|
||||
.identity-resolve-content { display: grid; gap: 20px; padding: 24px; min-width: 0; }
|
||||
.identity-resolve-content > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.identity-resolve-content h2, .identity-resolve-content h3 { margin: 0; }
|
||||
.identity-resolve-content h2 { font-size: 1.2rem; }
|
||||
.identity-resolve-content label { display: grid; gap: 8px; min-width: 0; }
|
||||
.identity-resolve-content select { width: 100%; min-width: 0; }
|
||||
.identity-resolve-content p { overflow-wrap: anywhere; }
|
||||
@media (max-width: 540px) { .identity-resolve-content { padding: 16px; } }
|
||||
.identity-review p { margin: 0; line-height: 1.65; }
|
||||
.identity-review code { overflow-wrap: anywhere; font-size: .8rem; }
|
||||
.identity-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px; }
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import AdminShell from '../../ui/AdminShell'
|
||||
import './identities.css'
|
||||
import ResolveIdentityLink from './ResolveIdentityLink'
|
||||
|
||||
type Identity = { id: string; name: string }
|
||||
type Row = {
|
||||
export type Row = {
|
||||
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null }
|
||||
jellyfin: Identity | null
|
||||
candidate_jellyfin_id: string | null
|
||||
@@ -24,6 +25,7 @@ type Report = {
|
||||
revision: string; checked_at: string; server_id: string | null
|
||||
services: Record<string, string>
|
||||
counts: Record<string, number>
|
||||
jellyfin_users: Identity[]
|
||||
rows: Row[]
|
||||
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
|
||||
}
|
||||
@@ -43,6 +45,7 @@ export default function IdentityReviewPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [resolving, setResolving] = useState<Row | null>(null)
|
||||
const [reviewing, setReviewing] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const reviewPanel = useRef<HTMLElement | null>(null)
|
||||
@@ -148,7 +151,7 @@ export default function IdentityReviewPage() {
|
||||
<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' && <p className="identity-meta">A matching account is missing in one or more services. This account cannot be confirmed yet.</p>}
|
||||
{row.state === 'unlinked' && <div className="identity-resolution-entry"><p className="identity-meta">Choose the correct Jellyfin account and check its Seerr and Jellystat links before saving.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Resolve missing link</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>)}
|
||||
@@ -156,6 +159,10 @@ export default function IdentityReviewPage() {
|
||||
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
|
||||
</>}
|
||||
</>}
|
||||
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
|
||||
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
|
||||
setNotice('Account links confirmed and saved. Run another check to see the updated mappings.')
|
||||
}} />}
|
||||
</div>
|
||||
</AdminShell>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user