Add admin review and confirmation of cross-service user IDs
Magent CI/CD / verify (push) Successful in 10m29s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 54s

This commit is contained in:
2026-09-08 15:41:45 +12:00
parent 2976145dd8
commit 12611a9819
14 changed files with 972 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
- Docker-first deployment for easy hosting.
## Quick start (Docker - primary)
+32
View File
@@ -46,6 +46,38 @@ class JellystatClient(ApiClient):
raise JellystatError("Jellystat returned an unexpected library response")
return {"connected": True}
async def check_user_ids(self, user_ids: list[str]) -> dict:
"""Read metadata for known identities; never scan everyone's playback history."""
if not self.configured():
return {user_id: {"state": "not_configured"} for user_id in user_ids}
results = {user_id: {"state": "unavailable"} for user_id in user_ids}
semaphore = asyncio.Semaphore(6)
async with httpx.AsyncClient(timeout=8.0) as client:
async def check(user_id):
if not re.fullmatch(r"[a-f0-9]{32}", user_id):
return
async with semaphore:
try:
response = await client.post(f"{self.base_url}/api/getUserDetails",
headers={"x-api-token": self.api_key}, json={"userid": user_id})
if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
results[user_id] = {"state": "missing"}
return
response.raise_for_status()
row = response.json()
if row is None:
results[user_id] = {"state": "missing"}
elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
except (httpx.HTTPError, ValueError):
pass
try:
async with asyncio.timeout(25):
await asyncio.gather(*(check(user_id) for user_id in user_ids))
except TimeoutError:
pass
return results
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
raise JellystatError("Invalid linked Jellyfin identity")
+14
View File
@@ -193,6 +193,19 @@ def init_db() -> None:
PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_identity_confirmations (
local_user_id INTEGER PRIMARY KEY,
jellyfin_server_id TEXT NOT NULL,
jellyfin_user_id TEXT NOT NULL,
jellyfin_source TEXT NOT NULL,
seerr_source TEXT NOT NULL,
seerr_user_id INTEGER NOT NULL,
confirmed_at TEXT NOT NULL,
confirmed_by TEXT NOT NULL,
UNIQUE (jellyfin_server_id, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1288,6 +1301,7 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
AND id NOT IN (SELECT local_user_id FROM user_identity_confirmations)
""",
(jellyseerr_user_id, username),
)
+2
View File
@@ -28,6 +28,7 @@ from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import (
@@ -282,3 +283,4 @@ app.include_router(events_router)
app.include_router(portal_router)
app.include_router(operations_router)
app.include_router(insights_router)
app.include_router(identities_router)
+33
View File
@@ -0,0 +1,33 @@
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
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
class Confirmation(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
user_ids: list[int] = Field(min_length=1, max_length=3000)
@field_validator("user_ids")
@classmethod
def unique_positive_ids(cls, value):
if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
raise ValueError("Choose unique positive user IDs")
return value
@router.get("")
async def review(response: Response):
response.headers["Cache-Control"] = "no-store"
report, _, _ = await review_identities()
return report
@router.post("/confirm")
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)
+277
View File
@@ -0,0 +1,277 @@
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
import asyncio
import hashlib
import json
import re
import sqlite3
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .jellyfin_identity import source_key
MAX_USERS = 3000
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
def normalized_id(value):
value = str(value or "").lower().replace("-", "")
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def name_key(value):
return str(value or "").strip().casefold()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def config_digest(runtime):
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
def snapshot(conn):
conn.row_factory = sqlite3.Row
return {
"users": [dict(row) for row in conn.execute(
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
"links": [dict(row) for row in conn.execute(
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
"confirmations": [dict(row) for row in conn.execute(
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
# Detect settings changes between checking services and committing the reviewed links.
"config_revision": digest([tuple(row) for row in conn.execute(
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
}
def read_snapshot():
with closing(db._connect()) as conn:
return snapshot(conn)
async def jellyfin_directory(runtime):
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return {"state": "not_configured", "users": []}
try:
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
raise ValueError()
clean = []
seen = set()
for user in users:
user_id = normalized_id(user.get("Id"))
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
raise ValueError()
seen.add(user_id)
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
except Exception:
return {"state": "unavailable", "users": []}
async def seerr_directory(runtime):
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.base_url or not client.api_key:
return {"state": "not_configured", "users": []}
try:
users = []
seen = set()
expected_total = None
async with asyncio.timeout(20):
for skip in range(0, MAX_USERS, 100):
page = await client.get_users(take=100, skip=skip)
total = page["pageInfo"]["results"]
batch = page["results"]
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
raise ValueError()
if expected_total is not None and total != expected_total:
raise ValueError()
expected_total = total
for user in batch:
user_id = user.get("id")
if type(user_id) is not int or user_id <= 0 or user_id in seen:
raise ValueError()
seen.add(user_id)
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
if len(users) == total:
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
if len(batch) != 100 or len(users) > total:
raise ValueError()
except Exception:
pass
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime):
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
jf_by_name[name_key(row["name"])].append(row["id"])
seerr_by_id = {row["id"]: row for row in seerr["users"]}
seerr_by_jf = defaultdict(list)
for row in seerr["users"]:
if row["jellyfin_id"]:
seerr_by_jf[row["jellyfin_id"]].append(row)
current_source = source_key(runtime.jellyfin_base_url)
seerr_source = source_key(runtime.jellyseerr_base_url)
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
for user in local["users"]:
local_by_name[name_key(user["username"])].append(user["id"])
if user["jellyseerr_user_id"] is not None:
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
rows = []
for user in local["users"]:
issues = []
saved = confirmed.get(user["id"])
linked = links.get(user["id"])
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
by_name = jf_by_name.get(name_key(user["username"]), [])
basis = "none"
candidate = None
if saved:
candidate = saved["jellyfin_user_id"]
basis = "confirmed_id"
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
issues.append("The confirmed server or Seerr connection has changed.")
elif linked:
candidate, basis = linked, "stored_jellyfin_id"
elif stored_seerr and stored_seerr["jellyfin_id"]:
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 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:
issues.append("Multiple Magent rows share the stored Seerr ID.")
if len(by_name) > 1:
issues.append("This name matches multiple distinct Jellyfin IDs.")
if candidate and by_name and candidate not in by_name:
issues.append("The stored ID and current Jellyfin username point to different accounts.")
if linked and candidate and linked != candidate:
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
jf = jf_by_id.get(candidate)
if candidate and not jf and jellyfin["state"] == "available":
issues.append("The linked Jellyfin ID is absent from the current server.")
expected_seerr = seerr_by_jf.get(candidate, [])
if len(expected_seerr) > 1:
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
):
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
issues.append("The stored Seerr ID has changed since confirmation.")
js = jellystat.get(candidate, {"state": "not_checked"})
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
"can_confirm": False, "state": "unlinked"})
candidates = defaultdict(list)
for row in rows:
if row["candidate_jellyfin_id"]:
candidates[row["candidate_jellyfin_id"]].append(row)
for row in rows:
candidate = row["candidate_jellyfin_id"]
if len(candidates.get(candidate, [])) > 1:
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
if row["issues"]:
row["state"] = "conflict"
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
row["state"] = "unavailable"
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
row["state"] = "unlinked"
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
row["state"] = "confirmed"
else:
row["state"] = "ready"
row["can_confirm"] = True
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
"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,
"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")}}}
report["revision"] = digest([report, digest(local), config_digest(runtime)])
report["checked_at"] = datetime.now(timezone.utc).isoformat()
return report
async def review_identities():
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:
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
ids = {row["id"] for row in jf["users"]}
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
ids.discard(None)
if len(ids) > MAX_USERS:
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
def save_confirmations(report, local, runtime, user_ids, admin):
rows = {row["user"]["id"]: row for row in report["rows"]}
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
now = datetime.now(timezone.utc).isoformat()
try:
with closing(db._connect()) as conn, conn:
conn.execute("BEGIN IMMEDIATE")
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
for user_id in user_ids:
row = rows[user_id]
jf_id = row["jellyfin"]["id"]
seerr_id = row["seerr"][0]["id"]
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_confirmations
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
seerr_id, now, admin["username"]))
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
return {"confirmed": len(user_ids), "confirmed_at": now}
async def confirm_identities(revision, user_ids, admin):
report, local, runtime = await review_identities()
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)
@@ -28,6 +28,9 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
# Reviewed identities are updated only through the admin confirmation workflow.
return
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
+268
View File
@@ -0,0 +1,268 @@
import json
import unittest
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import JellystatClient
from backend.app.routers import identities
from backend.app.services import identity_review as review
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
JF = "a" * 32
OTHER = "b" * 32
SERVER = "c" * 32
ADMIN = {"username": "admin", "role": "admin"}
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
self.user_id = db.get_user_by_username("Georgia")["id"]
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
def build(self):
local = review.read_snapshot()
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
def row(self, report):
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
row = self.row(report)
self.assertEqual(row["basis"], "suggested_username")
self.assertEqual(row["state"], "ready")
self.assertEqual(row["seerr"][0]["id"], 20)
self.assertEqual(before, review.read_snapshot())
serialized = json.dumps(report)
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
self.assertNotIn(private, serialized)
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
report, local = self.build()
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
saved = review.read_snapshot()["confirmations"][0]
self.assertEqual(saved["jellyfin_server_id"], SERVER)
self.assertEqual(saved["confirmed_by"], "admin")
db.set_user_jellyseerr_id("Georgia", 999)
link_user("Georgia", OTHER, "http://other-server")
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
refreshed, _ = self.build()
self.assertEqual(self.row(refreshed)["state"], "confirmed")
self.assertFalse(self.row(refreshed)["can_confirm"])
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
db.set_user_jellyseerr_id("Georgia", 20)
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
report, local = self.build()
self.assertEqual(len(db.get_all_users()), 1)
self.assertEqual(len(report["rows"]), 2)
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot()["confirmations"], [])
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
db.set_user_jellyseerr_id("Georgia", 21)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertFalse(self.row(report)["can_confirm"])
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
db.create_user("georgia", "jellyfin-user", auth_provider="jellyfin")
report, _ = self.build()
self.assertEqual(report["counts"]["conflict"], 2)
self.assertEqual(report["counts"]["ready"], 0)
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
self.jf["users"].append({"id": OTHER, "name": "local"})
db.create_user("local", "password", auth_provider="local")
report, _ = self.build()
for row in report["rows"]:
if row["user"]["id"] != self.user_id:
self.assertIsNone(row["candidate_jellyfin_id"])
self.assertFalse(row["can_confirm"])
async def test_missing_or_unavailable_services_never_confirm(self):
for state in ["missing", "unavailable", "not_configured"]:
self.js[JF] = {"state": state}
report, _ = self.build()
self.assertFalse(self.row(report)["can_confirm"])
self.js = {JF: {"state": "matched", "id": JF}}
self.seerr = {"state": "unavailable", "users": []}
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "unavailable")
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.seerr["users"].pop()
with closing(db._connect()) as conn, conn:
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_wrong_stored_id_is_not_silently_replaced(self):
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
async def test_account_changes_reject_whole_save(self):
report, local = self.build()
db.set_user_jellyseerr_id("Georgia", 99)
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
self.assertEqual(review.read_snapshot()["confirmations"], [])
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
async def test_settings_changes_reject_save(self):
report, local = self.build()
db.set_setting("jellyfin_base_url", "http://changed")
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
async def test_save_reads_real_runtime_settings_inside_transaction(self):
from backend.app.runtime import get_runtime_settings
for key in review.CONFIG_KEYS:
db.set_setting(key, getattr(self.runtime, key))
report, local = self.build()
with patch.object(review, "get_runtime_settings", get_runtime_settings):
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
second_id = db.get_user_by_username("Second")["id"]
self.jf["users"].append({"id": OTHER, "name": "Second"})
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
self.js[OTHER] = {"state": "matched", "id": OTHER}
with closing(db._connect()) as conn, conn:
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
report, local = self.build()
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
after = review.read_snapshot()
self.assertEqual(after["confirmations"], [])
self.assertEqual(after["links"], [])
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
report, local = self.build()
changed = {**report, "revision": "f" * 64}
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
patch.object(review, "save_confirmations") as save:
with self.assertRaises(HTTPException) as raised:
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
save.assert_not_called()
async def test_different_server_cannot_reuse_confirmed_id(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf["server_id"] = OTHER
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
a, _ = self.build()
b, _ = self.build()
self.assertEqual(a["revision"], b["revision"])
self.seerr["users"][0]["id"] = 99
c, _ = self.build()
self.assertNotEqual(a["revision"], c["revision"])
async def test_seerr_directory_requires_complete_unique_pages(self):
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
result = await review.seerr_directory(self.runtime)
self.assertEqual(result["state"], "available")
self.assertEqual(len(result["users"]), 101)
self.assertEqual(get.await_args.kwargs["skip"], 100)
for broken in [[], users[:2], users[:1] * 100]:
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
async def test_jellyfin_server_and_directory_must_agree(self):
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
async def test_unconfigured_client_never_calls_upstream(self):
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
result = await JellystatClient(None, None).check_user_ids([JF])
http.assert_not_called()
self.assertEqual(result[JF]["state"], "not_configured")
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
ids = [f"{i:032x}" for i in range(1, 6)]
def handler(request):
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
user_id = json.loads(request.content)["userid"]
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
if user_id == ids[1]: return httpx.Response(200, content=b"")
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
return httpx.Response(503, text="PRIVATE error")
real = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
self.assertNotIn("PRIVATE", json.dumps(data))
class IdentityRouteTests(unittest.TestCase):
def client(self, role=None):
app = FastAPI()
app.include_router(identities.router)
if role:
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
return TestClient(app)
def test_admin_only_read_and_write(self):
for role, status in [(None, 401), ("user", 403)]:
client = self.client(role)
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_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")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)
+18
View File
@@ -0,0 +1,18 @@
# Confirming user identities
Open **Users → Confirm user IDs**, or **Settings → User identities**. Administrator access is required.
1. Choose **Check all user IDs** to read the live Jellyfin and Seerr directories and check Jellystat user metadata.
2. Search by account name or ID, or filter by status. The results include raw Magent rows that the ordinary user directory may hide as duplicates.
3. Select accounts marked **Ready to review**. Check the Magent account, Jellyfin ID and Seerr ID in **Review selected links**.
4. Choose **Confirm and save links**. Magent checks the live mappings again before saving. If accounts, service settings or mappings changed, run a fresh check.
The canonical external identity is the Jellyfin server ID plus Jellyfin user ID. Seerr is matched through its explicit `jellyfinUserId`; Jellystat must return the same user ID. Existing Magent Jellyfin or Seerr links take precedence. For existing Jellyfin sign-in accounts without a stored ID, a unique normalized Jellyfin username provides a **suggestion requiring administrator review**. Emails and email prefixes never establish an identity.
Confirmation saves the Jellyfin link, Seerr user ID, Jellyfin server ID, timestamp and confirming administrator. Normal name-based sync cannot replace confirmed links. My Stats uses the saved Jellyfin ID for playback and the Seerr ID for requests. Changes to the authentication token format are outside this feature.
Conflicts, duplicate accounts, ambiguous case/whitespace names, absent IDs and unavailable services cannot be confirmed. This workflow does not merge, delete or create user accounts in any platform. It does not rewrite playback or requests. Conflicting mappings need investigation before reconciliation.
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.
+1
View File
@@ -18,6 +18,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
{ href: '/admin/identities', label: 'User identities', description: 'Review and confirm IDs across your media services', symbol: 'ID' },
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
]},
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
@@ -0,0 +1,62 @@
.identity-review { display: grid; gap: 20px; min-width: 0; }
.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; }
.identity-intro h2 { margin: 0 0 8px; font-size: 1.1rem; }
.identity-intro p { max-width: 760px; color: var(--ops-muted); }
.identity-intro button { flex-shrink: 0; }
.identity-service-strip { display: flex; flex-wrap: wrap; gap: 14px 24px; }
.identity-service-strip span { font-size: .88rem; color: var(--ops-muted); }
.identity-service-strip strong { color: var(--ops-text); margin-right: 6px; }
.identity-meta { color: var(--ops-muted); font-size: .82rem; }
.identity-counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.identity-counts > div { border: 1px solid var(--ops-line); border-radius: 12px; padding: 16px; display: grid; gap: 4px; }
.identity-counts strong { font-size: 1.8rem; }
.identity-counts span { color: var(--ops-muted); font-size: .78rem; }
.identity-filters { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 16px; }
.identity-filters label { display: grid; gap: 8px; font-size: .85rem; }
.identity-filters input, .identity-filters select { width: 100%; min-width: 0; }
.identity-selection, .identity-confirm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.identity-selection > span { color: var(--ops-muted); font-size: .85rem; margin-right: auto; }
.identity-accounts { display: grid; gap: 16px; }
.identity-account { border: 1px solid var(--ops-line); border-radius: 14px; padding: 22px; min-width: 0; }
.identity-account > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.identity-account-name { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity-account-name h2 { font-size: 1.05rem; margin: 0 0 3px; overflow-wrap: anywhere; white-space: pre-wrap; }
.identity-account-name span { font-size: .8rem; color: var(--ops-muted); }
.identity-review input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; accent-color: var(--ops-primary-2); }
.identity-badge { border-radius: 100px; padding: 5px 10px; font-size: .73rem; background: #263242; color: #d9e2ef; white-space: nowrap; }
.identity-badge.is-ready { background: #18374c; color: #a3dbff; }
.identity-badge.is-confirmed { background: #17382d; color: #9ce3bd; }
.identity-badge.is-conflict { background: #492d28; color: #ffc1ac; }
.identity-badge.is-unavailable, .identity-badge.is-unlinked { background: #40391f; color: #ead696; }
.identity-mapping { display: grid; grid-template-columns: 1.3fr .8fr 1.3fr; gap: 22px; margin: 22px 0 0; }
.identity-mapping > div { min-width: 0; }
.identity-mapping dt { color: var(--ops-muted); font-size: .75rem; margin-bottom: 8px; }
.identity-mapping dd { display: grid; gap: 5px; margin: 0; overflow-wrap: anywhere; }
.identity-mapping dd small { color: var(--ops-muted); line-height: 1.5; }
.identity-issues { margin: 20px 0 0; padding: 14px 14px 14px 30px; color: #ffc1ac; background: #492d2833; border-radius: 8px; font-size: .83rem; line-height: 1.7; }
.identity-account > .identity-meta { margin-top: 16px; }
.identity-confirm-panel { border: 1px solid var(--ops-primary-2); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
.identity-confirm-panel h2 { margin: 0; font-size: 1.15rem; }
.identity-confirm-panel ul { margin: 0; padding-left: 20px; max-height: 260px; overflow: auto; }
.identity-confirm-panel li { line-height: 1.9; overflow-wrap: anywhere; }
.identity-upstream { border-top: 1px solid var(--ops-line); padding-top: 20px; }
.identity-upstream summary { cursor: pointer; }
.identity-upstream ul { padding-left: 20px; }
.identity-upstream li { margin: 16px 0; overflow-wrap: anywhere; }
@media (max-width: 980px) {
.identity-intro { align-items: flex-start; flex-direction: column; }
.identity-counts { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.identity-mapping { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 540px) {
.identity-filters { grid-template-columns: 1fr; }
.identity-account { padding: 16px; }
.identity-account > header { flex-direction: column; }
.identity-intro, .identity-confirm-panel { padding: 16px; }
.identity-counts { gap: 8px; }
.identity-counts > div { padding: 10px; }
.identity-counts strong { font-size: 1.4rem; }
.identity-selection button { width: 100%; }
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
import './identities.css'
type Identity = { id: string; name: string }
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
stored_jellyfin_id: string | null
seerr: { id: number; name: string; jellyfin_id: string }[]
jellystat: { state: string; id?: string; name?: string }
basis: string
issues: string[]
state: string
can_confirm: boolean
confirmed_at: string | null
}
type Report = {
revision: string; checked_at: string; server_id: string | null
services: Record<string, string>
counts: Record<string, number>
rows: Row[]
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
}
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerrs Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
export default function IdentityReviewPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [report, setReport] = useState<Report | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [selected, setSelected] = useState<number[]>([])
const [reviewing, setReviewing] = useState(false)
const controller = useRef<AbortController | null>(null)
const reviewPanel = useRef<HTMLElement | null>(null)
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login'); return }
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
if (!abort.signal.aborted) setReady(true)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => { abort.abort(); controller.current?.abort() }
}, [router])
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
const responseData = async (response: Response) => {
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
return data
}
const runCheck = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
if (!abort.signal.aborted) setReport(data)
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
} finally { if (!abort.signal.aborted) setBusy(false) }
}
const save = async () => {
if (!report || saving || !selected.length) return
setSaving(true); setError(''); setNotice('')
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}))
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
// The scan describes the previous database state and cannot be reused for another write.
setReport(null); setSelected([]); setReviewing(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save identity links.')
setReport(null); setSelected([]); setReviewing(false)
} finally { setSaving(false) }
}
const needle = query.trim().toLowerCase()
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
return <AdminShell title="User identities" subtitle="Review account links across Jellyfin, Seerr, Jellystat and Magent." actions={<a href="/users" className="ghost-button">Back to users</a>}>
<div className="identity-review">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!ready && !error && <p role="status">Checking administrator access</p>}
{ready && <>
<section className="identity-intro admin-panel">
<div><h2>Confirm user IDs</h2><p>Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Duplicate and conflicting accounts need individual investigation.</p></div>
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
</section>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
{report && <>
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
<div className="identity-filters">
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
</div>
<div className="identity-selection">
<span>{filtered.length} accounts shown · {selected.length} selected</span>
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
</div>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<h2>Save these {selected.length} account links?</h2>
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin <code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}</li>)}</ul>
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
</section>}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
<dl className="identity-mapping">
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
<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 === '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>)}
</section>
{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>}
</>}
</>}
</div>
</AdminShell>
}
+3
View File
@@ -336,6 +336,9 @@ export default function UsersPage() {
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/admin/identities')}>
Confirm user IDs
</button>
<button
type="button"
className="ghost-button"
+97
View File
@@ -0,0 +1,97 @@
// Fixture-only review: every API request is intercepted; no live identities are changed.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3116'
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let role = 'admin'
let mode = 'ready'
let scans = 0
const saves = []
const row = (id, username, state) => ({
user: { id, username, role: 'user', auth_provider: 'jellyfin', jellyseerr_user_id: null },
jellyfin: { id: 'a'.repeat(32), name: username }, candidate_jellyfin_id: 'a'.repeat(32), stored_jellyfin_id: null,
seerr: [{ id: 20 + id, name: username, jellyfin_id: 'a'.repeat(32) }],
jellystat: { state: state === 'unavailable' ? 'unavailable' : 'matched', id: 'a'.repeat(32), name: username },
basis: 'suggested_username', issues: state === 'conflict' ? ['Multiple Magent accounts resolve to this Jellyfin ID.'] : [],
state, can_confirm: state === 'ready', confirmed_at: state === 'confirmed' ? '2026-09-08T00:00:00Z' : null,
})
const fixture = {
revision: 'b'.repeat(64), checked_at: '2026-09-08T01:00:00Z', server_id: 'c'.repeat(32),
services: { jellyfin: 'available', seerr: 'available', jellystat: 'available' },
counts: { magent: 5, ready: 2, confirmed: 1, conflict: 1, unlinked: 0, unavailable: 1 },
rows: [row(51, 'Georgia', 'ready'), row(52, 'Another viewer', 'ready'), row(53, 'Duplicate account', 'conflict'), row(54, 'Unavailable viewer', 'unavailable'), row(55, 'Confirmed viewer', 'confirmed')],
upstream: [{ platform: 'Seerr', id: '99', name: 'Former viewer', jellyfin_id: 'd'.repeat(32), detail: 'No current Jellyfin account has this ID.' }],
}
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture admin', role } })
if (url.pathname === '/api/admin/identities') {
scans++
if (mode === 'forbidden') return route.fulfill({ status: 403, json: { detail: 'Forbidden' } })
return route.fulfill({ json: fixture })
}
if (url.pathname === '/api/admin/identities/confirm') {
saves.push(request.postDataJSON())
if (mode === 'stale') return route.fulfill({ status: 409, json: { detail: 'The identity check has changed. Run it again before confirming accounts.' } })
return route.fulfill({ json: { confirmed: request.postDataJSON().user_ids.length, confirmed_at: '2026-09-08T02:00:00Z' } })
}
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return route.fulfill({ json: {} })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(`${base}/admin/identities`)
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).waitFor()
const before = scans
assert.equal(await page.locator('.identity-account').count(), 0)
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).click()
await page.getByRole('heading', { name: 'Georgia', exact: true }).waitFor()
assert.equal(scans, before + 1)
assert.equal(await page.getByRole('checkbox').count(), 2)
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
if (process.env.REVIEW_DIR) {
fs.mkdirSync(process.env.REVIEW_DIR, { recursive: true })
await page.screenshot({ path: path.join(process.env.REVIEW_DIR, `identities-${width}.png`), fullPage: true })
}
}
await page.getByLabel('Find an account').fill('Georgia')
await page.getByRole('button', { name: 'Select ready accounts shown' }).click()
assert.equal(saves.length, 0)
await page.getByRole('button', { name: 'Review selected links (1)', exact: true }).click()
await page.getByRole('region', { name: 'Review links before saving' }).waitFor()
assert.equal(await page.locator('.identity-confirm-panel li').count(), 1)
assert(await page.locator('.identity-confirm-panel').evaluate((element) => document.activeElement === element))
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
await page.getByRole('button', { name: 'Confirm and save links', exact: true }).click()
await page.getByText('1 account link confirmed and saved.', { exact: false }).waitFor()
assert.deepEqual(saves[0], { revision: fixture.revision, user_ids: [51] })
assert.equal(await page.locator('.identity-account').count(), 0)
mode = 'stale'
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).click()
await page.getByRole('heading', { name: 'Georgia', exact: true }).waitFor()
await page.getByRole('button', { name: 'Select ready accounts shown' }).click()
await page.getByRole('button', { name: 'Review selected links (1)', exact: true }).click()
await page.getByRole('button', { name: 'Confirm and save links', exact: true }).click()
await page.getByRole('alert').filter({ hasText: 'The identity check has changed' }).waitFor()
assert.equal(await page.getByRole('button', { name: 'Confirm and save links', exact: true }).count(), 0)
mode = 'forbidden'
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).click()
await page.waitForURL(base + '/')
role = 'user'
await page.goto(`${base}/admin/identities`)
await page.waitForURL(base + '/')
assert.deepEqual(errors, [])
console.log('Identity UI: desktop/mobile, filtering, conflict exclusions, review, save, stale checks and access control passed.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })