Build 2602261605: invite trace and cross-system user lifecycle

This commit is contained in:
2026-02-26 16:06:09 +13:00
parent bd3c0bdade
commit 1b1a3e233b
13 changed files with 976 additions and 16 deletions

View File

@@ -1 +1 @@
2602261536 2602261605

View File

@@ -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' 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'

View File

@@ -41,3 +41,14 @@ class ApiClient:
if not response.content: if not response.content:
return None return None
return response.json() 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()

View File

@@ -10,27 +10,158 @@ class JellyfinClient(ApiClient):
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.base_url and self.api_key) 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]]: async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url: if not self.base_url:
return None return None
url = f"{self.base_url}/Users" 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: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() 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]]: async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
if not self.base_url: if not self.base_url:
return None return None
url = f"{self.base_url}/Users/AuthenticateByName" 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} payload = {"Username": username, "Pw": password}
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload) response = await client.post(url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
return response.json() 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( async def search_items(
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20 self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
@@ -43,7 +174,7 @@ class JellyfinClient(ApiClient):
"Recursive": "true", "Recursive": "true",
"Limit": limit, "Limit": limit,
} }
headers = {"X-Emby-Token": self.api_key} headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params) response = await client.get(url, headers=headers, params=params)
response.raise_for_status() response.raise_for_status()
@@ -53,7 +184,7 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
url = f"{self.base_url}/System/Info" 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: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
@@ -63,7 +194,7 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
url = f"{self.base_url}/Library/Refresh" 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"} params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params) response = await client.post(url, headers=headers, params=params)

View File

@@ -44,3 +44,9 @@ class JellyseerrClient(ApiClient):
"skip": skip, "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}")

View File

@@ -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: def set_user_role(username: str, role: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(

View File

@@ -30,6 +30,8 @@ from ..db import (
set_user_jellyseerr_id, set_user_jellyseerr_id,
set_setting, set_setting,
set_user_blocked, set_user_blocked,
delete_user_by_username,
delete_user_activity_by_username,
set_user_auto_search_enabled, set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users, set_auto_search_enabled_for_non_admin_users,
set_user_profile_id, set_user_profile_id,
@@ -55,6 +57,8 @@ from ..db import (
create_signup_invite, create_signup_invite,
update_signup_invite, update_signup_invite,
delete_signup_invite, delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient 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]: def _admin_live_state_snapshot() -> Dict[str, Any]:
return { return {
"type": "admin_live_state", "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") raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "") username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) 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}") @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") raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "") username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) 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") @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} 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") @router.post("/users/{username}/role")
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
role = payload.get("role") role = payload.get("role")
@@ -1158,6 +1384,11 @@ async def get_invites() -> Dict[str, Any]:
return {"invites": results} 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") @router.post("/invites")
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: 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): if not isinstance(payload, dict):

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
@@ -84,6 +85,29 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return 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: def _is_user_expired(user: dict | None) -> bool:
if not user: if not user:
return False return False
@@ -137,6 +161,11 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") 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) _assert_user_can_login(user)
token = create_access_token(user["username"], user["role"]) token = create_access_token(user["username"], user["role"])
set_last_login(user["username"]) 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: if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() 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: try:
create_user( create_user(
username, username,
password.strip(), local_password_value,
role=role, role=role,
auth_provider="local", auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled, auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None, profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at, expires_at=expires_at,
@@ -315,6 +393,15 @@ async def signup(payload: dict) -> dict:
increment_signup_invite_use(int(invite["id"])) increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username) 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) _assert_user_can_login(created_user)
token = create_access_token(username, role) token = create_access_token(username, role)
set_last_login(username) set_last_login(username)
@@ -324,6 +411,7 @@ async def signup(payload: dict) -> dict:
"user": { "user": {
"username": username, "username": username,
"role": role, "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, "profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None, "expires_at": created_user.get("expires_at") if created_user else None,
}, },

View File

