diff --git a/.build_number b/.build_number index 1ed1c55..4f3dbff 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602261536 +2602261605 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 582178f..79f59ff 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602261536" +BUILD_NUMBER = "2602261605" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index d918145..cb6273a 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -41,3 +41,14 @@ class ApiClient: if not response.content: return None return response.json() + + async def delete(self, path: str) -> Optional[Any]: + if not self.base_url: + return None + url = f"{self.base_url}{path}" + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.delete(url, headers=self.headers()) + response.raise_for_status() + if not response.content: + return None + return response.json() diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index f06ee93..d735b41 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -10,27 +10,158 @@ class JellyfinClient(ApiClient): def configured(self) -> bool: return bool(self.base_url and self.api_key) + def _emby_headers(self) -> Dict[str, str]: + return {"X-Emby-Token": self.api_key} if self.api_key else {} + + @staticmethod + def _extract_user_id(payload: Any) -> Optional[str]: + if not isinstance(payload, dict): + return None + candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload + if not isinstance(candidate, dict): + return None + for key in ("Id", "id", "UserId", "userId"): + value = candidate.get(key) + if value is None: + continue + if isinstance(value, (str, int)): + text = str(value).strip() + if text: + return text + return None + async def get_users(self) -> Optional[Dict[str, Any]]: if not self.base_url: return None url = f"{self.base_url}/Users" - headers = {"X-Emby-Token": self.api_key} if self.api_key else {} + headers = self._emby_headers() async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url, headers=headers) response.raise_for_status() return response.json() + async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]: + if not self.base_url or not self.api_key: + return None + url = f"{self.base_url}/Users/{user_id}" + headers = self._emby_headers() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + + async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]: + users = await self.get_users() + if not isinstance(users, list): + return None + target = username.strip().lower() + for user in users: + if not isinstance(user, dict): + continue + name = str(user.get("Name") or "").strip().lower() + if name and name == target: + return user + return None + async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]: if not self.base_url: return None url = f"{self.base_url}/Users/AuthenticateByName" - headers = {"X-Emby-Token": self.api_key} if self.api_key else {} + headers = self._emby_headers() payload = {"Username": username, "Pw": password} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() return response.json() + async def create_user(self, username: str) -> Optional[Dict[str, Any]]: + if not self.base_url or not self.api_key: + return None + url = f"{self.base_url}/Users/New" + headers = self._emby_headers() + payload = {"Name": username} + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + if not response.content: + return None + return response.json() + + async def set_user_password(self, user_id: str, password: str) -> None: + if not self.base_url or not self.api_key: + return None + headers = self._emby_headers() + payloads = [ + {"CurrentPw": "", "NewPw": password}, + {"CurrentPwd": "", "NewPw": password}, + {"CurrentPw": "", "NewPw": password, "ResetPassword": False}, + {"CurrentPwd": "", "NewPw": password, "ResetPassword": False}, + {"NewPw": password, "ResetPassword": False}, + ] + paths = [ + f"/Users/{user_id}/Password", + f"/Users/{user_id}/EasyPassword", + ] + last_error: Exception | None = None + async with httpx.AsyncClient(timeout=10.0) as client: + for path in paths: + url = f"{self.base_url}{path}" + for payload in payloads: + try: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + return + except httpx.HTTPStatusError as exc: + last_error = exc + continue + except Exception as exc: + last_error = exc + continue + if last_error: + raise last_error + + async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None: + if not self.base_url or not self.api_key: + return None + user = await self.get_user(user_id) + if not isinstance(user, dict): + raise RuntimeError("Jellyfin user details not available") + policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {} + payload = {**policy, "IsDisabled": bool(disabled)} + url = f"{self.base_url}/Users/{user_id}/Policy" + headers = self._emby_headers() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + + async def delete_user(self, user_id: str) -> None: + if not self.base_url or not self.api_key: + return None + url = f"{self.base_url}/Users/{user_id}" + headers = self._emby_headers() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.delete(url, headers=headers) + response.raise_for_status() + + async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]: + created = await self.create_user(username) + user_id = self._extract_user_id(created) + if not user_id: + users = await self.get_users() + if isinstance(users, list): + for user in users: + if not isinstance(user, dict): + continue + name = str(user.get("Name") or "").strip() + if name.lower() == username.strip().lower(): + created = user + user_id = self._extract_user_id(user) + break + if not user_id: + raise RuntimeError("Jellyfin user created but user ID was not returned") + await self.set_user_password(user_id, password) + return created + async def search_items( self, term: str, item_types: Optional[list[str]] = None, limit: int = 20 ) -> Optional[Dict[str, Any]]: @@ -43,7 +174,7 @@ class JellyfinClient(ApiClient): "Recursive": "true", "Limit": limit, } - headers = {"X-Emby-Token": self.api_key} + headers = self._emby_headers() async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url, headers=headers, params=params) response.raise_for_status() @@ -53,7 +184,7 @@ class JellyfinClient(ApiClient): if not self.base_url or not self.api_key: return None url = f"{self.base_url}/System/Info" - headers = {"X-Emby-Token": self.api_key} + headers = self._emby_headers() async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(url, headers=headers) response.raise_for_status() @@ -63,7 +194,7 @@ class JellyfinClient(ApiClient): if not self.base_url or not self.api_key: return None url = f"{self.base_url}/Library/Refresh" - headers = {"X-Emby-Token": self.api_key} + headers = self._emby_headers() params = {"Recursive": "true" if recursive else "false"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, headers=headers, params=params) diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py index b71df9d..7c75011 100644 --- a/backend/app/clients/jellyseerr.py +++ b/backend/app/clients/jellyseerr.py @@ -44,3 +44,9 @@ class JellyseerrClient(ApiClient): "skip": skip, }, ) + + async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]: + return await self.get(f"/api/v1/user/{user_id}") + + async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]: + return await self.delete(f"/api/v1/user/{user_id}") diff --git a/backend/app/db.py b/backend/app/db.py index 2caf00a..32d66ad 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -732,6 +732,42 @@ def set_user_blocked(username: str, blocked: bool) -> None: ) +def delete_user_by_username(username: str) -> bool: + with _connect() as conn: + cursor = conn.execute( + """ + DELETE FROM users WHERE username = ? COLLATE NOCASE + """, + (username,), + ) + return cursor.rowcount > 0 + + +def delete_user_activity_by_username(username: str) -> int: + with _connect() as conn: + cursor = conn.execute( + """ + DELETE FROM user_activity WHERE username = ? COLLATE NOCASE + """, + (username,), + ) + return cursor.rowcount + + +def disable_signup_invites_by_creator(username: str) -> int: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + UPDATE signup_invites + SET enabled = 0, updated_at = ? + WHERE created_by = ? COLLATE NOCASE AND enabled != 0 + """, + (timestamp, username), + ) + return cursor.rowcount + + def set_user_role(username: str, role: str) -> None: with _connect() as conn: conn.execute( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 52d0038..6f180ef 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -30,6 +30,8 @@ from ..db import ( set_user_jellyseerr_id, set_setting, set_user_blocked, + delete_user_by_username, + delete_user_activity_by_username, set_user_auto_search_enabled, set_auto_search_enabled_for_non_admin_users, set_user_profile_id, @@ -55,6 +57,8 @@ from ..db import ( create_signup_invite, update_signup_invite, delete_signup_invite, + get_signup_invite_by_code, + disable_signup_invites_by_creator, ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient @@ -137,6 +141,136 @@ SETTING_KEYS: List[str] = [ ] +def _http_error_detail(exc: Exception) -> str: + try: + import httpx # local import to avoid hard dependency in static analysis paths + + if isinstance(exc, httpx.HTTPStatusError): + response = exc.response + body = "" + try: + body = response.text.strip() + except Exception: + body = "" + if body: + return f"HTTP {response.status_code}: {body}" + return f"HTTP {response.status_code}" + except Exception: + pass + return str(exc) + + +def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not user: + return None + invite_code = user.get("invited_by_code") + if not invite_code: + return None + invite = get_signup_invite_by_code(str(invite_code)) + if not invite: + return { + "invite_code": invite_code, + "invited_by": None, + "invite": None, + } + return { + "invite_code": invite.get("code"), + "invited_by": invite.get("created_by"), + "invite": { + "id": invite.get("id"), + "code": invite.get("code"), + "label": invite.get("label"), + "created_by": invite.get("created_by"), + "created_at": invite.get("created_at"), + "enabled": invite.get("enabled"), + "is_usable": invite.get("is_usable"), + }, + } + + +def _build_invite_trace_payload() -> Dict[str, Any]: + users = get_all_users() + invites = list_signup_invites() + usernames = {str(user.get("username") or "") for user in users} + + nodes: list[Dict[str, Any]] = [] + edges: list[Dict[str, Any]] = [] + + for user in users: + username = str(user.get("username") or "") + inviter = _user_inviter_details(user) + nodes.append( + { + "id": f"user:{username}", + "type": "user", + "username": username, + "label": username, + "role": user.get("role"), + "auth_provider": user.get("auth_provider"), + "created_at": user.get("created_at"), + "invited_by_code": user.get("invited_by_code"), + "invited_by": inviter.get("invited_by") if inviter else None, + } + ) + + invite_codes = set() + for invite in invites: + code = str(invite.get("code") or "") + if not code: + continue + invite_codes.add(code) + nodes.append( + { + "id": f"invite:{code}", + "type": "invite", + "code": code, + "label": invite.get("label") or code, + "created_by": invite.get("created_by"), + "enabled": invite.get("enabled"), + "use_count": invite.get("use_count"), + "remaining_uses": invite.get("remaining_uses"), + "created_at": invite.get("created_at"), + } + ) + created_by = invite.get("created_by") + if isinstance(created_by, str) and created_by.strip(): + edges.append( + { + "id": f"user:{created_by}->invite:{code}", + "from": f"user:{created_by}", + "to": f"invite:{code}", + "kind": "created", + "label": "created", + "from_missing": created_by not in usernames, + } + ) + + for user in users: + username = str(user.get("username") or "") + invited_by_code = user.get("invited_by_code") + if not isinstance(invited_by_code, str) or not invited_by_code.strip(): + continue + code = invited_by_code.strip() + edges.append( + { + "id": f"invite:{code}->user:{username}", + "from": f"invite:{code}", + "to": f"user:{username}", + "kind": "invited", + "label": code, + "from_missing": code not in invite_codes, + } + ) + + return { + "users": users, + "invites": invites, + "nodes": nodes, + "edges": edges, + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + def _admin_live_state_snapshot() -> Dict[str, Any]: return { "type": "admin_live_state", @@ -835,7 +969,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]: raise HTTPException(status_code=404, detail="User not found") username_norm = _normalize_username(user.get("username") or "") stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) - return {"user": user, "stats": stats} + return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)} @router.get("/users/id/{user_id}") @@ -845,7 +979,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]: raise HTTPException(status_code=404, detail="User not found") username_norm = _normalize_username(user.get("username") or "") stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) - return {"user": user, "stats": stats} + return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)} @router.post("/users/{username}/block") @@ -860,6 +994,98 @@ async def unblock_user(username: str) -> Dict[str, Any]: return {"status": "ok", "username": username, "blocked": False} +@router.post("/users/{username}/system-action") +async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + action = str(payload.get("action") or "").strip().lower() + if action not in {"ban", "unban", "remove"}: + raise HTTPException(status_code=400, detail="action must be ban, unban, or remove") + + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.get("role") == "admin": + raise HTTPException(status_code=400, detail="Cross-system actions are not allowed for admin users") + + runtime = get_runtime_settings() + jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + result: Dict[str, Any] = { + "status": "ok", + "action": action, + "username": user.get("username"), + "local": {"status": "pending"}, + "jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"}, + "jellyseerr": {"status": "skipped", "detail": "Jellyseerr not configured or no linked user ID"}, + "invites": {"status": "pending", "disabled": 0}, + } + + if action == "ban": + set_user_blocked(username, True) + result["local"] = {"status": "ok", "blocked": True} + elif action == "unban": + set_user_blocked(username, False) + result["local"] = {"status": "ok", "blocked": False} + else: + result["local"] = {"status": "pending-delete"} + + if action in {"ban", "remove"}: + result["invites"] = {"status": "ok", "disabled": disable_signup_invites_by_creator(username)} + else: + result["invites"] = {"status": "ok", "disabled": 0} + + if jellyfin.configured(): + try: + jellyfin_user = await jellyfin.find_user_by_name(username) + if not jellyfin_user: + result["jellyfin"] = {"status": "not_found"} + else: + jellyfin_user_id = jellyfin._extract_user_id(jellyfin_user) # type: ignore[attr-defined] + if not jellyfin_user_id: + raise RuntimeError("Could not determine Jellyfin user ID") + if action == "ban": + await jellyfin.set_user_disabled(jellyfin_user_id, True) + result["jellyfin"] = {"status": "ok", "action": "disabled", "user_id": jellyfin_user_id} + elif action == "unban": + await jellyfin.set_user_disabled(jellyfin_user_id, False) + result["jellyfin"] = {"status": "ok", "action": "enabled", "user_id": jellyfin_user_id} + else: + await jellyfin.delete_user(jellyfin_user_id) + result["jellyfin"] = {"status": "ok", "action": "deleted", "user_id": jellyfin_user_id} + except Exception as exc: + result["jellyfin"] = {"status": "error", "detail": _http_error_detail(exc)} + + jellyseerr_user_id = user.get("jellyseerr_user_id") + if jellyseerr.configured() and jellyseerr_user_id is not None: + try: + if action == "remove": + await jellyseerr.delete_user(int(jellyseerr_user_id)) + result["jellyseerr"] = {"status": "ok", "action": "deleted", "user_id": int(jellyseerr_user_id)} + elif action == "ban": + result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-disable", "user_id": int(jellyseerr_user_id)} + else: + result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-enable", "user_id": int(jellyseerr_user_id)} + except Exception as exc: + result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)} + + if action == "remove": + deleted = delete_user_by_username(username) + activity_deleted = delete_user_activity_by_username(username) + result["local"] = { + "status": "ok" if deleted else "not_found", + "deleted": bool(deleted), + "activity_deleted": activity_deleted, + } + + if any( + isinstance(system, dict) and system.get("status") == "error" + for system in (result.get("jellyfin"), result.get("jellyseerr")) + ): + result["status"] = "partial" + return result + + @router.post("/users/{username}/role") async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: role = payload.get("role") @@ -1158,6 +1384,11 @@ async def get_invites() -> Dict[str, Any]: return {"invites": results} +@router.get("/invites/trace") +async def get_invite_trace() -> Dict[str, Any]: + return {"status": "ok", "trace": _build_invite_trace_payload()} + + @router.post("/invites") async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: if not isinstance(payload, dict): diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 16e2529..00826cb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone +import httpx from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordRequestForm @@ -84,6 +85,29 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None: return None +def _extract_http_error_detail(exc: Exception) -> str: + if isinstance(exc, httpx.HTTPStatusError): + response = exc.response + try: + text = response.text.strip() + except Exception: + text = "" + if text: + return text + return f"HTTP {response.status_code}" + return str(exc) + + +async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None: + try: + users = await client.get_users() + if isinstance(users, list): + save_jellyfin_users_cache(users) + except Exception: + # Cache refresh is best-effort and should not block auth/signup. + return + + def _is_user_expired(user: dict | None) -> bool: if not user: return False @@ -137,6 +161,11 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + if user.get("auth_provider") != "local": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This account uses external sign-in. Use the external sign-in option.", + ) _assert_user_can_login(user) token = create_access_token(user["username"], user["role"]) set_last_login(user["username"]) @@ -299,12 +328,61 @@ async def signup(payload: dict) -> dict: if isinstance(account_expires_days, int) and account_expires_days > 0: expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() + runtime = get_runtime_settings() + password_value = password.strip() + auth_provider = "local" + local_password_value = password_value + matched_jellyseerr_user_id: int | None = None + + jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if jellyfin_client.configured(): + auth_provider = "jellyfin" + local_password_value = "jellyfin-user" + try: + await jellyfin_client.create_user_with_password(username, password_value) + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response is not None else None + duplicate_like = status_code in {400, 409} + if duplicate_like: + try: + response = await jellyfin_client.authenticate_by_name(username, password_value) + except Exception as auth_exc: + detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jellyfin account already exists and could not be authenticated: {detail}", + ) from exc + if not isinstance(response, dict) or not response.get("User"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Jellyfin account already exists for that username.", + ) from exc + else: + detail = _extract_http_error_detail(exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Jellyfin account provisioning failed: {detail}", + ) from exc + except Exception as exc: + detail = _extract_http_error_detail(exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Jellyfin account provisioning failed: {detail}", + ) from exc + + await _refresh_jellyfin_user_cache(jellyfin_client) + jellyseerr_users = get_cached_jellyseerr_users() + candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) + if candidate_map: + matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map) + try: create_user( username, - password.strip(), + local_password_value, role=role, - auth_provider="local", + auth_provider=auth_provider, + jellyseerr_user_id=matched_jellyseerr_user_id, auto_search_enabled=auto_search_enabled, profile_id=int(profile_id) if profile_id is not None else None, expires_at=expires_at, @@ -315,6 +393,15 @@ async def signup(payload: dict) -> dict: increment_signup_invite_use(int(invite["id"])) created_user = get_user_by_username(username) + if auth_provider == "jellyfin": + set_jellyfin_auth_cache(username, password_value) + if ( + created_user + and created_user.get("jellyseerr_user_id") is None + and matched_jellyseerr_user_id is not None + ): + set_user_jellyseerr_id(username, matched_jellyseerr_user_id) + created_user = get_user_by_username(username) _assert_user_can_login(created_user) token = create_access_token(username, role) set_last_login(username) @@ -324,6 +411,7 @@ async def signup(payload: dict) -> dict: "user": { "username": username, "role": role, + "auth_provider": created_user.get("auth_provider") if created_user else auth_provider, "profile_id": created_user.get("profile_id") if created_user else None, "expires_at": created_user.get("expires_at") if created_user else None, }, diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 48a546a..250ae0d 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -9,8 +9,12 @@ type AdminUserLite = { id: number username: string role: string + auth_provider?: string | null profile_id?: number | null expires_at?: string | null + created_at?: string | null + invited_by_code?: string | null + invited_at?: string | null } type Profile = { @@ -41,6 +45,7 @@ type Invite = { is_expired?: boolean is_usable?: boolean created_at?: string | null + created_by?: string | null } type InviteForm = { @@ -63,7 +68,7 @@ type ProfileForm = { is_active: boolean } -type InviteManagementTab = 'bulk' | 'profiles' | 'invites' +type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' const defaultInviteForm = (): InviteForm => ({ code: '', @@ -116,6 +121,7 @@ export default function AdminInviteManagementPage() { const [bulkProfileId, setBulkProfileId] = useState('') const [bulkExpiryDays, setBulkExpiryDays] = useState('') const [activeTab, setActiveTab] = useState('bulk') + const [traceFilter, setTraceFilter] = useState('') const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' @@ -468,6 +474,133 @@ export default function AdminInviteManagementPage() { const disabledInvites = invites.filter((invite) => invite.enabled === false).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length + const inviteTraceRows = useMemo(() => { + const inviteByCode = new Map() + invites.forEach((invite) => { + const code = String(invite.code || '').trim() + if (code) inviteByCode.set(code.toLowerCase(), invite) + }) + + const userByName = new Map() + users.forEach((user) => { + const username = String(user.username || '').trim() + if (username) userByName.set(username.toLowerCase(), user) + }) + + const childrenByInviter = new Map() + const inviterMetaByUser = new Map< + string, + { inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null } + >() + + users.forEach((user) => { + const username = String(user.username || '').trim() + if (!username) return + const inviteCodeRaw = String(user.invited_by_code || '').trim() + let inviterUsername: string | null = null + let inviteLabel: string | null = null + if (inviteCodeRaw) { + const invite = inviteByCode.get(inviteCodeRaw.toLowerCase()) + inviteLabel = (invite?.label as string | undefined) || null + const createdBy = String(invite?.created_by || '').trim() + if (createdBy) inviterUsername = createdBy + } + inviterMetaByUser.set(username.toLowerCase(), { + inviterUsername, + inviteCode: inviteCodeRaw || null, + inviteLabel, + }) + const key = (inviterUsername || '__root__').toLowerCase() + const bucket = childrenByInviter.get(key) ?? [] + bucket.push(user) + childrenByInviter.set(key, bucket) + }) + + childrenByInviter.forEach((bucket) => + bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' })) + ) + + const rows: Array<{ + username: string + role: string + authProvider: string + level: number + inviterUsername: string | null + inviteCode: string | null + inviteLabel: string | null + createdAt: string | null + childCount: number + isCycle?: boolean + }> = [] + + const visited = new Set() + const walk = (user: AdminUserLite, level: number, path: Set) => { + const username = String(user.username || '').trim() + const userKey = username.toLowerCase() + if (!username) return + const meta = inviterMetaByUser.get(userKey) ?? { + inviterUsername: null, + inviteCode: null, + inviteLabel: null, + } + const childCount = (childrenByInviter.get(userKey) ?? []).length + if (path.has(userKey)) { + rows.push({ + username, + role: String(user.role || 'user'), + authProvider: String(user.auth_provider || 'local'), + level, + inviterUsername: meta.inviterUsername, + inviteCode: meta.inviteCode, + inviteLabel: meta.inviteLabel, + createdAt: (user.created_at as string | null) ?? null, + childCount, + isCycle: true, + }) + return + } + rows.push({ + username, + role: String(user.role || 'user'), + authProvider: String(user.auth_provider || 'local'), + level, + inviterUsername: meta.inviterUsername, + inviteCode: meta.inviteCode, + inviteLabel: meta.inviteLabel, + createdAt: (user.created_at as string | null) ?? null, + childCount, + }) + visited.add(userKey) + const nextPath = new Set(path) + nextPath.add(userKey) + ;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath)) + } + + ;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set())) + users.forEach((user) => { + const key = String(user.username || '').toLowerCase() + if (key && !visited.has(key)) { + walk(user, 0, new Set()) + } + }) + + const filter = traceFilter.trim().toLowerCase() + if (!filter) return rows + return rows.filter((row) => + [ + row.username, + row.inviterUsername || '', + row.inviteCode || '', + row.inviteLabel || '', + row.role || '', + row.authProvider || '', + ] + .join(' ') + .toLowerCase() + .includes(filter) + ) + }, [invites, traceFilter, users]) + return ( Invites +
)} + + {activeTab === 'trace' && ( +
+
+
+
+

Invite trace map

+

+ Visual lineage of who invited who, including the invite code used for each sign-up. +

+
+
+
+ +
+ {inviteTraceRows.length} rows shown + {users.length} users loaded + {invites.length} invites loaded +
+
+ {loading ? ( +
Loading trace map…
+ ) : inviteTraceRows.length === 0 ? ( +
No trace matches found.
+ ) : ( +
+ {inviteTraceRows.map((row) => ( +
+
+
+
+ + Invited by + {row.inviterUsername || 'Root/direct'} + + + Via code + {row.inviteCode || 'None'} + + + Invite label + {row.inviteLabel || 'None'} + + + Children + {row.childCount} + + + Created + {formatDate(row.createdAt)} + +
+
+ ))} +
+ )} +
+
+ )}
) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 09a4db5..28cfdff 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4757,3 +4757,143 @@ textarea { .theme-toggle { border-radius: 50%; } + +.invite-trace-toolbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: 10px 14px; + align-items: end; + margin-bottom: 10px; +} + +.invite-trace-filter { + display: grid; + gap: 6px; +} + +.invite-trace-filter > span { + color: #9ea7b6; + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.invite-trace-summary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + color: #aeb7c4; + font-size: 0.8rem; +} + +.invite-trace-summary span { + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); + border-radius: 5px; +} + +.invite-trace-map { + display: grid; + gap: 8px; +} + +.invite-trace-row { + display: grid; + grid-template-columns: minmax(260px, 420px) minmax(0, 1fr); + gap: 10px 12px; + align-items: start; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.015); + border-radius: 6px; +} + +.invite-trace-row-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 28px; +} + +.invite-trace-branch { + width: 12px; + height: 1px; + background: rgba(138, 163, 196, 0.55); + position: relative; + margin-right: 2px; +} + +.invite-trace-branch::before { + content: ''; + position: absolute; + left: -8px; + top: -7px; + width: 8px; + height: 8px; + border-left: 1px solid rgba(138, 163, 196, 0.38); + border-bottom: 1px solid rgba(138, 163, 196, 0.38); +} + +.invite-trace-user { + color: #edf2f8; + font-weight: 700; + letter-spacing: 0.01em; +} + +.invite-trace-row-meta { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.invite-trace-meta-item { + display: grid; + gap: 3px; + align-content: start; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.01); + border-radius: 5px; + min-height: 48px; +} + +.invite-trace-meta-item .label { + color: #8f9aac; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.invite-trace-meta-item strong { + color: #e7edf6; + font-size: 0.82rem; + word-break: break-word; +} + +@media (max-width: 1180px) { + .invite-trace-toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .invite-trace-summary { + justify-content: flex-start; + } + + .invite-trace-row { + grid-template-columns: 1fr; + } + + .invite-trace-row-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .invite-trace-row-meta { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 7ef7be1..6ecb9a0 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -52,7 +52,7 @@ export default function LoginPage() {

Sign in

-

Use your Jellyfin account, or sign in with Magent instead.

+

Use your Jellyfin account, or sign in with a local Magent admin account.

submit(event, 'jellyfin')} className="auth-form">
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 0fa344f..fdc9c8e 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -135,7 +135,7 @@ function SignupPageContent() {

Create account

-

Use an invite code from your admin to create a Magent account.

+

Use an invite code from your admin to create your Jellyfin-backed Magent account.