Unify user management and add reviewed identity repairs
This commit is contained in:
@@ -206,6 +206,16 @@ def init_db() -> None:
|
|||||||
UNIQUE (jellyfin_server_id, jellyfin_user_id)
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS request_repairs (
|
CREATE TABLE IF NOT EXISTS request_repairs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Response
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from ..auth import require_admin
|
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)])
|
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)):
|
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
|
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)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
|
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
import httpx
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -116,8 +119,17 @@ async def seerr_directory(runtime):
|
|||||||
return {"state": "unavailable", "users": []}
|
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 {}
|
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):
|
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.")
|
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
|
||||||
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
|
jf_by_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"
|
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
|
||||||
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
|
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
|
||||||
candidate, basis = by_name[0], "suggested_username"
|
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:
|
if user["id"] in selections:
|
||||||
chosen = selections[user["id"]]
|
chosen = selections[user["id"]]
|
||||||
if saved and chosen != saved["jellyfin_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
|
"not_checked" if not jellystat else
|
||||||
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
|
"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,
|
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"]),
|
"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()),
|
"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")}}}
|
**{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()
|
report["checked_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
async def review_identities(selections=None):
|
async def review_identities(selections=None, repair=False):
|
||||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
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))
|
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
|
||||||
if len(local["users"]) > MAX_USERS:
|
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.")
|
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
|
||||||
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
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}
|
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"]}
|
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):
|
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.")
|
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""",
|
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),
|
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
|
||||||
seerr_id, now, admin["username"]))
|
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:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from 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}
|
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 await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
|
||||||
return {"revision": report["revision"], "server_id": report["server_id"],
|
return {"revision": report["revision"], "server_id": report["server_id"],
|
||||||
"row": next(row for row in report["rows"] if row["user"]["id"] == user_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.')}
|
||||||
|
|||||||
@@ -99,6 +99,139 @@ class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|||||||
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
|
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
|
||||||
self.assertEqual(result['confirmed'], 1)
|
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):
|
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
|
||||||
before = review.read_snapshot()
|
before = review.read_snapshot()
|
||||||
report, _ = self.build()
|
report, _ = self.build()
|
||||||
@@ -319,8 +452,10 @@ class IdentityRouteTests(unittest.TestCase):
|
|||||||
if endpoint == 'confirm': body['revision'] = 'a' * 64
|
if endpoint == 'confirm': body['revision'] = 'a' * 64
|
||||||
for role, status in [(None, 401), ('user', 403)]:
|
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/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}]:
|
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/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': {}}):
|
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})
|
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')
|
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||||
|
|||||||
@@ -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
|
conflicting stored identities cannot be replaced using this flow. Missing upstream
|
||||||
records must be corrected in their service before confirmation is available.
|
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.
|
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.
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
|||||||
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' },
|
{ 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/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: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
||||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' },
|
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' },
|
||||||
{ 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' },
|
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' },
|
||||||
]},
|
]},
|
||||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
||||||
|
|||||||
@@ -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<string, string>
|
||||||
|
counts: Record<string, number>
|
||||||
|
jellyfin_users: Identity[]
|
||||||
|
rows: Row[]
|
||||||
|
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
|
||||||
|
}
|
||||||
|
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
|
||||||
|
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
|
||||||
|
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerr’s Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
|
||||||
|
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
|
||||||
|
|
||||||
|
export default function IdentityReviewPanel() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [report, setReport] = useState<Report | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [selected, setSelected] = useState<number[]>([])
|
||||||
|
const [resolving, setResolving] = useState<Row | null>(null)
|
||||||
|
const [reviewing, setReviewing] = useState(false)
|
||||||
|
const controller = useRef<AbortController | null>(null)
|
||||||
|
const reviewPanel = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
|
||||||
|
if (response.status === 401) { router.replace('/login'); return }
|
||||||
|
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
|
||||||
|
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
|
||||||
|
if (!abort.signal.aborted) setReady(true)
|
||||||
|
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||||
|
return () => { abort.abort(); controller.current?.abort() }
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
|
||||||
|
|
||||||
|
const responseData = async (response: Response) => {
|
||||||
|
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
|
||||||
|
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCheck = async () => {
|
||||||
|
controller.current?.abort()
|
||||||
|
const abort = new AbortController()
|
||||||
|
controller.current = abort
|
||||||
|
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
|
||||||
|
try {
|
||||||
|
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
|
||||||
|
if (!abort.signal.aborted) setReport(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
|
||||||
|
} finally { if (!abort.signal.aborted) setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!report || saving || !selected.length) return
|
||||||
|
setSaving(true); setError(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
|
||||||
|
}))
|
||||||
|
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
|
||||||
|
// The scan describes the previous database state and cannot be reused for another write.
|
||||||
|
setReport(null); setSelected([]); setReviewing(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not save identity links.')
|
||||||
|
setReport(null); setSelected([]); setReviewing(false)
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
|
||||||
|
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
|
||||||
|
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
|
||||||
|
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
|
||||||
|
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="identity-review">
|
||||||
|
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||||
|
{notice && <p className="status-banner" role="status">{notice}</p>}
|
||||||
|
{!ready && !error && <p role="status">Checking administrator access…</p>}
|
||||||
|
{ready && <>
|
||||||
|
<section className="identity-intro admin-panel">
|
||||||
|
<div><h2>Confirm user IDs</h2><p>Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</p></div>
|
||||||
|
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
|
||||||
|
</section>
|
||||||
|
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
|
||||||
|
{report && <>
|
||||||
|
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
|
||||||
|
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
|
||||||
|
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
|
||||||
|
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
|
||||||
|
<div className="identity-filters">
|
||||||
|
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
|
||||||
|
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
<div className="identity-selection">
|
||||||
|
<span>{filtered.length} accounts shown · {selected.length} selected</span>
|
||||||
|
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
|
||||||
|
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
|
||||||
|
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
|
||||||
|
</div>
|
||||||
|
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
|
||||||
|
<h2>Save these {selected.length} account links?</h2>
|
||||||
|
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
|
||||||
|
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} → Jellyfin <code>{row.candidate_jellyfin_id}</code> → Seerr {row.seerr[0].id}</li>)}</ul>
|
||||||
|
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
|
||||||
|
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
|
||||||
|
</section>}
|
||||||
|
<section className="identity-accounts" aria-label="Account identity results">
|
||||||
|
{!filtered.length && <p>No accounts match these filters.</p>}
|
||||||
|
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
|
||||||
|
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
|
||||||
|
<dl className="identity-mapping">
|
||||||
|
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
|
||||||
|
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
|
||||||
|
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
||||||
|
</dl>
|
||||||
|
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||||
|
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button></div>}
|
||||||
|
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
||||||
|
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
||||||
|
</article>)}
|
||||||
|
</section>
|
||||||
|
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
|
||||||
|
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
|
||||||
|
setNotice('Account links repaired and saved. Run another check to see the updated mappings.')
|
||||||
|
}} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,16 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { authFetch, getApiBase } from '../../lib/auth'
|
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 }: {
|
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
|
||||||
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
|
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
const dialog = useRef<HTMLDialogElement>(null)
|
||||||
const controller = useRef<AbortController | null>(null)
|
const controller = useRef<AbortController | null>(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<Preview | null>(null)
|
const [preview, setPreview] = useState<Preview | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -37,9 +45,9 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
|
|||||||
if (confirm) setSaving(true)
|
if (confirm) setSaving(true)
|
||||||
else { setBusy(true); setPreview(null) }
|
else { setBusy(true); setPreview(null) }
|
||||||
try {
|
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' },
|
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(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
|
if (!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 <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
||||||
<div className="identity-resolve-content">
|
<div className="identity-resolve-content">
|
||||||
<header><h2 id="resolve-title">Resolve missing link</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
||||||
<p>Link <strong>{row.user.username}</strong> (Magent {row.user.id}) to their Jellyfin account. Review the IDs below to confirm this is the same person.</p>
|
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
|
||||||
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
||||||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setChosen(event.target.value)
|
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value)
|
||||||
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} — {account.id}</option>)}</select></label>
|
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} — {account.id}</option>)}</select></label>
|
||||||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Check selected account'}</button>
|
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
|
||||||
|
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button>
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||||
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
||||||
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
||||||
<h3>{preview.row.can_confirm ? 'Ready to confirm' : 'This link needs attention'}</h3>
|
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3>
|
||||||
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
||||||
|
<div className="identity-mapping">
|
||||||
|
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
|
||||||
|
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
|
||||||
|
</div>
|
||||||
<dl className="identity-mapping">
|
<dl className="identity-mapping">
|
||||||
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
|
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
|
||||||
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}</dd></div>
|
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}</dd></div>
|
||||||
@@ -75,8 +88,18 @@ export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }:
|
|||||||
</dl>
|
</dl>
|
||||||
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||||
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
|
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
|
||||||
<p>Saving stores the verified Jellyfin and Seerr links in Magent, with your administrator name and confirmation time. All platform IDs and duplicate ownership are checked again before saving.</p>
|
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
|
||||||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : 'Confirm and save link'}</button>
|
<h3>Check the existing Seerr account</h3>
|
||||||
|
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
|
||||||
|
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
|
||||||
|
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
|
||||||
|
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
|
||||||
|
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
|
||||||
|
</div>}
|
||||||
|
<p>{preview.scope}</p>
|
||||||
|
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
|
||||||
|
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
|
||||||
|
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
|
||||||
</section>}
|
</section>}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -71,3 +71,8 @@
|
|||||||
.identity-counts strong { font-size: 1.4rem; }
|
.identity-counts strong { font-size: 1.4rem; }
|
||||||
.identity-selection button { width: 100%; }
|
.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; }
|
||||||
|
|||||||
@@ -1,168 +1,5 @@
|
|||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
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<string, string>
|
|
||||||
counts: Record<string, number>
|
|
||||||
jellyfin_users: Identity[]
|
|
||||||
rows: Row[]
|
|
||||||
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
|
|
||||||
}
|
|
||||||
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
|
|
||||||
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
|
|
||||||
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerr’s Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
|
|
||||||
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
|
|
||||||
|
|
||||||
export default function IdentityReviewPage() {
|
export default function IdentityReviewPage() {
|
||||||
const router = useRouter()
|
redirect('/users?view=identities')
|
||||||
const [ready, setReady] = useState(false)
|
|
||||||
const [report, setReport] = useState<Report | null>(null)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [notice, setNotice] = useState('')
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [filter, setFilter] = useState('all')
|
|
||||||
const [selected, setSelected] = useState<number[]>([])
|
|
||||||
const [resolving, setResolving] = useState<Row | null>(null)
|
|
||||||
const [reviewing, setReviewing] = useState(false)
|
|
||||||
const controller = useRef<AbortController | null>(null)
|
|
||||||
const reviewPanel = useRef<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const abort = new AbortController()
|
|
||||||
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
|
|
||||||
if (response.status === 401) { router.replace('/login'); return }
|
|
||||||
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
|
|
||||||
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
|
|
||||||
if (!abort.signal.aborted) setReady(true)
|
|
||||||
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
|
||||||
return () => { abort.abort(); controller.current?.abort() }
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
|
|
||||||
|
|
||||||
const responseData = async (response: Response) => {
|
|
||||||
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
|
|
||||||
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const runCheck = async () => {
|
|
||||||
controller.current?.abort()
|
|
||||||
const abort = new AbortController()
|
|
||||||
controller.current = abort
|
|
||||||
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
|
|
||||||
try {
|
|
||||||
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
|
|
||||||
if (!abort.signal.aborted) setReport(data)
|
|
||||||
} catch (err) {
|
|
||||||
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
|
|
||||||
} finally { if (!abort.signal.aborted) setBusy(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!report || saving || !selected.length) return
|
|
||||||
setSaving(true); setError(''); setNotice('')
|
|
||||||
try {
|
|
||||||
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
|
|
||||||
}))
|
|
||||||
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
|
|
||||||
// The scan describes the previous database state and cannot be reused for another write.
|
|
||||||
setReport(null); setSelected([]); setReviewing(false)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not save identity links.')
|
|
||||||
setReport(null); setSelected([]); setReviewing(false)
|
|
||||||
} finally { setSaving(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const needle = query.trim().toLowerCase()
|
|
||||||
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
|
|
||||||
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
|
|
||||||
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
|
|
||||||
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
|
|
||||||
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
|
|
||||||
|
|
||||||
return <AdminShell title="User identities" subtitle="Review account links across Jellyfin, Seerr, Jellystat and Magent." actions={<a href="/users" className="ghost-button">Back to users</a>}>
|
|
||||||
<div className="identity-review">
|
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
|
||||||
{notice && <p className="status-banner" role="status">{notice}</p>}
|
|
||||||
{!ready && !error && <p role="status">Checking administrator access…</p>}
|
|
||||||
{ready && <>
|
|
||||||
<section className="identity-intro admin-panel">
|
|
||||||
<div><h2>Confirm user IDs</h2><p>Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Duplicate and conflicting accounts need individual investigation.</p></div>
|
|
||||||
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
|
|
||||||
</section>
|
|
||||||
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
|
|
||||||
{report && <>
|
|
||||||
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
|
|
||||||
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
|
|
||||||
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
|
|
||||||
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
|
|
||||||
<div className="identity-filters">
|
|
||||||
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
|
|
||||||
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
|
|
||||||
</div>
|
|
||||||
<div className="identity-selection">
|
|
||||||
<span>{filtered.length} accounts shown · {selected.length} selected</span>
|
|
||||||
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
|
|
||||||
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
|
|
||||||
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
|
|
||||||
</div>
|
|
||||||
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
|
|
||||||
<h2>Save these {selected.length} account links?</h2>
|
|
||||||
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
|
|
||||||
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} → Jellyfin <code>{row.candidate_jellyfin_id}</code> → Seerr {row.seerr[0].id}</li>)}</ul>
|
|
||||||
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
|
|
||||||
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
|
|
||||||
</section>}
|
|
||||||
<section className="identity-accounts" aria-label="Account identity results">
|
|
||||||
{!filtered.length && <p>No accounts match these filters.</p>}
|
|
||||||
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
|
|
||||||
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
|
|
||||||
<dl className="identity-mapping">
|
|
||||||
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
|
|
||||||
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
|
|
||||||
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
|
||||||
</dl>
|
|
||||||
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
|
||||||
{row.state === 'unlinked' && <div className="identity-resolution-entry"><p className="identity-meta">Choose the correct Jellyfin account and check its Seerr and Jellystat links before saving.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Resolve missing link</button></div>}
|
|
||||||
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
|
||||||
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
|
||||||
</article>)}
|
|
||||||
</section>
|
|
||||||
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
|
|
||||||
</>}
|
|
||||||
</>}
|
|
||||||
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
|
|
||||||
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
|
|
||||||
setNotice('Account links confirmed and saved. Run another check to see the updated mappings.')
|
|
||||||
}} />}
|
|
||||||
</div>
|
|
||||||
</AdminShell>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
|||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
import './users.css'
|
import './users.css'
|
||||||
|
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: number
|
id: number
|
||||||
@@ -82,6 +83,17 @@ const normalizeStats = (stats: any): UserStats => ({
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const router = useRouter()
|
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 [controlsOpen, setControlsOpen] = useState(false)
|
||||||
const controlsDialog = useRef<HTMLDialogElement>(null)
|
const controlsDialog = useRef<HTMLDialogElement>(null)
|
||||||
const controlsTrigger = useRef<HTMLButtonElement>(null)
|
const controlsTrigger = useRef<HTMLButtonElement>(null)
|
||||||
@@ -350,8 +362,8 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Users"
|
title="User management"
|
||||||
subtitle="Directory, access status, and request activity."
|
subtitle="Accounts, access, request activity and verified service links."
|
||||||
actions={<button ref={controlsTrigger} type="button" className="ghost-button" aria-haspopup="dialog" aria-expanded={controlsOpen} aria-controls="user-management-dialog" onClick={() => setControlsOpen(true)}>Manage users</button>}
|
actions={<button ref={controlsTrigger} type="button" className="ghost-button" aria-haspopup="dialog" aria-expanded={controlsOpen} aria-controls="user-management-dialog" onClick={() => setControlsOpen(true)}>Manage users</button>}
|
||||||
>
|
>
|
||||||
<dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
|
<dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
|
||||||
@@ -362,7 +374,7 @@ export default function UsersPage() {
|
|||||||
{inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
{inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||||
<div className="user-management-grid">
|
<div className="user-management-grid">
|
||||||
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
|
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
|
||||||
<div className="user-management-action"><Link className="ghost-button" href="/admin/identities" aria-describedby="identity-help">Review account links ↗</Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
|
<div className="user-management-action"><Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">Review account links ↗</Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
|
||||||
<div className="user-management-action"><Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">Manage invitations ↗</Link><p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p></div>
|
<div className="user-management-action"><Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">Manage invitations ↗</Link><p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p></div>
|
||||||
<div className="user-management-action"><button type="button" className="ghost-button" onClick={() => void loadUsers()} disabled={controlsBusy} aria-describedby="reload-help">{refreshing ? 'Refreshing…' : 'Refresh user list'}</button><p id="reload-help">Reload account status and request totals from Magent. Your search stays in place.</p></div>
|
<div className="user-management-action"><button type="button" className="ghost-button" onClick={() => void loadUsers()} disabled={controlsBusy} aria-describedby="reload-help">{refreshing ? 'Refreshing…' : 'Refresh user list'}</button><p id="reload-help">Reload account status and request totals from Magent. Your search stays in place.</p></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -376,7 +388,11 @@ export default function UsersPage() {
|
|||||||
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
|
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<section className="admin-section users-directory-centered">
|
<nav className="identity-selection" aria-label="User management sections">
|
||||||
|
<button type="button" className="ghost-button" aria-pressed={view === 'directory'} onClick={() => changeView('directory')}>User directory</button>
|
||||||
|
<button type="button" className="ghost-button" aria-pressed={view === 'identities'} onClick={() => changeView('identities')}>Account links & repairs</button>
|
||||||
|
</nav>
|
||||||
|
{view === 'identities' ? <IdentityReviewPanel /> : <section className="admin-section users-directory-centered">
|
||||||
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
|
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
|
||||||
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
|
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
|
||||||
{!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
{!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
|
||||||
@@ -472,7 +488,7 @@ export default function UsersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>}
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user