107 lines
8.3 KiB
TypeScript
107 lines
8.3 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { authFetch, getApiBase } from '../../lib/auth'
|
||
import type { Row } from './IdentityReviewPanel'
|
||
|
||
type Preview = {
|
||
revision: string; server_id: string; row: Row
|
||
before: { jellyfin_user_id: string | null; seerr_user_id: number | null }
|
||
seerr_users: { id: number; name: string; jellyfin_id: string | null }[]
|
||
scope: string
|
||
action: string
|
||
}
|
||
|
||
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(row.candidate_jellyfin_id ?? '')
|
||
const [inspectSeerr, setInspectSeerr] = useState('')
|
||
const [createSeerr, setCreateSeerr] = useState(false)
|
||
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/repair/${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, create_seerr: createSeerr, ...(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">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
||
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
|
||
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); 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>
|
||
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
|
||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</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 repair' : 'This link needs attention'}</h3>
|
||
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
||
<div className="identity-mapping">
|
||
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
|
||
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
|
||
</div>
|
||
<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>}
|
||
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
|
||
<h3>Check the existing Seerr account</h3>
|
||
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
|
||
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
|
||
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
|
||
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
|
||
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
|
||
</div>}
|
||
<p>{preview.scope}</p>
|
||
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
|
||
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
|
||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
|
||
</section>}
|
||
</div>
|
||
</dialog>
|
||
}
|