Files
Magent/frontend/app/admin/identities/ResolveIdentityLink.tsx
T
Assclaw 6e473fd0a7
Magent CI/CD / verify (push) Canceled after 9m3s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
Unify user management and add reviewed identity repairs
2026-09-10 16:06:30 +12:00

107 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 users 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>
}