diff --git a/README.md b/README.md index 22f6fd1..80784f7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/app/clients/jellystat.py b/backend/app/clients/jellystat.py index 77a3667..e388ed8 100644 --- a/backend/app/clients/jellystat.py +++ b/backend/app/clients/jellystat.py @@ -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") diff --git a/backend/app/db.py b/backend/app/db.py index d7dc41a..77e1990 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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), ) diff --git a/backend/app/main.py b/backend/app/main.py index e034a19..887d6be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/routers/identities.py b/backend/app/routers/identities.py new file mode 100644 index 0000000..4a7c79d --- /dev/null +++ b/backend/app/routers/identities.py @@ -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) diff --git a/backend/app/services/identity_review.py b/backend/app/services/identity_review.py new file mode 100644 index 0000000..7f379bb --- /dev/null +++ b/backend/app/services/identity_review.py @@ -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) diff --git a/backend/app/services/jellyfin_identity.py b/backend/app/services/jellyfin_identity.py index 7f0b20a..6226639 100644 --- a/backend/app/services/jellyfin_identity.py +++ b/backend/app/services/jellyfin_identity.py @@ -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 (?, ?, ?)", diff --git a/backend/tests/test_identity_review.py b/backend/tests/test_identity_review.py new file mode 100644 index 0000000..4cee421 --- /dev/null +++ b/backend/tests/test_identity_review.py @@ -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) diff --git a/docs/user-identities.md b/docs/user-identities.md new file mode 100644 index 0000000..82e2cf5 --- /dev/null +++ b/docs/user-identities.md @@ -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. diff --git a/frontend/app/admin/configNavigation.ts b/frontend/app/admin/configNavigation.ts index 890a88d..6232331 100644 --- a/frontend/app/admin/configNavigation.ts +++ b/frontend/app/admin/configNavigation.ts @@ -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: [ diff --git a/frontend/app/admin/identities/identities.css b/frontend/app/admin/identities/identities.css new file mode 100644 index 0000000..0307b7d --- /dev/null +++ b/frontend/app/admin/identities/identities.css @@ -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%; } +} diff --git a/frontend/app/admin/identities/page.tsx b/frontend/app/admin/identities/page.tsx new file mode 100644 index 0000000..2871839 --- /dev/null +++ b/frontend/app/admin/identities/page.tsx @@ -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 + counts: Record + rows: Row[] + upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[] +} +const labels: Record = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' } +const serviceLabels: Record = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' } +const basisLabels: Record = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerr’s Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' } +const statsLabels: Record = { 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(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([]) + const [reviewing, setReviewing] = useState(false) + const controller = useRef(null) + const reviewPanel = useRef(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 Back to users}> +
+ {error &&

{error}

} + {notice &&

{notice}

} + {!ready && !error &&

Checking administrator access…

} + {ready && <> +
+

Confirm user IDs

Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.

Review the proposed links before saving. Duplicate and conflicting accounts need individual investigation.

+ +
+ {busy &&

Reading the live user directories and checking Jellystat IDs. This can take up to a minute.

} + {report && <> +
{Object.entries(report.services).map(([service, state]) => {service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'} {serviceLabels[state] ?? state})}
+

Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server {report.server_id ?? 'Unavailable'}

+
{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) =>
{report.counts[state]}{state === 'magent' ? 'Magent accounts' : labels[state]}
)}
+

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.

+
+ + +
+
+ {filtered.length} accounts shown · {selected.length} selected + + + +
+ {reviewing &&
+

Save these {selected.length} account links?

+

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.

+
    {selectedRows.map((row) =>
  • {row.user.username} · Magent {row.user.id} → Jellyfin {row.candidate_jellyfin_id} → Seerr {row.seerr[0].id}
  • )}
+

Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.

+
+
} +
+ {!filtered.length &&

No accounts match these filters.

} + {filtered.map((row) =>
+
{row.can_confirm && toggle(row.user.id)} />}

{row.user.username}

Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in
{labels[row.state]}
+
+
Jellyfin user ID
{row.candidate_jellyfin_id ?? 'No match'}{row.jellyfin && {row.jellyfin.name}}{basisLabels[row.basis]}{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && Stored: {row.stored_jellyfin_id}}
+
Seerr user ID
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}{row.seerr.map((entry) => entry.name).join(', ')}Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}
+
Jellystat user ID
{row.jellystat.id ?? 'Not verified'}{statsLabels[row.jellystat.state] ?? row.jellystat.state}
+
+ {row.issues.length > 0 &&
    {row.issues.map((issue) =>
  • {issue}
  • )}
} + {row.state === 'unlinked' &&

A matching account is missing in one or more services. This account cannot be confirmed yet.

} + {row.state === 'unavailable' &&

A required service could not be checked. Check its connection and run this again.

} + {row.confirmed_at &&

Last confirmed {new Date(row.confirmed_at).toLocaleString()}

} +
)} +
+ {report.upstream.length > 0 &&
{report.upstream.length} upstream accounts need review
    {report.upstream.map((entry) =>
  • {entry.platform}: {entry.name} · ID {entry.id}{entry.jellyfin_id && · Jellyfin {entry.jellyfin_id}}

    {entry.detail}

  • )}
} + } + } +
+
+} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index be49c97..ccab4e8 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -336,6 +336,9 @@ export default function UsersPage() {
Directory actions
+