Add reviewed resolution for missing user identity links
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s

This commit is contained in:
2026-09-10 15:02:04 +12:00
parent b310e86f80
commit 77f2c1b42a
7 changed files with 231 additions and 6 deletions
+23 -1
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import require_admin
from ..services.identity_review import confirm_identities, review_identities
from ..services.identity_review import confirm_identities, review_identities, resolve_identity
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
@@ -31,3 +31,25 @@ async def review(response: Response):
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await confirm_identities(payload.revision, payload.user_ids, admin)
class Resolution(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: int = Field(gt=0, strict=True)
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ResolutionConfirmation(Resolution):
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
@router.post("/resolve/check")
async def check_resolution(payload: Resolution, response: Response):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
@router.post("/resolve/confirm")
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
+22 -3
View File
@@ -116,7 +116,10 @@ async def seerr_directory(runtime):
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime):
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None):
selections = selections or {}
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
@@ -155,6 +158,11 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if user["id"] in selections:
chosen = selections[user["id"]]
if saved and chosen != saved["jellyfin_user_id"]:
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
candidate, basis = chosen, "admin_selected"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
@@ -217,6 +225,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"jellyfin_users": jellyfin["users"],
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
@@ -225,7 +234,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
return report
async def review_identities():
async def review_identities(selections=None):
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
@@ -238,7 +247,7 @@ async def review_identities():
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime), local, runtime
return build_report(local, jf, seerr, js, runtime, selections), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin):
@@ -275,3 +284,13 @@ async def confirm_identities(revision, user_ids, admin):
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
if revision is not None:
if report["revision"] != revision:
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
return {"revision": report["revision"], "server_id": report["server_id"],
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
+67
View File
@@ -44,6 +44,61 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def row(self, report):
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
async def test_manual_selection_resolves_different_username_without_guessing(self):
self.jf['users'][0]['name'] = 'Different Jellyfin name'
before = review.read_snapshot()
report, _ = self.build()
self.assertEqual(self.row(report)['state'], 'unlinked')
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot(), local)
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
self.jf['users'][0]['name'] = 'Different'
for state in ['missing', 'unavailable', 'not_configured']:
self.js[JF] = {'state': state}
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertFalse(self.row(report)['can_confirm'])
self.js[JF] = {'state': 'matched', 'id': JF}
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertEqual(self.row(report)['state'], 'conflict')
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException) as error:
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
self.assertEqual(error.exception.status_code, 404)
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
before = review.read_snapshot()
preview = await review.resolve_identity(self.user_id, JF)
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException) as error:
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(error.exception.status_code, 409)
self.assertEqual(review.read_snapshot(), before)
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
self.assertEqual(result['confirmed'], 1)
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
@@ -258,6 +313,18 @@ class IdentityRouteTests(unittest.TestCase):
self.assertEqual(client.get("/admin/identities").status_code, status)
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
def test_resolution_requires_admin_and_strict_ids(self):
for endpoint in ['check', 'confirm']:
body = {'user_id': 1, 'jellyfin_user_id': JF}
if endpoint == 'confirm': body['revision'] = 'a' * 64
for role, status in [(None, 401), ('user', 403)]:
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
self.assertEqual(result.headers['cache-control'], 'no-store')
def test_no_store_and_no_browser_supplied_identity(self):
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
response = self.client("admin").get("/admin/identities")
+16
View File
@@ -16,3 +16,19 @@ Conflicts, duplicate accounts, ambiguous case/whitespace names, absent IDs and u
Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links. They do not enumerate historical Jellystat-only users or playback records. Each run supports up to 3,000 identities, fetches complete Seerr pages, limits concurrent Jellystat requests to six, and stops checking Jellystat after 25 seconds. Unfinished checks remain unavailable, never verified. Results are not HTTP-cached and contain no credentials or raw playback history.
All selected accounts are saved in one transaction. The server derives the destination IDs from a fresh check and verifies the database snapshot before writing; the browser only supplies the reviewed revision and selected Magent row IDs.
### Resolve a missing link
Open **Users > Manage users > Review account links**, then **Check all user IDs**.
For an account marked **Missing link**, choose **Resolve missing link**. Select the
correct Jellyfin account by name and ID, then **Check selected account**. The
preview checks Seerr's explicit Jellyfin ID, Jellystat's matching ID, and every
Magent account (including hidden duplicates) for ownership conflicts.
Review the IDs and choose **Confirm and save link**. Magent rechecks live services
and the local directory before atomically saving both links and the administrator
audit record. A changed preview must be checked again. Existing confirmed or
conflicting stored identities cannot be replaced using this flow. Missing upstream
records must be corrected in their service before confirmation is available.
No accounts are created, merged or deleted; emails are not used to infer identity.
@@ -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 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>}
<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; }
+9 -2
View File
@@ -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>
}