@@ -9,8 +9,12 @@ type AdminUserLite = {
id: number id: number
username: string username: string
role: string role: string
auth_provider?: string | null
profile_id?: number | null profile_id?: number | null
expires_at?: string | null expires_at?: string | null
created_at?: string | null
invited_by_code?: string | null
invited_at?: string | null
} }
type Profile = { type Profile = {
@@ -41,6 +45,7 @@ type Invite = {
is_expired?: boolean is_expired?: boolean
is_usable?: boolean is_usable?: boolean
created_at?: string | null created_at?: string | null
created_by?: string | null
} }
type InviteForm = { type InviteForm = {
@@ -63,7 +68,7 @@ type ProfileForm = {
is_active: boolean is_active: boolean
} }
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
const defaultInviteForm = (): InviteForm => ({ const defaultInviteForm = (): InviteForm => ({
code: '', code: '',
@@ -116,6 +121,7 @@ export default function AdminInviteManagementPage() {
const [bulkProfileId, setBulkProfileId] = useState('') const [bulkProfileId, setBulkProfileId] = useState('')
const [bulkExpiryDays, setBulkExpiryDays] = useState('') const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk') const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
const signupBaseUrl = useMemo(() => { const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup' if (typeof window === 'undefined') return '/signup'
@@ -468,6 +474,133 @@ export default function AdminInviteManagementPage() {
const disabledInvites = invites.filter((invite) => invite.enabled === false).length const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
invites.forEach((invite) => {
const code = String(invite.code || '').trim()
if (code) inviteByCode.set(code.toLowerCase(), invite)
})
const userByName = new Map<string, AdminUserLite>()
users.forEach((user) => {
const username = String(user.username || '').trim()
if (username) userByName.set(username.toLowerCase(), user)
})
const childrenByInviter = new Map<string, AdminUserLite[]>()
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<string>()
const walk = (user: AdminUserLite, level: number, path: Set<string>) => {
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 ( return (
<AdminShell <AdminShell
title="Invite management" title="Invite management"
@@ -547,6 +680,15 @@ export default function AdminInviteManagementPage() {
> >
Invites Invites
</button> </button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Trace map
</button>
</div> </div>
<div className="admin-inline-actions invite-admin-tab-actions"> <div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}> <button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
@@ -1064,6 +1206,78 @@ export default function AdminInviteManagementPage() {
</div> </div>
</div> </div>
)} )}
{activeTab === 'trace' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite trace map</h2>
<p className="lede">
Visual lineage of who invited who, including the invite code used for each sign-up.
</p>
</div>
</div>
<div className="invite-trace-toolbar">
<label className="invite-trace-filter">
<span>Find user / inviter / code</span>
<input
type="search"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-summary">
<span>{inviteTraceRows.length} rows shown</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : (
<div className="invite-trace-map">
{inviteTraceRows.map((row) => (
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" />
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<div className="invite-trace-row-meta">
<span className="invite-trace-meta-item">
<span className="label">Invited by</span>
<strong>{row.inviterUsername || 'Root/direct'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Via code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</section> </section>
</AdminShell> </AdminShell>
) )

View File

@@ -4757,3 +4757,143 @@ textarea {
.theme-toggle { .theme-toggle {
border-radius: 50%; 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;
}
}

View File

@@ -52,7 +52,7 @@ export default function LoginPage() {
<main className="card auth-card"> <main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" /> <BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1> <h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p> <p className="lede">Use your Jellyfin account, or sign in with a local Magent admin account.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form"> <form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<label> <label>
Username Username
@@ -86,7 +86,7 @@ export default function LoginPage() {
Sign in with Magent account Sign in with Magent account
</button> </button>
<a className="ghost-button" href="/signup"> <a className="ghost-button" href="/signup">
Have an invite? Create a Magent account Have an invite? Create your account (Jellyfin + Magent)
</a> </a>
</form> </form>
</main> </main>

View File

@@ -135,7 +135,7 @@ function SignupPageContent() {
<main className="card auth-card"> <main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" /> <BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1> <h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create a Magent account.</p> <p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form"> <form onSubmit={submit} className="auth-form">
<label> <label>
Invite code Invite code
@@ -203,7 +203,7 @@ function SignupPageContent() {
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
<div className="auth-actions"> <div className="auth-actions">
<button type="submit" disabled={!canSubmit}> <button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'} {loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
</button> </button>
</div> </div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}> <button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>

View File

@@ -29,8 +29,24 @@ type AdminUser = {
profile_id?: number | null profile_id?: number | null
expires_at?: string | null expires_at?: string | null
is_expired?: boolean is_expired?: boolean
invited_by_code?: string | null
invited_at?: string | null
} }
type UserLineage = {
invite_code?: string | null
invited_by?: string | null
invite?: {
id?: number
code?: string
label?: string | null
created_by?: string | null
created_at?: string | null
enabled?: boolean
is_usable?: boolean
} | null
} | null
type UserProfileOption = { type UserProfileOption = {
id: number id: number
name: string name: string
@@ -85,7 +101,9 @@ export default function UserDetailPage() {
const [expiryInput, setExpiryInput] = useState('') const [expiryInput, setExpiryInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false) const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false) const [savingExpiry, setSavingExpiry] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null) const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
const loadProfiles = async () => { const loadProfiles = async () => {
try { try {
@@ -138,6 +156,7 @@ export default function UserDetailPage() {
const nextUser = data?.user ?? null const nextUser = data?.user ?? null
setUser(nextUser) setUser(nextUser)
setStats(normalizeStats(data?.stats)) setStats(normalizeStats(data?.stats))
setLineage((data?.lineage ?? null) as UserLineage)
setProfileSelection( setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id)) nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? '' ? ''
@@ -315,6 +334,59 @@ export default function UserDetailPage() {
} }
} }
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
)
if (!confirmed) return
}
setSystemActionBusy(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Cross-system action failed')
}
const state = data?.status === 'partial' ? 'partial' : 'complete'
if (action === 'remove') {
setActionStatus(`User removed (${state}).`)
router.push('/users')
return
}
await loadUser()
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
} finally {
setSystemActionBusy(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -378,6 +450,14 @@ export default function UserDetailPage() {
<span className="label">Assigned profile</span> <span className="label">Assigned profile</span>
<strong>{user.profile_id ?? 'None'}</strong> <strong>{user.profile_id ?? 'None'}</strong>
</div> </div>
<div className="user-detail-meta-item">
<span className="label">Invited by</span>
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invite code used</span>
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
</div>
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">
<span className="label">Last login</span> <span className="label">Last login</span>
<strong>{formatDateTime(user.last_login_at)}</strong> <strong>{formatDateTime(user.last_login_at)}</strong>
@@ -463,9 +543,32 @@ export default function UserDetailPage() {
type="button" type="button"
className="ghost-button" className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)} onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
> >
{user.is_blocked ? 'Allow access' : 'Block access'} {user.is_blocked ? 'Allow access' : 'Block access'}
</button> </button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="user-detail-helper"> <div className="user-detail-helper">
Admins always have auto search/download access. Admins always have auto search/download access.