From 6e473fd0a792b78aafd1774c9ce63ac3edc0042b Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Thu, 10 Sep 2026 16:06:30 +1200 Subject: [PATCH] Unify user management and add reviewed identity repairs --- backend/app/db.py | 10 ++ backend/app/routers/identities.py | 22 ++- backend/app/services/identity_review.py | 85 ++++++++- backend/tests/test_identity_review.py | 135 ++++++++++++++ docs/user-identities.md | 25 +++ frontend/app/admin/configNavigation.ts | 3 +- .../admin/identities/IdentityReviewPanel.tsx | 167 ++++++++++++++++++ .../admin/identities/ResolveIdentityLink.tsx | 47 +++-- frontend/app/admin/identities/identities.css | 5 + frontend/app/admin/identities/page.tsx | 167 +----------------- frontend/app/users/page.tsx | 26 ++- 11 files changed, 501 insertions(+), 191 deletions(-) create mode 100644 frontend/app/admin/identities/IdentityReviewPanel.tsx diff --git a/backend/app/db.py b/backend/app/db.py index d2787f9..2e0492e 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -206,6 +206,16 @@ def init_db() -> None: UNIQUE (jellyfin_server_id, jellyfin_user_id) ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_identity_repairs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_user_id INTEGER NOT NULL, + before_json TEXT NOT NULL, + after_json TEXT NOT NULL, + repaired_at TEXT NOT NULL, + repaired_by TEXT NOT NULL + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS request_repairs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/app/routers/identities.py b/backend/app/routers/identities.py index aff0e06..2aa1a08 100644 --- a/backend/app/routers/identities.py +++ b/backend/app/routers/identities.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Response from pydantic import BaseModel, ConfigDict, Field, field_validator from ..auth import require_admin -from ..services.identity_review import confirm_identities, review_identities, resolve_identity +from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)]) @@ -53,3 +53,23 @@ async def check_resolution(payload: Resolution, response: Response): async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)): response.headers["Cache-Control"] = "no-store" return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin) + + +class RepairResolution(Resolution): + create_seerr: bool = Field(default=False, strict=True) + + +class RepairConfirmation(RepairResolution): + revision: str = Field(pattern=r'^[a-f0-9]{64}$') + + +@router.post('/repair/check') +async def check_repair(payload: RepairResolution, response: Response): + response.headers['Cache-Control'] = 'no-store' + return await repair_identity(payload.user_id, payload.jellyfin_user_id, create_seerr=payload.create_seerr) + + +@router.post('/repair/confirm') +async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)): + response.headers['Cache-Control'] = 'no-store' + return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr) diff --git a/backend/app/services/identity_review.py b/backend/app/services/identity_review.py index 001636f..839bc38 100644 --- a/backend/app/services/identity_review.py +++ b/backend/app/services/identity_review.py @@ -1,10 +1,13 @@ """Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates.""" import asyncio +import copy import hashlib import json import re import sqlite3 + +import httpx from collections import defaultdict from contextlib import closing from datetime import datetime, timezone @@ -116,8 +119,17 @@ async def seerr_directory(runtime): return {"state": "unavailable", "users": []} -def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None): +def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False): + original = local selections = selections or {} + if repair: + local = copy.deepcopy(local) + for user in local['users']: + if user['id'] in selections: + user['jellyseerr_user_id'] = None + local['links'] = [link for link in local['links'] if not ( + link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))] + local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections] if any(user_id not in {user['id'] for user in local['users']} for user_id in selections): raise HTTPException(404, "This Magent account no longer exists. Run the check again.") jf_by_id = {row["id"]: row for row in jellyfin["users"]} @@ -158,6 +170,10 @@ def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None): 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 repair and user['id'] in selections and any( + item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id') + for item in original['confirmations']): + issues.append('The Jellyfin server changed. A server migration requires separate review.') if user["id"] in selections: chosen = selections[user["id"]] if saved and chosen != saved["jellyfin_user_id"]: @@ -225,16 +241,16 @@ def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None): "not_checked" if not jellystat else "unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"} report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream, - "jellyfin_users": jellyfin["users"], + "jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"], "counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]), "jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()), **{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}} - report["revision"] = digest([report, digest(local), config_digest(runtime)]) + report["revision"] = digest([report, digest(original), config_digest(runtime), repair]) report["checked_at"] = datetime.now(timezone.utc).isoformat() return report -async def review_identities(selections=None): +async def review_identities(selections=None, repair=False): 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: @@ -247,10 +263,10 @@ async def review_identities(selections=None): 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, selections), local, runtime + return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime -def save_confirmations(report, local, runtime, user_ids, admin): +def save_confirmations(report, local, runtime, user_ids, admin, repair=False): 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.") @@ -274,6 +290,20 @@ def save_confirmations(report, local, runtime, user_ids, admin): 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"])) + if repair: + before_user = next(user for user in local['users'] if user['id'] == user_id) + before = {'seerr_user_id': before_user['jellyseerr_user_id'], + 'links': [link for link in local['links'] if link['local_user_id'] == user_id], + 'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)} + conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?, + jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""", + (report['server_id'], jf_id, source_key(runtime.jellyfin_base_url), + source_key(runtime.jellyseerr_base_url), seerr_id, user_id)) + conn.execute("""INSERT INTO user_identity_repairs + (local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""", + (user_id, json.dumps(before, sort_keys=True), json.dumps({ + 'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id, + 'seerr_user_id': seerr_id}, sort_keys=True), 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} @@ -294,3 +324,46 @@ async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None) return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin) return {"revision": report["revision"], "server_id": report["server_id"], "row": next(row for row in report["rows"] if row["user"]["id"] == user_id)} + + +async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False): + report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True) + row = next(row for row in report['rows'] if row['user']['id'] == user_id) + importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin'] + and not row['seerr'] and row['jellystat']['state'] == 'matched' + and report['services']['seerr'] == 'available') + if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name']) + for account in report['seerr_users']): + importing = False + row['issues'].append('A Seerr account already has this name. Review its existing link before importing.') + report['revision'] = digest([report['revision'], create_seerr]) + if revision is not None: + if report['revision'] != revision: + raise HTTPException(409, 'The repair preview changed. Check the selected account again.') + if importing: + if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime): + raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.') + client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + try: + await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]}) + except (httpx.HTTPError, ValueError) as exc: + raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc + # Upstream and SQLite cannot share a transaction. Reconcile using live IDs; + # never delete an imported account if the local save is blocked or interrupted. + refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True) + if config_digest(fresh_runtime) != config_digest(runtime): + raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.') + try: + return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True) + except HTTPException as exc: + raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc + return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True) + before = next(user for user in local['users'] if user['id'] == user_id) + linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id + and link['source'] == source_key(runtime.jellyfin_base_url)), None) + row['can_confirm'] = row['can_confirm'] or importing + return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row, + 'action': 'import_seerr' if importing else 'repair_magent', + 'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']}, + 'seerr_users': report['seerr_users'], + 'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')} diff --git a/backend/tests/test_identity_review.py b/backend/tests/test_identity_review.py index eade466..2d24a90 100644 --- a/backend/tests/test_identity_review.py +++ b/backend/tests/test_identity_review.py @@ -99,6 +99,139 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN) self.assertEqual(result['confirmed'], 1) + async def test_repair_replaces_wrong_local_link_and_records_before_after(self): + link_user('Georgia', OTHER, self.runtime.jellyfin_base_url) + db.set_user_jellyseerr_id('Georgia', 999) + before = review.read_snapshot() + report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True) + self.assertTrue(self.row(report)['can_confirm']) + self.assertEqual(review.read_snapshot(), before) + review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True) + self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF) + self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20) + with closing(db._connect()) as conn: + audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone() + self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999) + self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF) + self.assertEqual(audit[2], 'admin') + + async def test_repair_preserves_duplicate_ownership_and_server_guards(self): + db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20) + local = review.read_snapshot() + report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True) + self.assertFalse(self.row(report)['can_confirm']) + with self.assertRaises(HTTPException): + review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True) + with closing(db._connect()) as conn, conn: + conn.execute('DELETE FROM users WHERE username=?', ('Owner',)) + report, local = self.build() + review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN) + self.jf['server_id'] = OTHER + report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True) + self.assertFalse(self.row(report)['can_confirm']) + + async def test_repair_does_not_invent_missing_seerr_identity(self): + self.seerr['users'][0]['jellyfin_id'] = OTHER + local = review.read_snapshot() + report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True) + self.assertEqual(self.row(report)['state'], 'unlinked') + with self.assertRaises(HTTPException): + review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True) + self.assertEqual(local, review.read_snapshot()) + + async def test_repair_audit_failure_rolls_back_links(self): + link_user('Georgia', OTHER, self.runtime.jellyfin_base_url) + with closing(db._connect()) as conn, conn: + conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END") + local = review.read_snapshot() + report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True) + with self.assertRaises(HTTPException): + review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True) + self.assertEqual(local, review.read_snapshot()) + + async def test_repair_rechecks_revision_and_updates_confirmed_ids(self): + report, local = self.build() + review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN) + self.jf['users'][0]['id'] = OTHER + self.seerr['users'][0]['jellyfin_id'] = OTHER + self.js = {OTHER: {'state': 'matched', 'id': OTHER}} + with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \ + patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \ + patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js): + preview = await review.repair_identity(self.user_id, OTHER) + with self.assertRaises(HTTPException): + await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN) + await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN) + self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER) + + async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self): + self.seerr['users'] = [] + async def imported(*args, **kwargs): + self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}] + return [] + with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \ + patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \ + patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \ + patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post: + before = review.read_snapshot() + blocked = await review.repair_identity(self.user_id, JF) + self.assertFalse(blocked['row']['can_confirm']) + preview = await review.repair_identity(self.user_id, JF, create_seerr=True) + self.assertEqual(preview['action'], 'import_seerr') + self.assertTrue(preview['row']['can_confirm']) + post.assert_not_called() + self.assertEqual(review.read_snapshot(), before) + with self.assertRaises(HTTPException): + await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False) + post.assert_not_called() + await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True) + post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]}) + self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25) + + async def test_failed_import_never_writes_local_links_or_retries(self): + self.seerr['users'] = [] + with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \ + patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \ + patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \ + patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post: + before = review.read_snapshot() + preview = await review.repair_identity(self.user_id, JF, create_seerr=True) + with self.assertRaises(HTTPException) as error: + await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True) + self.assertEqual(error.exception.status_code, 502) + self.assertEqual(post.await_count, 1) + self.assertEqual(review.read_snapshot(), before) + + async def test_import_preserves_upstream_account_when_local_save_is_blocked(self): + self.seerr['users'] = [] + async def imported(*args, **kwargs): + self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}] + db.set_user_jellyseerr_id('Georgia', 999) + with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \ + patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \ + patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \ + patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \ + patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete: + preview = await review.repair_identity(self.user_id, JF, create_seerr=True) + with self.assertRaises(HTTPException) as error: + await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True) + self.assertIn('Seerr import completed', error.exception.detail) + self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999) + self.assertEqual(review.read_snapshot()['confirmations'], []) + delete.assert_not_called() + + async def test_import_blocks_existing_name_with_different_jellyfin_id(self): + self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}] + with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \ + patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \ + patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \ + patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post: + preview = await review.repair_identity(self.user_id, JF, create_seerr=True) + self.assertFalse(preview['row']['can_confirm']) + with self.assertRaises(HTTPException): + await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True) + post.assert_not_called() + async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self): before = review.read_snapshot() report, _ = self.build() @@ -319,8 +452,10 @@ class IdentityRouteTests(unittest.TestCase): if endpoint == 'confirm': body['revision'] = 'a' * 64 for role, status in [(None, 401), ('user', 403)]: self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status) + self.assertEqual(self.client(role).post('/admin/identities/repair/' + endpoint, json=body).status_code, status) for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]: self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422) + self.assertEqual(self.client('admin').post('/admin/identities/repair/' + endpoint, json={**body, **invalid}).status_code, 422) with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}): result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF}) self.assertEqual(result.headers['cache-control'], 'no-store') diff --git a/docs/user-identities.md b/docs/user-identities.md index 7d9c846..9716dc5 100644 --- a/docs/user-identities.md +++ b/docs/user-identities.md @@ -32,3 +32,28 @@ audit record. A changed preview must be checked again. Existing confirmed or conflicting stored identities cannot be replaced using this flow. Missing upstream records must be corrected in their service before confirmation is available. No accounts are created, merged or deleted; emails are not used to infer identity. + + +### User Management and repairs + +Identity checks now live at **Config > User management > Account links & repairs**. +The old `/admin/identities` link redirects there. Choose **Review repair** on a +missing or conflicting account, select the authoritative Jellyfin identity, and +preview the current and proposed Magent links. Saving rechecks live service IDs, +all local owners, the server identity and concurrent changes. Repairs retain an +atomic before/after audit in `user_identity_repairs`. Changing a Jellyfin identity +revokes identity-bound email subscriptions; users must opt in again. + +If a person has never had a Seerr account, explicitly choose the single-account +import option and preview again. Confirmation imports only that Jellyfin ID via +Seerr's supported API and rechecks its resulting Seerr ID before saving Magent. +Seerr and Magent cannot share a transaction: if an import succeeds but the local +save fails, the imported account is retained and the administrator must recheck. +No automatic deletion or rollback of upstream accounts is attempted. + +For an existing Seerr account with a different Jellyfin ID, inspect its ID in the +preview and reconnect that existing account in Seerr using the account owner's +Jellyfin sign-in. Magent cannot rewrite Seerr's Jellyfin ID through the normal +admin user-update endpoint. Do not import another account to bypass a mismatch. +Duplicate Magent owners remain blocked until the ownership conflict is resolved; +this workflow does not merge users, permissions, requests or playback history. diff --git a/frontend/app/admin/configNavigation.ts b/frontend/app/admin/configNavigation.ts index 4fc6357..3141706 100644 --- a/frontend/app/admin/configNavigation.ts +++ b/frontend/app/admin/configNavigation.ts @@ -19,8 +19,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [ { href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' }, { href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' }, { href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' }, - { href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' }, - { href: '/admin/identities', label: 'User identities', description: 'Review and confirm IDs across your media services', symbol: 'ID' }, + { href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' }, { href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' }, ]}, { title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [ diff --git a/frontend/app/admin/identities/IdentityReviewPanel.tsx b/frontend/app/admin/identities/IdentityReviewPanel.tsx new file mode 100644 index 0000000..2e1ded8 --- /dev/null +++ b/frontend/app/admin/identities/IdentityReviewPanel.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { authFetch, getApiBase } from '../../lib/auth' +import './identities.css' +import ResolveIdentityLink from './ResolveIdentityLink' + +type Identity = { id: string; name: string } +export type Row = { + user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null } + jellyfin: Identity | null + candidate_jellyfin_id: string | null + 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 + jellyfin_users: Identity[] + 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 IdentityReviewPanel() { + 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 [resolving, setResolving] = useState(null) + 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 ( +
+ {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. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.

+ +
+ {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' || row.state === 'conflict') &&

Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.

} + {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}

  • )}
} + } + } + {resolving && report && setResolving(null)} onSaved={() => { + setResolving(null); setReport(null); setSelected([]); setReviewing(false) + setNotice('Account links repaired and saved. Run another check to see the updated mappings.') + }} />} +
+ ) +} diff --git a/frontend/app/admin/identities/ResolveIdentityLink.tsx b/frontend/app/admin/identities/ResolveIdentityLink.tsx index a8e8f75..0aa62e2 100644 --- a/frontend/app/admin/identities/ResolveIdentityLink.tsx +++ b/frontend/app/admin/identities/ResolveIdentityLink.tsx @@ -2,16 +2,24 @@ import { useEffect, useRef, useState } from 'react' import { authFetch, getApiBase } from '../../lib/auth' -import type { Row } from './page' +import type { Row } from './IdentityReviewPanel' -type Preview = { revision: string; server_id: string; row: Row } +type Preview = { + revision: string; server_id: string; row: Row + before: { jellyfin_user_id: string | null; seerr_user_id: number | null } + seerr_users: { id: number; name: string; jellyfin_id: string | null }[] + scope: string + action: string +} export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: { row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void }) { const dialog = useRef(null) const controller = useRef(null) - const [chosen, setChosen] = useState('') + const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '') + const [inspectSeerr, setInspectSeerr] = useState('') + const [createSeerr, setCreateSeerr] = useState(false) const [preview, setPreview] = useState(null) const [busy, setBusy] = useState(false) const [saving, setSaving] = useState(false) @@ -37,9 +45,9 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: if (confirm) setSaving(true) else { setBusy(true); setPreview(null) } try { - const response = await authFetch(`${getApiBase()}/admin/identities/resolve/${confirm ? 'confirm' : 'check'}`, { + const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, { method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, ...(confirm ? { revision: preview?.revision } : {}) }), + body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }), }) const data = await response.json().catch(() => ({})) if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.') @@ -57,17 +65,22 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: return { event.preventDefault(); if (!saving) onClose() }}>
-

Resolve missing link

-

Link {row.user.username} (Magent {row.user.id}) to their Jellyfin account. Review the IDs below to confirm this is the same person.

+

Review account repair

+

Review {row.user.username} (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.

- + + {error &&

{error}

} {busy &&

Checking live IDs and whether another Magent account already owns this identity.

} {preview &&
-

{preview.row.can_confirm ? 'Ready to confirm' : 'This link needs attention'}

+

{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}

Jellyfin server {preview.server_id ?? 'Unavailable'}

+
+
Current Magent links

Jellyfin: {preview.before.jellyfin_user_id ?? 'Not linked'}

Seerr: {preview.before.seerr_user_id ?? 'Not linked'}

+
Proposed Magent links

Jellyfin: {preview.row.candidate_jellyfin_id}

Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}

+
Jellyfin
{preview.row.jellyfin?.name ?? 'Account not found'}{preview.row.candidate_jellyfin_id}
Seerr
{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}
@@ -75,8 +88,18 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
{preview.row.issues.length > 0 &&
    {preview.row.issues.map((issue) =>
  • {issue}
  • )}
} {preview.row.state === 'unavailable' &&

A required service is unavailable. Restore its connection and check again.

} -

Saving stores the verified Jellyfin and Seerr links in Magent, with your administrator name and confirmation time. All platform IDs and duplicate ownership are checked again before saving.

- + {preview.row.seerr.length !== 1 &&
+

Check the existing Seerr account

+

Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.

+ + {preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) =>

Current Jellyfin ID: {account.jellyfin_id ?? 'Not linked'}

)} +

If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.

+

If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.

+
} +

{preview.scope}

+

Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.

+ {preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id &&

Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.

} +
}
diff --git a/frontend/app/admin/identities/identities.css b/frontend/app/admin/identities/identities.css index 6a48dfd..02bf052 100644 --- a/frontend/app/admin/identities/identities.css +++ b/frontend/app/admin/identities/identities.css @@ -71,3 +71,8 @@ .identity-counts strong { font-size: 1.4rem; } .identity-selection button { width: 100%; } } + +.identity-upstream-guidance { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ops-line); } + +.identity-import-option > span { display: flex; align-items: flex-start; gap: 10px; line-height: 1.6; } +.identity-import-option input[type=checkbox] { flex: 0 0 20px; margin: 3px 0 0; } diff --git a/frontend/app/admin/identities/page.tsx b/frontend/app/admin/identities/page.tsx index b74e71e..452c4c6 100644 --- a/frontend/app/admin/identities/page.tsx +++ b/frontend/app/admin/identities/page.tsx @@ -1,168 +1,5 @@ -'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' -import ResolveIdentityLink from './ResolveIdentityLink' - -type Identity = { id: string; name: string } -export type Row = { - user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null } - jellyfin: Identity | null - candidate_jellyfin_id: string | null - 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 - jellyfin_users: Identity[] - 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' } +import { redirect } from 'next/navigation' 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 [resolving, setResolving] = useState(null) - 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' &&

Choose the correct Jellyfin account and check its Seerr and Jellystat links before saving.

} - {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}

  • )}
} - } - } - {resolving && report && setResolving(null)} onSaved={() => { - setResolving(null); setReport(null); setSelected([]); setReviewing(false) - setNotice('Account links confirmed and saved. Run another check to see the updated mappings.') - }} />} -
-
+ redirect('/users?view=identities') } diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index 43e2c49..1b64379 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import AdminShell from '../ui/AdminShell' import './users.css' +import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel' type AdminUser = { id: number @@ -82,6 +83,17 @@ const normalizeStats = (stats: any): UserStats => ({ export default function UsersPage() { const router = useRouter() + const [view, setView] = useState('directory') + useEffect(() => { + const update = () => setView(new URLSearchParams(window.location.search).get('view') === 'identities' ? 'identities' : 'directory') + update() + window.addEventListener('popstate', update) + return () => window.removeEventListener('popstate', update) + }, []) + const changeView = (next: string) => { + setView(next) + window.history.pushState(null, '', next === 'identities' ? '/users?view=identities' : '/users') + } const [controlsOpen, setControlsOpen] = useState(false) const controlsDialog = useRef(null) const controlsTrigger = useRef(null) @@ -350,8 +362,8 @@ export default function UsersPage() { return ( setControlsOpen(true)}>Manage users} > setControlsOpen(false)} onClose={() => setControlsOpen(false)}> @@ -362,7 +374,7 @@ export default function UsersPage() { {inviteStatus &&

{inviteStatus}

}

Directory actions

Review linked accounts, manage invitations or refresh the list.

-
Review account links ↗

Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.

+
Review account links ↗

Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.

Manage invitations ↗

Create invitations, review issued links and set invitation defaults.

Reload account status and request totals from Magent. Your search stays in place.

@@ -376,7 +388,11 @@ export default function UsersPage() {
Directory totals{usersRail}
-
+ + {view === 'identities' ? :
{!controlsOpen && error &&

{error}

} {!controlsOpen && jellyseerrSyncStatus &&

{jellyseerrSyncStatus}

} {!controlsOpen && inviteStatus &&

{inviteStatus}

} @@ -472,7 +488,7 @@ export default function UsersPage() { ))} )} -
+
}
) }