Build 2602261605: invite trace and cross-system user lifecycle
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602261536
|
2602261605
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user