Compare commits
12 Commits
50be0b6b57
...
05a3d1e3b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a3d1e3b0 | |||
| b84c27c698 | |||
| 744b213fa0 | |||
| f362676c4e | |||
| 7257d32d6c | |||
| 1c6b8255c1 | |||
| 0b73d9f4ee | |||
| b215e8030c | |||
| 6a5d2c4310 | |||
| 23c57da3cc | |||
| 1b1a3e233b | |||
| bd3c0bdade |
@@ -1 +1 @@
|
|||||||
2602261523
|
2702261153
|
||||||
|
|||||||
@@ -38,11 +38,18 @@ def _extract_client_ip(request: Request) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]:
|
def _load_current_user_from_token(
|
||||||
|
token: str,
|
||||||
|
request: Optional[Request] = None,
|
||||||
|
allowed_token_types: Optional[set[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
payload = safe_decode_token(token)
|
payload = safe_decode_token(token)
|
||||||
except TokenError as exc:
|
except TokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
|
token_type = str(payload.get("typ") or "access").strip().lower()
|
||||||
|
if allowed_token_types and token_type not in allowed_token_types:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
@@ -67,6 +74,7 @@ def _load_current_user_from_token(token: str, request: Optional[Request] = None)
|
|||||||
"auth_provider": user.get("auth_provider", "local"),
|
"auth_provider": user.get("auth_provider", "local"),
|
||||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
||||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
||||||
|
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
||||||
"profile_id": user.get("profile_id"),
|
"profile_id": user.get("profile_id"),
|
||||||
"expires_at": user.get("expires_at"),
|
"expires_at": user.get("expires_at"),
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
"is_expired": bool(user.get("is_expired", False)),
|
||||||
@@ -78,16 +86,24 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
|
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
|
||||||
"""EventSource cannot send Authorization headers, so allow a query token here only."""
|
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
||||||
token = None
|
token = None
|
||||||
|
stream_query_token = None
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.lower().startswith("bearer "):
|
if auth_header.lower().startswith("bearer "):
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
if not token:
|
if not token:
|
||||||
token = request.query_params.get("access_token")
|
stream_query_token = request.query_params.get("stream_token")
|
||||||
if not token:
|
if not token and not stream_query_token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
return _load_current_user_from_token(token, None)
|
if token:
|
||||||
|
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
|
||||||
|
return _load_current_user_from_token(token, None)
|
||||||
|
return _load_current_user_from_token(
|
||||||
|
str(stream_query_token),
|
||||||
|
None,
|
||||||
|
allowed_token_types={"sse"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
BUILD_NUMBER = "2602261523"
|
BUILD_NUMBER = "2702261314"
|
||||||
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}")
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ class Settings(BaseSettings):
|
|||||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||||
|
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||||
|
auth_rate_limit_window_seconds: int = Field(
|
||||||
|
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
||||||
|
)
|
||||||
|
auth_rate_limit_max_attempts_ip: int = Field(
|
||||||
|
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
||||||
|
)
|
||||||
|
auth_rate_limit_max_attempts_user: int = Field(
|
||||||
|
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
||||||
|
)
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||||
@@ -51,6 +61,126 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||||
|
|
||||||
|
magent_application_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
||||||
|
)
|
||||||
|
magent_application_port: int = Field(
|
||||||
|
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
||||||
|
)
|
||||||
|
magent_api_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
||||||
|
)
|
||||||
|
magent_api_port: int = Field(
|
||||||
|
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
||||||
|
)
|
||||||
|
magent_bind_host: str = Field(
|
||||||
|
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
||||||
|
)
|
||||||
|
magent_proxy_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
||||||
|
)
|
||||||
|
magent_proxy_base_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
||||||
|
)
|
||||||
|
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||||
|
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
||||||
|
)
|
||||||
|
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||||
|
)
|
||||||
|
magent_ssl_bind_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
||||||
|
)
|
||||||
|
magent_ssl_certificate_path: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
||||||
|
)
|
||||||
|
magent_ssl_private_key_path: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
||||||
|
)
|
||||||
|
magent_ssl_certificate_pem: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
||||||
|
)
|
||||||
|
magent_ssl_private_key_pem: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
||||||
|
)
|
||||||
|
|
||||||
|
magent_notify_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_email_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_email_smtp_host: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
||||||
|
)
|
||||||
|
magent_notify_email_smtp_port: int = Field(
|
||||||
|
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
||||||
|
)
|
||||||
|
magent_notify_email_smtp_username: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
||||||
|
)
|
||||||
|
magent_notify_email_smtp_password: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
||||||
|
)
|
||||||
|
magent_notify_email_from_address: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
||||||
|
)
|
||||||
|
magent_notify_email_from_name: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
||||||
|
)
|
||||||
|
magent_notify_email_use_tls: bool = Field(
|
||||||
|
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
||||||
|
)
|
||||||
|
magent_notify_email_use_ssl: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
||||||
|
)
|
||||||
|
|
||||||
|
magent_notify_discord_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_discord_webhook_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
||||||
|
)
|
||||||
|
|
||||||
|
magent_notify_telegram_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_telegram_bot_token: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
||||||
|
)
|
||||||
|
magent_notify_telegram_chat_id: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
||||||
|
)
|
||||||
|
|
||||||
|
magent_notify_push_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_push_provider: Optional[str] = Field(
|
||||||
|
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
||||||
|
)
|
||||||
|
magent_notify_push_base_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
||||||
|
)
|
||||||
|
magent_notify_push_topic: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
||||||
|
)
|
||||||
|
magent_notify_push_token: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
||||||
|
)
|
||||||
|
magent_notify_push_user_key: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
||||||
|
)
|
||||||
|
magent_notify_push_device: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
||||||
|
)
|
||||||
|
|
||||||
|
magent_notify_webhook_enabled: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
||||||
|
)
|
||||||
|
magent_notify_webhook_url: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
||||||
|
)
|
||||||
|
|
||||||
jellyseerr_base_url: Optional[str] = Field(
|
jellyseerr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ def init_db() -> None:
|
|||||||
last_login_at TEXT,
|
last_login_at TEXT,
|
||||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
|
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
invite_management_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
profile_id INTEGER,
|
profile_id INTEGER,
|
||||||
expires_at TEXT,
|
expires_at TEXT,
|
||||||
invited_by_code TEXT,
|
invited_by_code TEXT,
|
||||||
@@ -341,6 +342,10 @@ def init_db() -> None:
|
|||||||
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
@@ -498,6 +503,7 @@ def create_user(
|
|||||||
auth_provider: str = "local",
|
auth_provider: str = "local",
|
||||||
jellyseerr_user_id: Optional[int] = None,
|
jellyseerr_user_id: Optional[int] = None,
|
||||||
auto_search_enabled: bool = True,
|
auto_search_enabled: bool = True,
|
||||||
|
invite_management_enabled: bool = False,
|
||||||
profile_id: Optional[int] = None,
|
profile_id: Optional[int] = None,
|
||||||
expires_at: Optional[str] = None,
|
expires_at: Optional[str] = None,
|
||||||
invited_by_code: Optional[str] = None,
|
invited_by_code: Optional[str] = None,
|
||||||
@@ -515,12 +521,13 @@ def create_user(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
auto_search_enabled,
|
auto_search_enabled,
|
||||||
|
invite_management_enabled,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
invited_at
|
invited_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
username,
|
username,
|
||||||
@@ -530,6 +537,7 @@ def create_user(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
1 if auto_search_enabled else 0,
|
1 if auto_search_enabled else 0,
|
||||||
|
1 if invite_management_enabled else 0,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -545,6 +553,7 @@ def create_user_if_missing(
|
|||||||
auth_provider: str = "local",
|
auth_provider: str = "local",
|
||||||
jellyseerr_user_id: Optional[int] = None,
|
jellyseerr_user_id: Optional[int] = None,
|
||||||
auto_search_enabled: bool = True,
|
auto_search_enabled: bool = True,
|
||||||
|
invite_management_enabled: bool = False,
|
||||||
profile_id: Optional[int] = None,
|
profile_id: Optional[int] = None,
|
||||||
expires_at: Optional[str] = None,
|
expires_at: Optional[str] = None,
|
||||||
invited_by_code: Optional[str] = None,
|
invited_by_code: Optional[str] = None,
|
||||||
@@ -562,12 +571,13 @@ def create_user_if_missing(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
auto_search_enabled,
|
auto_search_enabled,
|
||||||
|
invite_management_enabled,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
invited_at
|
invited_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
username,
|
username,
|
||||||
@@ -577,6 +587,7 @@ def create_user_if_missing(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
1 if auto_search_enabled else 0,
|
1 if auto_search_enabled else 0,
|
||||||
|
1 if invite_management_enabled else 0,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -592,7 +603,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
profile_id, expires_at, invited_by_code, invited_at,
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
jellyfin_password_hash, last_jellyfin_auth_at
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = ? COLLATE NOCASE
|
WHERE username = ? COLLATE NOCASE
|
||||||
@@ -612,13 +623,14 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
|||||||
"last_login_at": row[7],
|
"last_login_at": row[7],
|
||||||
"is_blocked": bool(row[8]),
|
"is_blocked": bool(row[8]),
|
||||||
"auto_search_enabled": bool(row[9]),
|
"auto_search_enabled": bool(row[9]),
|
||||||
"profile_id": row[10],
|
"invite_management_enabled": bool(row[10]),
|
||||||
"expires_at": row[11],
|
"profile_id": row[11],
|
||||||
"invited_by_code": row[12],
|
"expires_at": row[12],
|
||||||
"invited_at": row[13],
|
"invited_by_code": row[13],
|
||||||
"is_expired": _is_datetime_in_past(row[11]),
|
"invited_at": row[14],
|
||||||
"jellyfin_password_hash": row[14],
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
"last_jellyfin_auth_at": row[15],
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -628,7 +640,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
profile_id, expires_at, invited_by_code, invited_at,
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
jellyfin_password_hash, last_jellyfin_auth_at
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -648,13 +660,14 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
"last_login_at": row[7],
|
"last_login_at": row[7],
|
||||||
"is_blocked": bool(row[8]),
|
"is_blocked": bool(row[8]),
|
||||||
"auto_search_enabled": bool(row[9]),
|
"auto_search_enabled": bool(row[9]),
|
||||||
"profile_id": row[10],
|
"invite_management_enabled": bool(row[10]),
|
||||||
"expires_at": row[11],
|
"profile_id": row[11],
|
||||||
"invited_by_code": row[12],
|
"expires_at": row[12],
|
||||||
"invited_at": row[13],
|
"invited_by_code": row[13],
|
||||||
"is_expired": _is_datetime_in_past(row[11]),
|
"invited_at": row[14],
|
||||||
"jellyfin_password_hash": row[14],
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
"last_jellyfin_auth_at": row[15],
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_all_users() -> list[Dict[str, Any]]:
|
def get_all_users() -> list[Dict[str, Any]]:
|
||||||
@@ -662,15 +675,15 @@ def get_all_users() -> list[Dict[str, Any]]:
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
|
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
|
||||||
last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at,
|
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
|
||||||
invited_by_code, invited_at
|
profile_id, expires_at, invited_by_code, invited_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY username COLLATE NOCASE
|
ORDER BY username COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
results: list[Dict[str, Any]] = []
|
all_rows: list[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
results.append(
|
all_rows.append(
|
||||||
{
|
{
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
"username": row[1],
|
"username": row[1],
|
||||||
@@ -681,13 +694,63 @@ def get_all_users() -> list[Dict[str, Any]]:
|
|||||||
"last_login_at": row[6],
|
"last_login_at": row[6],
|
||||||
"is_blocked": bool(row[7]),
|
"is_blocked": bool(row[7]),
|
||||||
"auto_search_enabled": bool(row[8]),
|
"auto_search_enabled": bool(row[8]),
|
||||||
"profile_id": row[9],
|
"invite_management_enabled": bool(row[9]),
|
||||||
"expires_at": row[10],
|
"profile_id": row[10],
|
||||||
"invited_by_code": row[11],
|
"expires_at": row[11],
|
||||||
"invited_at": row[12],
|
"invited_by_code": row[12],
|
||||||
"is_expired": _is_datetime_in_past(row[10]),
|
"invited_at": row[13],
|
||||||
|
"is_expired": _is_datetime_in_past(row[11]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# Admin user management uses Jellyfin as the source of truth for non-admin
|
||||||
|
# user objects. Jellyseerr rows are treated as enrichment-only and hidden
|
||||||
|
# from admin/user-management views to avoid duplicate accounts in the UI.
|
||||||
|
def _provider_rank(user: Dict[str, Any]) -> int:
|
||||||
|
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||||
|
if provider == "jellyfin":
|
||||||
|
return 0
|
||||||
|
if provider == "local":
|
||||||
|
return 1
|
||||||
|
if provider == "jellyseerr":
|
||||||
|
return 2
|
||||||
|
return 2
|
||||||
|
|
||||||
|
visible_candidates = [
|
||||||
|
user
|
||||||
|
for user in all_rows
|
||||||
|
if not (
|
||||||
|
str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr"
|
||||||
|
and str(user.get("role") or "user").strip().lower() != "admin"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
visible_candidates.sort(
|
||||||
|
key=lambda user: (
|
||||||
|
0 if str(user.get("role") or "user").strip().lower() == "admin" else 1,
|
||||||
|
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
||||||
|
_provider_rank(user),
|
||||||
|
0 if user.get("last_login_at") else 1,
|
||||||
|
int(user.get("id") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen_usernames: set[str] = set()
|
||||||
|
seen_jellyseerr_ids: set[int] = set()
|
||||||
|
results: list[Dict[str, Any]] = []
|
||||||
|
for user in visible_candidates:
|
||||||
|
username = str(user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
username_key = username.lower()
|
||||||
|
jellyseerr_user_id = user.get("jellyseerr_user_id")
|
||||||
|
if isinstance(jellyseerr_user_id, int) and jellyseerr_user_id in seen_jellyseerr_ids:
|
||||||
|
continue
|
||||||
|
if username_key in seen_usernames:
|
||||||
|
continue
|
||||||
|
results.append(user)
|
||||||
|
seen_usernames.add(username_key)
|
||||||
|
if isinstance(jellyseerr_user_id, int):
|
||||||
|
seen_jellyseerr_ids.add(jellyseerr_user_id)
|
||||||
|
results.sort(key=lambda user: str(user.get("username") or "").lower())
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -711,6 +774,17 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_auth_provider(username: str, auth_provider: str) -> None:
|
||||||
|
provider = (auth_provider or "local").strip().lower() or "local"
|
||||||
|
with _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET auth_provider = ? WHERE username = ?
|
||||||
|
""",
|
||||||
|
(provider, username),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_last_login(username: str) -> None:
|
def set_last_login(username: str) -> None:
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
@@ -732,6 +806,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(
|
||||||
@@ -752,6 +862,16 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
|
||||||
|
with _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET invite_management_enabled = ? WHERE username = ? COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0, username),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -763,6 +883,17 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
|||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int:
|
||||||
|
with _connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET invite_management_enabled = ? WHERE role != 'admin'
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0,),
|
||||||
|
)
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
|
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1091,12 +1222,94 @@ def increment_signup_invite_use(invite_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
|
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
user = get_user_by_username(username)
|
# Resolve case-insensitive duplicates safely by only considering local-provider rows.
|
||||||
if not user:
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = ? COLLATE NOCASE
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN username = ? THEN 0 ELSE 1 END,
|
||||||
|
id ASC
|
||||||
|
""",
|
||||||
|
(username, username),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
return None
|
return None
|
||||||
if not verify_password(password, user["password_hash"]):
|
for row in rows:
|
||||||
return None
|
provider = str(row[4] or "local").lower()
|
||||||
return user
|
if provider != "local":
|
||||||
|
continue
|
||||||
|
if not verify_password(password, row[2]):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"username": row[1],
|
||||||
|
"password_hash": row[2],
|
||||||
|
"role": row[3],
|
||||||
|
"auth_provider": row[4],
|
||||||
|
"jellyseerr_user_id": row[5],
|
||||||
|
"created_at": row[6],
|
||||||
|
"last_login_at": row[7],
|
||||||
|
"is_blocked": bool(row[8]),
|
||||||
|
"auto_search_enabled": bool(row[9]),
|
||||||
|
"invite_management_enabled": bool(row[10]),
|
||||||
|
"profile_id": row[11],
|
||||||
|
"expires_at": row[12],
|
||||||
|
"invited_by_code": row[13],
|
||||||
|
"invited_at": row[14],
|
||||||
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = ? COLLATE NOCASE
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN username = ? THEN 0 ELSE 1 END,
|
||||||
|
id ASC
|
||||||
|
""",
|
||||||
|
(username, username),
|
||||||
|
).fetchall()
|
||||||
|
results: list[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"username": row[1],
|
||||||
|
"password_hash": row[2],
|
||||||
|
"role": row[3],
|
||||||
|
"auth_provider": row[4],
|
||||||
|
"jellyseerr_user_id": row[5],
|
||||||
|
"created_at": row[6],
|
||||||
|
"last_login_at": row[7],
|
||||||
|
"is_blocked": bool(row[8]),
|
||||||
|
"auto_search_enabled": bool(row[9]),
|
||||||
|
"invite_management_enabled": bool(row[10]),
|
||||||
|
"profile_id": row[11],
|
||||||
|
"expires_at": row[12],
|
||||||
|
"invited_by_code": row[13],
|
||||||
|
"invited_at": row[14],
|
||||||
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def set_user_password(username: str, password: str) -> None:
|
def set_user_password(username: str, password: str) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -24,7 +24,12 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync
|
|||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
docs_url="/docs" if settings.api_docs_enabled else None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -35,6 +40,22 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_security_headers(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
|
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||||
|
# Keep API responses non-executable and non-embeddable by default.
|
||||||
|
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..db import (
|
|||||||
get_all_users,
|
get_all_users,
|
||||||
get_cached_requests,
|
get_cached_requests,
|
||||||
get_cached_requests_count,
|
get_cached_requests_count,
|
||||||
|
get_setting,
|
||||||
get_request_cache_overview,
|
get_request_cache_overview,
|
||||||
get_request_cache_missing_titles,
|
get_request_cache_missing_titles,
|
||||||
get_request_cache_stats,
|
get_request_cache_stats,
|
||||||
@@ -30,11 +31,16 @@ 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_invite_management_enabled,
|
||||||
|
set_invite_management_enabled_for_non_admin_users,
|
||||||
set_user_profile_id,
|
set_user_profile_id,
|
||||||
set_user_expires_at,
|
set_user_expires_at,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
|
set_jellyfin_auth_cache,
|
||||||
set_user_role,
|
set_user_role,
|
||||||
run_integrity_check,
|
run_integrity_check,
|
||||||
vacuum_db,
|
vacuum_db,
|
||||||
@@ -55,6 +61,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
|
||||||
@@ -79,8 +87,17 @@ from ..routers.branding import save_branding_image
|
|||||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
|
"magent_ssl_certificate_pem",
|
||||||
|
"magent_ssl_private_key_pem",
|
||||||
|
"magent_notify_email_smtp_password",
|
||||||
|
"magent_notify_discord_webhook_url",
|
||||||
|
"magent_notify_telegram_bot_token",
|
||||||
|
"magent_notify_push_token",
|
||||||
|
"magent_notify_push_user_key",
|
||||||
|
"magent_notify_webhook_url",
|
||||||
"jellyseerr_api_key",
|
"jellyseerr_api_key",
|
||||||
"jellyfin_api_key",
|
"jellyfin_api_key",
|
||||||
"sonarr_api_key",
|
"sonarr_api_key",
|
||||||
@@ -90,6 +107,11 @@ SENSITIVE_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
URL_SETTING_KEYS = {
|
URL_SETTING_KEYS = {
|
||||||
|
"magent_application_url",
|
||||||
|
"magent_api_url",
|
||||||
|
"magent_proxy_base_url",
|
||||||
|
"magent_notify_discord_webhook_url",
|
||||||
|
"magent_notify_push_base_url",
|
||||||
"jellyseerr_base_url",
|
"jellyseerr_base_url",
|
||||||
"jellyfin_base_url",
|
"jellyfin_base_url",
|
||||||
"jellyfin_public_url",
|
"jellyfin_public_url",
|
||||||
@@ -100,6 +122,44 @@ URL_SETTING_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SETTING_KEYS: List[str] = [
|
SETTING_KEYS: List[str] = [
|
||||||
|
"magent_application_url",
|
||||||
|
"magent_application_port",
|
||||||
|
"magent_api_url",
|
||||||
|
"magent_api_port",
|
||||||
|
"magent_bind_host",
|
||||||
|
"magent_proxy_enabled",
|
||||||
|
"magent_proxy_base_url",
|
||||||
|
"magent_proxy_trust_forwarded_headers",
|
||||||
|
"magent_proxy_forwarded_prefix",
|
||||||
|
"magent_ssl_bind_enabled",
|
||||||
|
"magent_ssl_certificate_path",
|
||||||
|
"magent_ssl_private_key_path",
|
||||||
|
"magent_ssl_certificate_pem",
|
||||||
|
"magent_ssl_private_key_pem",
|
||||||
|
"magent_notify_enabled",
|
||||||
|
"magent_notify_email_enabled",
|
||||||
|
"magent_notify_email_smtp_host",
|
||||||
|
"magent_notify_email_smtp_port",
|
||||||
|
"magent_notify_email_smtp_username",
|
||||||
|
"magent_notify_email_smtp_password",
|
||||||
|
"magent_notify_email_from_address",
|
||||||
|
"magent_notify_email_from_name",
|
||||||
|
"magent_notify_email_use_tls",
|
||||||
|
"magent_notify_email_use_ssl",
|
||||||
|
"magent_notify_discord_enabled",
|
||||||
|
"magent_notify_discord_webhook_url",
|
||||||
|
"magent_notify_telegram_enabled",
|
||||||
|
"magent_notify_telegram_bot_token",
|
||||||
|
"magent_notify_telegram_chat_id",
|
||||||
|
"magent_notify_push_enabled",
|
||||||
|
"magent_notify_push_provider",
|
||||||
|
"magent_notify_push_base_url",
|
||||||
|
"magent_notify_push_topic",
|
||||||
|
"magent_notify_push_token",
|
||||||
|
"magent_notify_push_user_key",
|
||||||
|
"magent_notify_push_device",
|
||||||
|
"magent_notify_webhook_enabled",
|
||||||
|
"magent_notify_webhook_url",
|
||||||
"jellyseerr_base_url",
|
"jellyseerr_base_url",
|
||||||
"jellyseerr_api_key",
|
"jellyseerr_api_key",
|
||||||
"jellyfin_base_url",
|
"jellyfin_base_url",
|
||||||
@@ -137,6 +197,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 +1025,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 +1035,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 +1050,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")
|
||||||
@@ -881,6 +1163,24 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
|
|||||||
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/invite-access")
|
||||||
|
async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||||
|
user = get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
set_user_invite_management_enabled(username, enabled)
|
||||||
|
refreshed = get_user_by_username(username)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"username": username,
|
||||||
|
"invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
|
||||||
|
"user": refreshed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{username}/profile")
|
@router.post("/users/{username}/profile")
|
||||||
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
user = get_user_by_username(username)
|
user = get_user_by_username(username)
|
||||||
@@ -946,6 +1246,20 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/invite-access/bulk")
|
||||||
|
async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||||
|
updated = set_invite_management_enabled_for_non_admin_users(enabled)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"enabled": enabled,
|
||||||
|
"updated": updated,
|
||||||
|
"scope": "non-admin-users",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/profile/bulk")
|
@router.post("/users/profile/bulk")
|
||||||
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -1016,12 +1330,30 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
|||||||
user = get_user_by_username(username)
|
user = get_user_by_username(username)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if user.get("auth_provider") != "local":
|
new_password_clean = new_password.strip()
|
||||||
raise HTTPException(
|
auth_provider = str(user.get("auth_provider") or "local").lower()
|
||||||
status_code=400, detail="Password changes are only available for local users."
|
if auth_provider == "local":
|
||||||
)
|
set_user_password(username, new_password_clean)
|
||||||
set_user_password(username, new_password.strip())
|
return {"status": "ok", "username": username, "provider": "local"}
|
||||||
return {"status": "ok", "username": username}
|
if auth_provider == "jellyfin":
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
|
||||||
|
try:
|
||||||
|
jf_user = await client.find_user_by_name(username)
|
||||||
|
user_id = client._extract_user_id(jf_user)
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Jellyfin user ID not found")
|
||||||
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||||
|
set_jellyfin_auth_cache(username, new_password_clean)
|
||||||
|
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Password changes are not available for this sign-in provider.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles")
|
@router.get("/profiles")
|
||||||
@@ -1158,6 +1490,68 @@ async def get_invites() -> Dict[str, Any]:
|
|||||||
return {"invites": results}
|
return {"invites": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invites/policy")
|
||||||
|
async def get_invite_policy() -> Dict[str, Any]:
|
||||||
|
users = get_all_users()
|
||||||
|
non_admin_users = [user for user in users if user.get("role") != "admin"]
|
||||||
|
invite_access_enabled_count = sum(
|
||||||
|
1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
|
||||||
|
)
|
||||||
|
raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||||
|
master_invite_id: Optional[int] = None
|
||||||
|
master_invite: Optional[Dict[str, Any]] = None
|
||||||
|
if raw_master_invite_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
candidate = int(str(raw_master_invite_id).strip())
|
||||||
|
if candidate > 0:
|
||||||
|
master_invite_id = candidate
|
||||||
|
master_invite = get_signup_invite_by_id(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
master_invite_id = None
|
||||||
|
master_invite = None
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"policy": {
|
||||||
|
"master_invite_id": master_invite_id if master_invite is not None else None,
|
||||||
|
"master_invite": master_invite,
|
||||||
|
"non_admin_users": len(non_admin_users),
|
||||||
|
"invite_access_enabled_users": invite_access_enabled_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invites/policy")
|
||||||
|
async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
master_invite_value = payload.get("master_invite_id")
|
||||||
|
if master_invite_value in (None, "", 0, "0"):
|
||||||
|
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
|
||||||
|
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
|
||||||
|
try:
|
||||||
|
master_invite_id = int(master_invite_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
|
||||||
|
if master_invite_id <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
|
||||||
|
invite = get_signup_invite_by_id(master_invite_id)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=404, detail="Master invite not found")
|
||||||
|
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"policy": {
|
||||||
|
"master_invite_id": master_invite_id,
|
||||||
|
"master_invite": invite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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,6 +1,12 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from ..db import (
|
from ..db import (
|
||||||
@@ -9,10 +15,17 @@ from ..db import (
|
|||||||
create_user_if_missing,
|
create_user_if_missing,
|
||||||
set_last_login,
|
set_last_login,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
|
get_users_by_username_ci,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
set_jellyfin_auth_cache,
|
set_jellyfin_auth_cache,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
|
set_user_auth_provider,
|
||||||
get_signup_invite_by_code,
|
get_signup_invite_by_code,
|
||||||
|
get_signup_invite_by_id,
|
||||||
|
list_signup_invites,
|
||||||
|
create_signup_invite,
|
||||||
|
update_signup_invite,
|
||||||
|
delete_signup_invite,
|
||||||
increment_signup_invite_use,
|
increment_signup_invite_use,
|
||||||
get_user_profile,
|
get_user_profile,
|
||||||
get_user_activity,
|
get_user_activity,
|
||||||
@@ -20,12 +33,15 @@ from ..db import (
|
|||||||
get_user_request_stats,
|
get_user_request_stats,
|
||||||
get_global_request_leader,
|
get_global_request_leader,
|
||||||
get_global_request_total,
|
get_global_request_total,
|
||||||
|
get_setting,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..security import create_access_token, verify_password
|
from ..security import create_access_token, verify_password
|
||||||
|
from ..security import create_stream_token
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
|
from ..config import settings
|
||||||
from ..services.user_cache import (
|
from ..services.user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
build_jellyseerr_candidate_map,
|
||||||
get_cached_jellyseerr_users,
|
get_cached_jellyseerr_users,
|
||||||
@@ -34,6 +50,106 @@ from ..services.user_cache import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
|
STREAM_TOKEN_TTL_SECONDS = 120
|
||||||
|
|
||||||
|
_LOGIN_RATE_LOCK = Lock()
|
||||||
|
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_client_ip(request: Request) -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if isinstance(forwarded, str) and forwarded.strip():
|
||||||
|
return forwarded.split(",", 1)[0].strip()
|
||||||
|
real = request.headers.get("x-real-ip")
|
||||||
|
if isinstance(real, str) and real.strip():
|
||||||
|
return real.strip()
|
||||||
|
if request.client and request.client.host:
|
||||||
|
return str(request.client.host)
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _login_rate_key_user(username: str) -> str:
|
||||||
|
return (username or "").strip().lower()[:256] or "<empty>"
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
|
||||||
|
cutoff = now - window_seconds
|
||||||
|
while bucket and bucket[0] < cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
|
||||||
|
if not users:
|
||||||
|
return None
|
||||||
|
requested = (requested_username or "").strip()
|
||||||
|
requested_lower = requested.lower()
|
||||||
|
|
||||||
|
def _rank(user: dict) -> tuple[int, int, int, int]:
|
||||||
|
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||||
|
role = str(user.get("role") or "user").strip().lower()
|
||||||
|
username = str(user.get("username") or "")
|
||||||
|
return (
|
||||||
|
0 if role == "admin" else 1,
|
||||||
|
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
||||||
|
0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)),
|
||||||
|
0 if username.lower() == requested_lower else 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(users, key=_rank)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _record_login_failure(request: Request, username: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
|
||||||
|
ip_key = _auth_client_ip(request)
|
||||||
|
user_key = _login_rate_key_user(username)
|
||||||
|
with _LOGIN_RATE_LOCK:
|
||||||
|
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
||||||
|
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
||||||
|
_prune_attempts(ip_bucket, now, window)
|
||||||
|
_prune_attempts(user_bucket, now, window)
|
||||||
|
ip_bucket.append(now)
|
||||||
|
user_bucket.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_login_failures(request: Request, username: str) -> None:
|
||||||
|
ip_key = _auth_client_ip(request)
|
||||||
|
user_key = _login_rate_key_user(username)
|
||||||
|
with _LOGIN_RATE_LOCK:
|
||||||
|
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
|
||||||
|
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_login_rate_limit(request: Request, username: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
|
||||||
|
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
|
||||||
|
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
|
||||||
|
ip_key = _auth_client_ip(request)
|
||||||
|
user_key = _login_rate_key_user(username)
|
||||||
|
with _LOGIN_RATE_LOCK:
|
||||||
|
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
||||||
|
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
||||||
|
_prune_attempts(ip_bucket, now, window)
|
||||||
|
_prune_attempts(user_bucket, now, window)
|
||||||
|
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
|
||||||
|
retry_after = 1
|
||||||
|
if exceeded:
|
||||||
|
retry_candidates = []
|
||||||
|
if ip_bucket:
|
||||||
|
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
|
||||||
|
if user_bucket:
|
||||||
|
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
|
||||||
|
if retry_candidates:
|
||||||
|
retry_after = max(retry_candidates)
|
||||||
|
if exceeded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many login attempts. Try again shortly.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_username(value: str) -> str:
|
def _normalize_username(value: str) -> str:
|
||||||
@@ -84,6 +200,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
|
||||||
@@ -132,13 +271,229 @@ def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_positive_int(value: object, field_name: str) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc
|
||||||
|
if parsed <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"{field_name} must be greater than 0",
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_expires_at(value: object) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="expires_at must be an ISO datetime string",
|
||||||
|
)
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="expires_at must be a valid ISO datetime",
|
||||||
|
) from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_invite_code(value: str | None) -> str:
|
||||||
|
raw = (value or "").strip().upper()
|
||||||
|
filtered = "".join(ch for ch in raw if ch.isalnum())
|
||||||
|
if len(filtered) < 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invite code must be at least 6 letters/numbers.",
|
||||||
|
)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_invite_code(length: int = 12) -> str:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def _same_username(a: object, b: object) -> bool:
|
||||||
|
if not isinstance(a, str) or not isinstance(b, str):
|
||||||
|
return False
|
||||||
|
return a.strip().lower() == b.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_self_invite(invite: dict) -> dict:
|
||||||
|
profile = None
|
||||||
|
profile_id = invite.get("profile_id")
|
||||||
|
if profile_id is not None:
|
||||||
|
try:
|
||||||
|
profile = get_user_profile(int(profile_id))
|
||||||
|
except Exception:
|
||||||
|
profile = None
|
||||||
|
return {
|
||||||
|
"id": invite.get("id"),
|
||||||
|
"code": invite.get("code"),
|
||||||
|
"label": invite.get("label"),
|
||||||
|
"description": invite.get("description"),
|
||||||
|
"profile_id": invite.get("profile_id"),
|
||||||
|
"profile": (
|
||||||
|
{"id": profile.get("id"), "name": profile.get("name")}
|
||||||
|
if isinstance(profile, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"role": invite.get("role"),
|
||||||
|
"max_uses": invite.get("max_uses"),
|
||||||
|
"use_count": invite.get("use_count", 0),
|
||||||
|
"remaining_uses": invite.get("remaining_uses"),
|
||||||
|
"enabled": bool(invite.get("enabled")),
|
||||||
|
"expires_at": invite.get("expires_at"),
|
||||||
|
"is_expired": bool(invite.get("is_expired")),
|
||||||
|
"is_usable": bool(invite.get("is_usable")),
|
||||||
|
"created_at": invite.get("created_at"),
|
||||||
|
"updated_at": invite.get("updated_at"),
|
||||||
|
"created_by": invite.get("created_by"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_invites(username: str) -> list[dict]:
|
||||||
|
owned = [
|
||||||
|
invite
|
||||||
|
for invite in list_signup_invites()
|
||||||
|
if _same_username(invite.get("created_by"), username)
|
||||||
|
]
|
||||||
|
owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True)
|
||||||
|
return owned
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
|
||||||
|
invite = get_signup_invite_by_id(invite_id)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
if not _same_username(invite.get("created_by"), current_user.get("username")):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites")
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
def _self_service_invite_access_enabled(current_user: dict) -> bool:
|
||||||
|
if str(current_user.get("role") or "").lower() == "admin":
|
||||||
|
return True
|
||||||
|
return bool(current_user.get("invite_management_enabled", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_self_service_invite_access(current_user: dict) -> None:
|
||||||
|
if _self_service_invite_access_enabled(current_user):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invite management is not enabled for your account.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_self_service_master_invite() -> dict | None:
|
||||||
|
raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||||
|
if raw_value is None:
|
||||||
|
return None
|
||||||
|
candidate = str(raw_value).strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
invite_id = int(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if invite_id <= 0:
|
||||||
|
return None
|
||||||
|
return get_signup_invite_by_id(invite_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
||||||
|
if not isinstance(invite, dict):
|
||||||
|
return None
|
||||||
|
profile = None
|
||||||
|
profile_id = invite.get("profile_id")
|
||||||
|
if isinstance(profile_id, int):
|
||||||
|
profile = get_user_profile(profile_id)
|
||||||
|
return {
|
||||||
|
"id": invite.get("id"),
|
||||||
|
"code": invite.get("code"),
|
||||||
|
"label": invite.get("label"),
|
||||||
|
"description": invite.get("description"),
|
||||||
|
"profile_id": invite.get("profile_id"),
|
||||||
|
"profile": (
|
||||||
|
{"id": profile.get("id"), "name": profile.get("name")}
|
||||||
|
if isinstance(profile, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"role": invite.get("role"),
|
||||||
|
"max_uses": invite.get("max_uses"),
|
||||||
|
"enabled": bool(invite.get("enabled")),
|
||||||
|
"expires_at": invite.get("expires_at"),
|
||||||
|
"is_expired": bool(invite.get("is_expired")),
|
||||||
|
"is_usable": bool(invite.get("is_usable")),
|
||||||
|
"created_at": invite.get("created_at"),
|
||||||
|
"updated_at": invite.get("updated_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
|
||||||
|
profile_id_raw = master_invite.get("profile_id")
|
||||||
|
profile_id: int | None = None
|
||||||
|
if isinstance(profile_id_raw, int):
|
||||||
|
profile_id = profile_id_raw
|
||||||
|
elif profile_id_raw not in (None, ""):
|
||||||
|
try:
|
||||||
|
profile_id = int(profile_id_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
profile_id = None
|
||||||
|
role_value = str(master_invite.get("role") or "").strip().lower()
|
||||||
|
role = role_value if role_value in {"user", "admin"} else "user"
|
||||||
|
max_uses_raw = master_invite.get("max_uses")
|
||||||
|
try:
|
||||||
|
max_uses = int(max_uses_raw) if max_uses_raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_uses = None
|
||||||
|
enabled = bool(master_invite.get("enabled", True))
|
||||||
|
expires_at_value = master_invite.get("expires_at")
|
||||||
|
expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
|
||||||
|
return profile_id, role, max_uses, enabled, expires_at
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
|
# Provider placeholder passwords must never be accepted by the local-login endpoint.
|
||||||
|
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
|
||||||
|
_record_login_failure(request, form_data.username)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
matching_users = get_users_by_username_ci(form_data.username)
|
||||||
|
has_external_match = any(
|
||||||
|
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
|
||||||
|
)
|
||||||
|
if has_external_match:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||||
|
)
|
||||||
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:
|
||||||
|
_record_login_failure(request, form_data.username)
|
||||||
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"])
|
||||||
|
_clear_login_failures(request, form_data.username)
|
||||||
set_last_login(user["username"])
|
set_last_login(user["username"])
|
||||||
return {
|
return {
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
@@ -148,7 +503,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/jellyfin/login")
|
@router.post("/jellyfin/login")
|
||||||
async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
if not client.configured():
|
if not client.configured():
|
||||||
@@ -157,45 +513,62 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
|
|||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||||
username = form_data.username
|
username = form_data.username
|
||||||
password = form_data.password
|
password = form_data.password
|
||||||
user = get_user_by_username(username)
|
ci_matches = get_users_by_username_ci(username)
|
||||||
|
preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
|
||||||
|
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
|
||||||
|
user = preferred_match or get_user_by_username(username)
|
||||||
_assert_user_can_login(user)
|
_assert_user_can_login(user)
|
||||||
if user and _has_valid_jellyfin_cache(user, password):
|
if user and _has_valid_jellyfin_cache(user, password):
|
||||||
token = create_access_token(username, "user")
|
token = create_access_token(canonical_username, "user")
|
||||||
set_last_login(username)
|
_clear_login_failures(request, username)
|
||||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
set_last_login(canonical_username)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {"username": canonical_username, "role": "user"},
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
response = await client.authenticate_by_name(username, password)
|
response = await client.authenticate_by_name(username, password)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
if not isinstance(response, dict) or not response.get("User"):
|
if not isinstance(response, dict) or not response.get("User"):
|
||||||
|
_record_login_failure(request, username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||||
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
if not preferred_match:
|
||||||
user = get_user_by_username(username)
|
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||||
|
elif (
|
||||||
|
user
|
||||||
|
and str(user.get("role") or "user").strip().lower() != "admin"
|
||||||
|
and str(user.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
||||||
|
):
|
||||||
|
set_user_auth_provider(canonical_username, "jellyfin")
|
||||||
|
user = get_user_by_username(canonical_username)
|
||||||
|
user = get_user_by_username(canonical_username)
|
||||||
_assert_user_can_login(user)
|
_assert_user_can_login(user)
|
||||||
try:
|
try:
|
||||||
users = await client.get_users()
|
users = await client.get_users()
|
||||||
if isinstance(users, list):
|
if isinstance(users, list):
|
||||||
save_jellyfin_users_cache(users)
|
save_jellyfin_users_cache(users)
|
||||||
for jellyfin_user in users:
|
|
||||||
if not isinstance(jellyfin_user, dict):
|
|
||||||
continue
|
|
||||||
name = jellyfin_user.get("Name")
|
|
||||||
if isinstance(name, str) and name:
|
|
||||||
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
set_jellyfin_auth_cache(username, password)
|
set_jellyfin_auth_cache(canonical_username, password)
|
||||||
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
||||||
matched_id = match_jellyseerr_user_id(username, candidate_map)
|
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||||
if matched_id is not None:
|
if matched_id is not None:
|
||||||
set_user_jellyseerr_id(username, matched_id)
|
set_user_jellyseerr_id(canonical_username, matched_id)
|
||||||
token = create_access_token(username, "user")
|
token = create_access_token(canonical_username, "user")
|
||||||
set_last_login(username)
|
_clear_login_failures(request, username)
|
||||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
set_last_login(canonical_username)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {"username": canonical_username, "role": "user"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jellyseerr/login")
|
@router.post("/jellyseerr/login")
|
||||||
async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
|
_enforce_login_rate_limit(request, form_data.username)
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
if not client.configured():
|
if not client.configured():
|
||||||
@@ -206,22 +579,32 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
if not isinstance(response, dict):
|
if not isinstance(response, dict):
|
||||||
|
_record_login_failure(request, form_data.username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
||||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||||
create_user_if_missing(
|
ci_matches = get_users_by_username_ci(form_data.username)
|
||||||
form_data.username,
|
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||||
"jellyseerr-user",
|
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||||
role="user",
|
if not preferred_match:
|
||||||
auth_provider="jellyseerr",
|
create_user_if_missing(
|
||||||
jellyseerr_user_id=jellyseerr_user_id,
|
canonical_username,
|
||||||
)
|
"jellyseerr-user",
|
||||||
user = get_user_by_username(form_data.username)
|
role="user",
|
||||||
|
auth_provider="jellyseerr",
|
||||||
|
jellyseerr_user_id=jellyseerr_user_id,
|
||||||
|
)
|
||||||
|
user = get_user_by_username(canonical_username)
|
||||||
_assert_user_can_login(user)
|
_assert_user_can_login(user)
|
||||||
if jellyseerr_user_id is not None:
|
if jellyseerr_user_id is not None:
|
||||||
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
|
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
|
||||||
token = create_access_token(form_data.username, "user")
|
token = create_access_token(canonical_username, "user")
|
||||||
set_last_login(form_data.username)
|
_clear_login_failures(request, form_data.username)
|
||||||
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
|
set_last_login(canonical_username)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {"username": canonical_username, "role": "user"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
@@ -229,6 +612,20 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream-token")
|
||||||
|
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
token = create_stream_token(
|
||||||
|
current_user["username"],
|
||||||
|
current_user["role"],
|
||||||
|
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"stream_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": STREAM_TOKEN_TTL_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invites/{code}")
|
@router.get("/invites/{code}")
|
||||||
async def invite_details(code: str) -> dict:
|
async def invite_details(code: str) -> dict:
|
||||||
invite = get_signup_invite_by_code(code.strip())
|
invite = get_signup_invite_by_code(code.strip())
|
||||||
@@ -299,12 +696,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 +761,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 +779,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,
|
||||||
},
|
},
|
||||||
@@ -356,13 +812,160 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile/invites")
|
||||||
|
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
invite_access_enabled = _self_service_invite_access_enabled(current_user)
|
||||||
|
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
|
||||||
|
return {
|
||||||
|
"invites": invites,
|
||||||
|
"count": len(invites),
|
||||||
|
"invite_access": {
|
||||||
|
"enabled": invite_access_enabled,
|
||||||
|
"managed_by_master": bool(master_invite),
|
||||||
|
},
|
||||||
|
"master_invite": _serialize_self_service_master_invite(master_invite),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile/invites")
|
||||||
|
async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
|
||||||
|
requested_code = payload.get("code")
|
||||||
|
if isinstance(requested_code, str) and requested_code.strip():
|
||||||
|
code = _normalize_invite_code(requested_code)
|
||||||
|
existing = get_signup_invite_by_code(code)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
|
||||||
|
else:
|
||||||
|
code = ""
|
||||||
|
for _ in range(20):
|
||||||
|
candidate = _generate_invite_code()
|
||||||
|
if not get_signup_invite_by_code(candidate):
|
||||||
|
code = candidate
|
||||||
|
break
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code")
|
||||||
|
|
||||||
|
label = payload.get("label")
|
||||||
|
description = payload.get("description")
|
||||||
|
if label is not None:
|
||||||
|
label = str(label).strip() or None
|
||||||
|
if description is not None:
|
||||||
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
if master_invite:
|
||||||
|
if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
|
||||||
|
)
|
||||||
|
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||||
|
if profile_id is not None and not get_user_profile(profile_id):
|
||||||
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
|
enabled = bool(payload.get("enabled", True))
|
||||||
|
profile_id = current_user.get("profile_id")
|
||||||
|
if not isinstance(profile_id, int) or profile_id <= 0:
|
||||||
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
|
||||||
|
invite = create_signup_invite(
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
max_uses=max_uses,
|
||||||
|
enabled=enabled,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_by=username,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile/invites/{invite_id}")
|
||||||
|
async def update_profile_invite(
|
||||||
|
invite_id: int, payload: dict, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
|
existing = _get_owned_invite(invite_id, current_user)
|
||||||
|
|
||||||
|
requested_code = payload.get("code", existing.get("code"))
|
||||||
|
if isinstance(requested_code, str) and requested_code.strip():
|
||||||
|
code = _normalize_invite_code(requested_code)
|
||||||
|
else:
|
||||||
|
code = str(existing.get("code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
|
||||||
|
duplicate = get_signup_invite_by_code(code)
|
||||||
|
if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
|
||||||
|
|
||||||
|
label = payload.get("label", existing.get("label"))
|
||||||
|
description = payload.get("description", existing.get("description"))
|
||||||
|
if label is not None:
|
||||||
|
label = str(label).strip() or None
|
||||||
|
if description is not None:
|
||||||
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
if master_invite:
|
||||||
|
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||||
|
if profile_id is not None and not get_user_profile(profile_id):
|
||||||
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
||||||
|
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
||||||
|
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
||||||
|
enabled = bool(enabled_raw)
|
||||||
|
profile_id = existing.get("profile_id")
|
||||||
|
role = existing.get("role")
|
||||||
|
|
||||||
|
invite = update_signup_invite(
|
||||||
|
invite_id,
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
max_uses=max_uses,
|
||||||
|
enabled=enabled,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profile/invites/{invite_id}")
|
||||||
|
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
|
_get_owned_invite(invite_id, current_user)
|
||||||
|
deleted = delete_signup_invite(invite_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password")
|
@router.post("/password")
|
||||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
if current_user.get("auth_provider") != "local":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Password changes are only available for local users.",
|
|
||||||
)
|
|
||||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||||
@@ -371,8 +974,64 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||||
)
|
)
|
||||||
user = verify_user_password(current_user["username"], current_password)
|
username = str(current_user.get("username") or "").strip()
|
||||||
if not user:
|
auth_provider = str(current_user.get("auth_provider") or "local").lower()
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
if not username:
|
||||||
set_user_password(current_user["username"], new_password.strip())
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
return {"status": "ok"}
|
new_password_clean = new_password.strip()
|
||||||
|
|
||||||
|
if auth_provider == "local":
|
||||||
|
user = verify_user_password(username, current_password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||||
|
set_user_password(username, new_password_clean)
|
||||||
|
return {"status": "ok", "provider": "local"}
|
||||||
|
|
||||||
|
if auth_provider == "jellyfin":
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Jellyfin is not configured for password passthrough.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
auth_result = await client.authenticate_by_name(username, current_password)
|
||||||
|
if not isinstance(auth_result, dict) or not auth_result.get("User"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
detail = _extract_http_error_detail(exc)
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Jellyfin password validation failed: {detail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
jf_user = await client.find_user_by_name(username)
|
||||||
|
user_id = client._extract_user_id(jf_user)
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Jellyfin user ID not found")
|
||||||
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = _extract_http_error_detail(exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Jellyfin password update failed: {detail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
|
||||||
|
set_jellyfin_auth_cache(username, new_password_clean)
|
||||||
|
return {"status": "ok", "provider": "jellyfin"}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Password changes are not available for this sign-in provider.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
from ..auth import get_current_user_event_stream
|
||||||
@@ -20,6 +20,58 @@ def _sse_json(payload: Dict[str, Any]) -> str:
|
|||||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
try:
|
||||||
|
return value.model_dump(mode="json")
|
||||||
|
except TypeError:
|
||||||
|
return value.model_dump()
|
||||||
|
if hasattr(value, "dict"):
|
||||||
|
try:
|
||||||
|
return value.dict()
|
||||||
|
except TypeError:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"request_id": entry.get("request_id"),
|
||||||
|
"state": entry.get("state"),
|
||||||
|
"state_reason": entry.get("state_reason"),
|
||||||
|
"created_at": entry.get("created_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"request_id": entry.get("request_id"),
|
||||||
|
"action_id": entry.get("action_id"),
|
||||||
|
"label": entry.get("label"),
|
||||||
|
"status": entry.get("status"),
|
||||||
|
"message": entry.get("message"),
|
||||||
|
"created_at": entry.get("created_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def events_stream(
|
async def events_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -110,3 +162,88 @@ async def events_stream(
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
}
|
}
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/stream")
|
||||||
|
async def request_events_stream(
|
||||||
|
request_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
request_id = str(request_id).strip()
|
||||||
|
if not request_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing request id")
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
yield "retry: 2000\n\n"
|
||||||
|
last_signature: Optional[str] = None
|
||||||
|
next_refresh_at = 0.0
|
||||||
|
heartbeat_counter = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
sent_any = False
|
||||||
|
|
||||||
|
if now >= next_refresh_at:
|
||||||
|
next_refresh_at = now + 2.0
|
||||||
|
try:
|
||||||
|
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
||||||
|
history_payload = await requests_router.request_history(
|
||||||
|
request_id=request_id, limit=5, user=user
|
||||||
|
)
|
||||||
|
actions_payload = await requests_router.request_actions(
|
||||||
|
request_id=request_id, limit=5, user=user
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"snapshot": _jsonable(snapshot),
|
||||||
|
"history": _request_history_brief(
|
||||||
|
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
||||||
|
),
|
||||||
|
"actions": _request_actions_brief(
|
||||||
|
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except HTTPException as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(exc.detail),
|
||||||
|
"status_code": int(exc.status_code),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||||
|
if signature != last_signature:
|
||||||
|
last_signature = signature
|
||||||
|
yield _sse_json(payload)
|
||||||
|
sent_any = True
|
||||||
|
|
||||||
|
if sent_any:
|
||||||
|
heartbeat_counter = 0
|
||||||
|
else:
|
||||||
|
heartbeat_counter += 1
|
||||||
|
if heartbeat_counter >= 15:
|
||||||
|
yield ": ping\n\n"
|
||||||
|
heartbeat_counter = 0
|
||||||
|
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
webhook_url = runtime.discord_webhook_url
|
webhook_url = (
|
||||||
|
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
||||||
|
or runtime.discord_webhook_url
|
||||||
|
)
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from .config import settings
|
|||||||
from .db import get_settings_overrides
|
from .db import get_settings_overrides
|
||||||
|
|
||||||
_INT_FIELDS = {
|
_INT_FIELDS = {
|
||||||
|
"magent_application_port",
|
||||||
|
"magent_api_port",
|
||||||
"sonarr_quality_profile_id",
|
"sonarr_quality_profile_id",
|
||||||
"radarr_quality_profile_id",
|
"radarr_quality_profile_id",
|
||||||
"jwt_exp_minutes",
|
"jwt_exp_minutes",
|
||||||
@@ -9,8 +11,20 @@ _INT_FIELDS = {
|
|||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
|
"magent_notify_email_smtp_port",
|
||||||
}
|
}
|
||||||
_BOOL_FIELDS = {
|
_BOOL_FIELDS = {
|
||||||
|
"magent_proxy_enabled",
|
||||||
|
"magent_proxy_trust_forwarded_headers",
|
||||||
|
"magent_ssl_bind_enabled",
|
||||||
|
"magent_notify_enabled",
|
||||||
|
"magent_notify_email_enabled",
|
||||||
|
"magent_notify_email_use_tls",
|
||||||
|
"magent_notify_email_use_ssl",
|
||||||
|
"magent_notify_discord_enabled",
|
||||||
|
"magent_notify_telegram_enabled",
|
||||||
|
"magent_notify_push_enabled",
|
||||||
|
"magent_notify_webhook_enabled",
|
||||||
"jellyfin_sync_to_arr",
|
"jellyfin_sync_to_arr",
|
||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,30 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
return _pwd_context.verify(plain_password, hashed_password)
|
return _pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_token(
|
||||||
|
subject: str,
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
expires_at: datetime,
|
||||||
|
token_type: str = "access",
|
||||||
|
) -> str:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"sub": subject,
|
||||||
|
"role": role,
|
||||||
|
"typ": token_type,
|
||||||
|
"exp": expires_at,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
|
||||||
|
|
||||||
|
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
||||||
|
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Dict[str, Any]:
|
def decode_token(token: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import logging
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import create_user_if_missing, set_user_jellyseerr_id
|
from ..db import (
|
||||||
|
create_user_if_missing,
|
||||||
|
get_user_by_username,
|
||||||
|
set_user_auth_provider,
|
||||||
|
set_user_jellyseerr_id,
|
||||||
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .user_cache import (
|
from .user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
build_jellyseerr_candidate_map,
|
||||||
@@ -24,6 +29,8 @@ async def sync_jellyfin_users() -> int:
|
|||||||
if not isinstance(users, list):
|
if not isinstance(users, list):
|
||||||
return 0
|
return 0
|
||||||
save_jellyfin_users_cache(users)
|
save_jellyfin_users_cache(users)
|
||||||
|
# Jellyfin is the canonical source for local user objects; Jellyseerr IDs are
|
||||||
|
# matched as enrichment when possible.
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
jellyseerr_users = get_cached_jellyseerr_users()
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||||
imported = 0
|
imported = 0
|
||||||
@@ -43,8 +50,16 @@ async def sync_jellyfin_users() -> int:
|
|||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
imported += 1
|
imported += 1
|
||||||
elif matched_id is not None:
|
else:
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
existing = get_user_by_username(name)
|
||||||
|
if (
|
||||||
|
existing
|
||||||
|
and str(existing.get("role") or "user").strip().lower() != "admin"
|
||||||
|
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
||||||
|
):
|
||||||
|
set_user_auth_provider(name, "jellyfin")
|
||||||
|
if matched_id is not None:
|
||||||
|
set_user_jellyseerr_id(name, matched_id)
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
|
|
||||||
type AdminSetting = {
|
type AdminSetting = {
|
||||||
@@ -19,6 +19,9 @@ type ServiceOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
magent: 'Magent',
|
||||||
|
general: 'General',
|
||||||
|
notifications: 'Notifications',
|
||||||
jellyseerr: 'Jellyseerr',
|
jellyseerr: 'Jellyseerr',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
artwork: 'Artwork cache',
|
artwork: 'Artwork cache',
|
||||||
@@ -32,9 +35,34 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
site: 'Site',
|
site: 'Site',
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
|
const BOOL_SETTINGS = new Set([
|
||||||
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
|
'jellyfin_sync_to_arr',
|
||||||
|
'site_banner_enabled',
|
||||||
|
'magent_proxy_enabled',
|
||||||
|
'magent_proxy_trust_forwarded_headers',
|
||||||
|
'magent_ssl_bind_enabled',
|
||||||
|
'magent_notify_enabled',
|
||||||
|
'magent_notify_email_enabled',
|
||||||
|
'magent_notify_email_use_tls',
|
||||||
|
'magent_notify_email_use_ssl',
|
||||||
|
'magent_notify_discord_enabled',
|
||||||
|
'magent_notify_telegram_enabled',
|
||||||
|
'magent_notify_push_enabled',
|
||||||
|
'magent_notify_webhook_enabled',
|
||||||
|
])
|
||||||
|
const TEXTAREA_SETTINGS = new Set([
|
||||||
|
'site_banner_message',
|
||||||
|
'site_changelog',
|
||||||
|
'magent_ssl_certificate_pem',
|
||||||
|
'magent_ssl_private_key_pem',
|
||||||
|
])
|
||||||
const URL_SETTINGS = new Set([
|
const URL_SETTINGS = new Set([
|
||||||
|
'magent_application_url',
|
||||||
|
'magent_api_url',
|
||||||
|
'magent_proxy_base_url',
|
||||||
|
'magent_notify_discord_webhook_url',
|
||||||
|
'magent_notify_push_base_url',
|
||||||
|
'magent_notify_webhook_url',
|
||||||
'jellyseerr_base_url',
|
'jellyseerr_base_url',
|
||||||
'jellyfin_base_url',
|
'jellyfin_base_url',
|
||||||
'jellyfin_public_url',
|
'jellyfin_public_url',
|
||||||
@@ -43,9 +71,24 @@ const URL_SETTINGS = new Set([
|
|||||||
'prowlarr_base_url',
|
'prowlarr_base_url',
|
||||||
'qbittorrent_base_url',
|
'qbittorrent_base_url',
|
||||||
])
|
])
|
||||||
|
const NUMBER_SETTINGS = new Set([
|
||||||
|
'magent_application_port',
|
||||||
|
'magent_api_port',
|
||||||
|
'magent_notify_email_smtp_port',
|
||||||
|
'requests_sync_ttl_minutes',
|
||||||
|
'requests_poll_interval_seconds',
|
||||||
|
'requests_delta_sync_interval_minutes',
|
||||||
|
'requests_cleanup_days',
|
||||||
|
])
|
||||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||||
|
|
||||||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
magent:
|
||||||
|
'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
|
||||||
|
general:
|
||||||
|
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
|
||||||
|
notifications:
|
||||||
|
'Notification providers and delivery channel settings used by Magent messaging features.',
|
||||||
jellyseerr: 'Connect the request system where users submit content.',
|
jellyseerr: 'Connect the request system where users submit content.',
|
||||||
jellyfin: 'Control Jellyfin login and availability checks.',
|
jellyfin: 'Control Jellyfin login and availability checks.',
|
||||||
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
||||||
@@ -60,6 +103,9 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||||
|
magent: 'magent',
|
||||||
|
general: 'magent',
|
||||||
|
notifications: 'magent',
|
||||||
jellyseerr: 'jellyseerr',
|
jellyseerr: 'jellyseerr',
|
||||||
jellyfin: 'jellyfin',
|
jellyfin: 'jellyfin',
|
||||||
artwork: null,
|
artwork: null,
|
||||||
@@ -74,7 +120,162 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
|||||||
site: 'site',
|
site: 'site',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAGENT_SECTION_GROUPS: Array<{
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
keys: string[]
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'magent-runtime',
|
||||||
|
title: 'Application',
|
||||||
|
description:
|
||||||
|
'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
|
||||||
|
keys: [
|
||||||
|
'magent_application_url',
|
||||||
|
'magent_application_port',
|
||||||
|
'magent_api_url',
|
||||||
|
'magent_api_port',
|
||||||
|
'magent_bind_host',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-proxy',
|
||||||
|
title: 'Proxy',
|
||||||
|
description:
|
||||||
|
'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
|
||||||
|
keys: [
|
||||||
|
'magent_proxy_enabled',
|
||||||
|
'magent_proxy_base_url',
|
||||||
|
'magent_proxy_trust_forwarded_headers',
|
||||||
|
'magent_proxy_forwarded_prefix',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-ssl',
|
||||||
|
title: 'Manual SSL Bind',
|
||||||
|
description:
|
||||||
|
'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
|
||||||
|
keys: [
|
||||||
|
'magent_ssl_bind_enabled',
|
||||||
|
'magent_ssl_certificate_path',
|
||||||
|
'magent_ssl_private_key_path',
|
||||||
|
'magent_ssl_certificate_pem',
|
||||||
|
'magent_ssl_private_key_pem',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-core',
|
||||||
|
title: 'Notifications',
|
||||||
|
description:
|
||||||
|
'Global notification controls and provider-independent defaults used by Magent messaging features.',
|
||||||
|
keys: ['magent_notify_enabled'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-email',
|
||||||
|
title: 'Email',
|
||||||
|
description: 'SMTP configuration for email notifications.',
|
||||||
|
keys: [
|
||||||
|
'magent_notify_email_enabled',
|
||||||
|
'magent_notify_email_smtp_host',
|
||||||
|
'magent_notify_email_smtp_port',
|
||||||
|
'magent_notify_email_smtp_username',
|
||||||
|
'magent_notify_email_smtp_password',
|
||||||
|
'magent_notify_email_from_address',
|
||||||
|
'magent_notify_email_from_name',
|
||||||
|
'magent_notify_email_use_tls',
|
||||||
|
'magent_notify_email_use_ssl',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-discord',
|
||||||
|
title: 'Discord',
|
||||||
|
description: 'Webhook settings for Discord notifications and feedback routing.',
|
||||||
|
keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-telegram',
|
||||||
|
title: 'Telegram',
|
||||||
|
description: 'Bot token and chat target for Telegram notifications.',
|
||||||
|
keys: [
|
||||||
|
'magent_notify_telegram_enabled',
|
||||||
|
'magent_notify_telegram_bot_token',
|
||||||
|
'magent_notify_telegram_chat_id',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'magent-notify-push',
|
||||||
|
title: 'Push / Mobile',
|
||||||
|
description:
|
||||||
|
'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
|
||||||
|
keys: [
|
||||||
|
'magent_notify_push_enabled',
|
||||||
|
'magent_notify_push_provider',
|
||||||
|
'magent_notify_push_base_url',
|
||||||
|
'magent_notify_push_topic',
|
||||||
|
'magent_notify_push_token',
|
||||||
|
'magent_notify_push_user_key',
|
||||||
|
'magent_notify_push_device',
|
||||||
|
'magent_notify_webhook_enabled',
|
||||||
|
'magent_notify_webhook_url',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||||||
|
general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
|
||||||
|
notifications: new Set([
|
||||||
|
'magent-notify-core',
|
||||||
|
'magent-notify-email',
|
||||||
|
'magent-notify-discord',
|
||||||
|
'magent-notify-telegram',
|
||||||
|
'magent-notify-push',
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
magent_application_url: 'Application URL',
|
||||||
|
magent_application_port: 'Application port',
|
||||||
|
magent_api_url: 'API URL',
|
||||||
|
magent_api_port: 'API port',
|
||||||
|
magent_bind_host: 'Bind host',
|
||||||
|
magent_proxy_enabled: 'Proxy support enabled',
|
||||||
|
magent_proxy_base_url: 'Proxy base URL',
|
||||||
|
magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
|
||||||
|
magent_proxy_forwarded_prefix: 'Forwarded path prefix',
|
||||||
|
magent_ssl_bind_enabled: 'Manual SSL bind enabled',
|
||||||
|
magent_ssl_certificate_path: 'Certificate path',
|
||||||
|
magent_ssl_private_key_path: 'Private key path',
|
||||||
|
magent_ssl_certificate_pem: 'Certificate (PEM)',
|
||||||
|
magent_ssl_private_key_pem: 'Private key (PEM)',
|
||||||
|
magent_notify_enabled: 'Notifications enabled',
|
||||||
|
magent_notify_email_enabled: 'Email notifications enabled',
|
||||||
|
magent_notify_email_smtp_host: 'SMTP host',
|
||||||
|
magent_notify_email_smtp_port: 'SMTP port',
|
||||||
|
magent_notify_email_smtp_username: 'SMTP username',
|
||||||
|
magent_notify_email_smtp_password: 'SMTP password',
|
||||||
|
magent_notify_email_from_address: 'From email address',
|
||||||
|
magent_notify_email_from_name: 'From display name',
|
||||||
|
magent_notify_email_use_tls: 'Use STARTTLS',
|
||||||
|
magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
|
||||||
|
magent_notify_discord_enabled: 'Discord notifications enabled',
|
||||||
|
magent_notify_discord_webhook_url: 'Discord webhook URL',
|
||||||
|
magent_notify_telegram_enabled: 'Telegram notifications enabled',
|
||||||
|
magent_notify_telegram_bot_token: 'Telegram bot token',
|
||||||
|
magent_notify_telegram_chat_id: 'Telegram chat ID',
|
||||||
|
magent_notify_push_enabled: 'Push notifications enabled',
|
||||||
|
magent_notify_push_provider: 'Push provider',
|
||||||
|
magent_notify_push_base_url: 'Push provider/base URL',
|
||||||
|
magent_notify_push_topic: 'Topic / channel',
|
||||||
|
magent_notify_push_token: 'API token / password',
|
||||||
|
magent_notify_push_user_key: 'User key / recipient key',
|
||||||
|
magent_notify_push_device: 'Device / target',
|
||||||
|
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
||||||
|
magent_notify_webhook_url: 'Generic webhook URL',
|
||||||
|
}
|
||||||
|
|
||||||
const labelFromKey = (key: string) =>
|
const labelFromKey = (key: string) =>
|
||||||
|
SETTING_LABEL_OVERRIDES[key] ??
|
||||||
key
|
key
|
||||||
.replaceAll('_', ' ')
|
.replaceAll('_', ' ')
|
||||||
.replace('base url', 'URL')
|
.replace('base url', 'URL')
|
||||||
@@ -115,6 +316,13 @@ type SettingsPageProps = {
|
|||||||
section: string
|
section: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsSectionGroup = {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
items: AdminSetting[]
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [settings, setSettings] = useState<AdminSetting[]>([])
|
const [settings, setSettings] = useState<AdminSetting[]>([])
|
||||||
@@ -285,6 +493,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
||||||
|
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
const visibleSections = settingsSection ? [settingsSection] : []
|
||||||
const isCacheSection = section === 'cache'
|
const isCacheSection = section === 'cache'
|
||||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||||
@@ -308,26 +517,49 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
||||||
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
||||||
const settingsSections = isCacheSection
|
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||||||
? [
|
? [
|
||||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||||||
]
|
]
|
||||||
: visibleSections.map((sectionKey) => ({
|
: isMagentGroupedSection
|
||||||
key: sectionKey,
|
? (() => {
|
||||||
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
if (section === 'magent') {
|
||||||
items: (() => {
|
return []
|
||||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
|
||||||
const filtered =
|
|
||||||
sectionKey === 'requests' || sectionKey === 'artwork'
|
|
||||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
|
||||||
: sectionItems
|
|
||||||
if (sectionKey === 'requests') {
|
|
||||||
return sortByOrder(filtered, requestSettingOrder)
|
|
||||||
}
|
}
|
||||||
return filtered
|
const magentItems = groupedSettings.magent ?? []
|
||||||
})(),
|
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
||||||
}))
|
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||||||
|
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
||||||
|
allowedGroupKeys.has(group.key),
|
||||||
|
).map((group) => {
|
||||||
|
const items = group.keys
|
||||||
|
.map((key) => byKey.get(key))
|
||||||
|
.filter((item): item is AdminSetting => Boolean(item))
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
description: group.description,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})()
|
||||||
|
: visibleSections.map((sectionKey) => ({
|
||||||
|
key: sectionKey,
|
||||||
|
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
||||||
|
items: (() => {
|
||||||
|
const sectionItems = groupedSettings[sectionKey] ?? []
|
||||||
|
const filtered =
|
||||||
|
sectionKey === 'requests' || sectionKey === 'artwork'
|
||||||
|
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||||||
|
: sectionItems
|
||||||
|
if (sectionKey === 'requests') {
|
||||||
|
return sortByOrder(filtered, requestSettingOrder)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
})(),
|
||||||
|
}))
|
||||||
const showLogs = section === 'logs'
|
const showLogs = section === 'logs'
|
||||||
const showMaintenance = section === 'maintenance'
|
const showMaintenance = section === 'maintenance'
|
||||||
const showRequestsExtras = section === 'requests'
|
const showRequestsExtras = section === 'requests'
|
||||||
@@ -350,6 +582,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [artworkPrefetch])
|
}, [artworkPrefetch])
|
||||||
|
|
||||||
const settingDescriptions: Record<string, string> = {
|
const settingDescriptions: Record<string, string> = {
|
||||||
|
magent_application_url:
|
||||||
|
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
||||||
|
magent_application_port:
|
||||||
|
'Preferred frontend/UI port for local or direct-hosted deployments.',
|
||||||
|
magent_api_url:
|
||||||
|
'Canonical public URL for the Magent API when it differs from the app URL.',
|
||||||
|
magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
|
||||||
|
magent_bind_host:
|
||||||
|
'Host/IP to bind the application services to when running without an external process manager.',
|
||||||
|
magent_proxy_enabled:
|
||||||
|
'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
|
||||||
|
magent_proxy_base_url:
|
||||||
|
'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
|
||||||
|
magent_proxy_trust_forwarded_headers:
|
||||||
|
'Trust X-Forwarded-* headers from your reverse proxy.',
|
||||||
|
magent_proxy_forwarded_prefix:
|
||||||
|
'Optional path prefix added by your proxy (example: /magent).',
|
||||||
|
magent_ssl_bind_enabled:
|
||||||
|
'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
|
||||||
|
magent_ssl_certificate_path:
|
||||||
|
'Path to the TLS certificate file on disk (PEM).',
|
||||||
|
magent_ssl_private_key_path:
|
||||||
|
'Path to the TLS private key file on disk (PEM).',
|
||||||
|
magent_ssl_certificate_pem:
|
||||||
|
'Paste the TLS certificate PEM if you want Magent to store it directly.',
|
||||||
|
magent_ssl_private_key_pem:
|
||||||
|
'Paste the TLS private key PEM if you want Magent to store it directly.',
|
||||||
|
magent_notify_enabled:
|
||||||
|
'Master switch for Magent notifications. Individual provider toggles still apply.',
|
||||||
|
magent_notify_email_enabled: 'Enable SMTP email notifications.',
|
||||||
|
magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
|
||||||
|
magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
|
||||||
|
magent_notify_email_smtp_username: 'SMTP account username.',
|
||||||
|
magent_notify_email_smtp_password: 'SMTP account password or app password.',
|
||||||
|
magent_notify_email_from_address: 'Sender email address used by Magent.',
|
||||||
|
magent_notify_email_from_name: 'Sender display name shown to recipients.',
|
||||||
|
magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
|
||||||
|
magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
|
||||||
|
magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
|
||||||
|
magent_notify_discord_webhook_url:
|
||||||
|
'Discord channel webhook URL used for notifications and optional feedback routing.',
|
||||||
|
magent_notify_telegram_enabled: 'Enable Telegram notifications.',
|
||||||
|
magent_notify_telegram_bot_token: 'Bot token from BotFather.',
|
||||||
|
magent_notify_telegram_chat_id:
|
||||||
|
'Default Telegram chat/group/user ID for notifications.',
|
||||||
|
magent_notify_push_enabled: 'Enable generic push notifications.',
|
||||||
|
magent_notify_push_provider:
|
||||||
|
'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
|
||||||
|
magent_notify_push_base_url:
|
||||||
|
'Base URL for your push provider (for example ntfy/gotify server URL).',
|
||||||
|
magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
|
||||||
|
magent_notify_push_token: 'Provider token/API key/password.',
|
||||||
|
magent_notify_push_user_key:
|
||||||
|
'Provider recipient key/user key (for example Pushover user key).',
|
||||||
|
magent_notify_push_device:
|
||||||
|
'Optional device or target override, depending on provider.',
|
||||||
|
magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
|
||||||
|
magent_notify_webhook_url:
|
||||||
|
'Generic webhook endpoint for custom integrations or automation flows.',
|
||||||
jellyseerr_base_url:
|
jellyseerr_base_url:
|
||||||
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
||||||
jellyseerr_api_key: 'API key used to read requests and status.',
|
jellyseerr_api_key: 'API key used to read requests and status.',
|
||||||
@@ -397,6 +688,29 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settingPlaceholders: Record<string, string> = {
|
const settingPlaceholders: Record<string, string> = {
|
||||||
|
magent_application_url: 'https://magent.example.com',
|
||||||
|
magent_application_port: '3000',
|
||||||
|
magent_api_url: 'https://api.example.com or https://magent.example.com/api',
|
||||||
|
magent_api_port: '8000',
|
||||||
|
magent_bind_host: '0.0.0.0',
|
||||||
|
magent_proxy_base_url: 'https://proxy.example.com/magent',
|
||||||
|
magent_proxy_forwarded_prefix: '/magent',
|
||||||
|
magent_ssl_certificate_path: '/certs/fullchain.pem',
|
||||||
|
magent_ssl_private_key_path: '/certs/privkey.pem',
|
||||||
|
magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
|
||||||
|
magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
|
||||||
|
magent_notify_email_smtp_host: 'smtp.office365.com',
|
||||||
|
magent_notify_email_smtp_port: '587',
|
||||||
|
magent_notify_email_smtp_username: 'notifications@example.com',
|
||||||
|
magent_notify_email_from_address: 'notifications@example.com',
|
||||||
|
magent_notify_email_from_name: 'Magent',
|
||||||
|
magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
|
||||||
|
magent_notify_telegram_bot_token: '123456789:AA...',
|
||||||
|
magent_notify_telegram_chat_id: '-1001234567890',
|
||||||
|
magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
|
||||||
|
magent_notify_push_topic: 'magent-alerts',
|
||||||
|
magent_notify_push_device: 'iphone-zak',
|
||||||
|
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
|
||||||
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
|
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
|
||||||
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
||||||
jellyfin_public_url: 'https://jelly.example.com',
|
jellyfin_public_url: 'https://jelly.example.com',
|
||||||
@@ -599,83 +913,101 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('access_token', token)
|
|
||||||
if (showLogs) {
|
|
||||||
params.set('include_logs', '1')
|
|
||||||
params.set('log_lines', String(logsCount))
|
|
||||||
}
|
|
||||||
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const source = new EventSource(streamUrl)
|
let source: EventSource | null = null
|
||||||
|
|
||||||
source.onopen = () => {
|
const connect = async () => {
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const streamToken = await getEventStreamToken()
|
||||||
if (!payload || payload.type !== 'admin_live_state') {
|
if (closed) return
|
||||||
return
|
const params = new URLSearchParams()
|
||||||
|
params.set('stream_token', streamToken)
|
||||||
|
if (showLogs) {
|
||||||
|
params.set('include_logs', '1')
|
||||||
|
params.set('log_lines', String(logsCount))
|
||||||
|
}
|
||||||
|
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSync =
|
source.onmessage = (event) => {
|
||||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
if (closed) return
|
||||||
? payload.requestsSync
|
setLiveStreamConnected(true)
|
||||||
: null
|
try {
|
||||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
const payload = JSON.parse(event.data)
|
||||||
const prevSync = requestsSyncRef.current
|
if (!payload || payload.type !== 'admin_live_state') {
|
||||||
requestsSyncRef.current = nextSync
|
return
|
||||||
setRequestsSync(nextSync)
|
}
|
||||||
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
|
||||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawArtwork =
|
const rawSync =
|
||||||
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||||
? payload.artworkPrefetch
|
? payload.requestsSync
|
||||||
: null
|
: null
|
||||||
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||||
const prevArtwork = artworkPrefetchRef.current
|
const prevSync = requestsSyncRef.current
|
||||||
artworkPrefetchRef.current = nextArtwork
|
requestsSyncRef.current = nextSync
|
||||||
setArtworkPrefetch(nextArtwork)
|
setRequestsSync(nextSync)
|
||||||
if (
|
if (
|
||||||
prevArtwork?.status === 'running' &&
|
prevSync?.status === 'running' &&
|
||||||
nextArtwork?.status &&
|
nextSync?.status &&
|
||||||
nextArtwork.status !== 'running'
|
nextSync.status !== 'running'
|
||||||
) {
|
) {
|
||||||
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||||
if (showArtworkExtras) {
|
}
|
||||||
void loadArtworkSummary()
|
|
||||||
|
const rawArtwork =
|
||||||
|
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
||||||
|
? payload.artworkPrefetch
|
||||||
|
: null
|
||||||
|
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
||||||
|
const prevArtwork = artworkPrefetchRef.current
|
||||||
|
artworkPrefetchRef.current = nextArtwork
|
||||||
|
setArtworkPrefetch(nextArtwork)
|
||||||
|
if (
|
||||||
|
prevArtwork?.status === 'running' &&
|
||||||
|
nextArtwork?.status &&
|
||||||
|
nextArtwork.status !== 'running'
|
||||||
|
) {
|
||||||
|
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
||||||
|
if (showArtworkExtras) {
|
||||||
|
void loadArtworkSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.logs && typeof payload.logs === 'object') {
|
||||||
|
if (Array.isArray(payload.logs.lines)) {
|
||||||
|
setLogsLines(payload.logs.lines)
|
||||||
|
setLogsStatus(null)
|
||||||
|
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
||||||
|
setLogsStatus(payload.logs.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.logs && typeof payload.logs === 'object') {
|
source.onerror = () => {
|
||||||
if (Array.isArray(payload.logs.lines)) {
|
if (closed) return
|
||||||
setLogsLines(payload.logs.lines)
|
setLiveStreamConnected(false)
|
||||||
setLogsStatus(null)
|
|
||||||
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
|
||||||
setLogsStatus(payload.logs.error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (closed) return
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
setLiveStreamConnected(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.onerror = () => {
|
void connect()
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
closed = true
|
||||||
setLiveStreamConnected(false)
|
setLiveStreamConnected(false)
|
||||||
source.close()
|
source?.close()
|
||||||
}
|
}
|
||||||
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
|
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
|
||||||
|
|
||||||
@@ -930,6 +1262,86 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheSourceLabel =
|
||||||
|
formValues.requests_data_source === 'always_js'
|
||||||
|
? 'Jellyseerr direct'
|
||||||
|
: formValues.requests_data_source === 'prefer_cache'
|
||||||
|
? 'Saved requests only'
|
||||||
|
: 'Saved requests only'
|
||||||
|
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
|
||||||
|
const cacheRail = showCacheExtras ? (
|
||||||
|
<div className="admin-rail-stack">
|
||||||
|
<div className="admin-rail-card cache-rail-card">
|
||||||
|
<span className="admin-rail-eyebrow">Cache control</span>
|
||||||
|
<h2>Saved requests</h2>
|
||||||
|
<p>Load and inspect cached request entries from the right rail.</p>
|
||||||
|
<div className="cache-rail-metrics">
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Data source</span>
|
||||||
|
<strong>{cacheSourceLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Refresh TTL</span>
|
||||||
|
<strong>{cacheTtlLabel} min</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Rows loaded</span>
|
||||||
|
<strong>{cacheRows.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Live updates</span>
|
||||||
|
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="cache-rail-limit">
|
||||||
|
<span>Rows to load</span>
|
||||||
|
<select
|
||||||
|
value={cacheCount}
|
||||||
|
onChange={(event) => setCacheCount(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={loadCache} disabled={cacheLoading}>
|
||||||
|
{cacheLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner button-spinner" aria-hidden="true" />
|
||||||
|
Loading saved requests
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load saved requests'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="admin-rail-card cache-rail-card">
|
||||||
|
<span className="admin-rail-eyebrow">Artwork</span>
|
||||||
|
<h2>Cache stats</h2>
|
||||||
|
<div className="cache-rail-metrics">
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Missing artwork</span>
|
||||||
|
<strong>{artworkSummary?.missing_artwork ?? '--'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Cache size</span>
|
||||||
|
<strong>{formatBytes(artworkSummary?.cache_bytes)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Cached files</span>
|
||||||
|
<strong>{artworkSummary?.cache_files ?? '--'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-rail-metric">
|
||||||
|
<span>Mode</span>
|
||||||
|
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading admin settings...</main>
|
return <main className="card">Loading admin settings...</main>
|
||||||
}
|
}
|
||||||
@@ -938,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<AdminShell
|
<AdminShell
|
||||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||||
|
rail={cacheRail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
<button type="button" onClick={() => router.push('/admin')}>
|
||||||
Back to settings
|
Back to settings
|
||||||
@@ -1000,8 +1413,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
|
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||||||
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
|
(!settingsSection || isMagentGroupedSection) && (
|
||||||
|
<p className="section-subtitle">
|
||||||
|
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
|
||||||
|
<div className="status-banner">
|
||||||
|
Runtime host/port and SSL values are configuration settings. Container/process
|
||||||
|
restarts may still be required before bind/port changes take effect.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'sonarr' && sonarrError && (
|
{sectionGroup.key === 'sonarr' && sonarrError && (
|
||||||
<div className="error-banner">{sonarrError}</div>
|
<div className="error-banner">{sonarrError}</div>
|
||||||
@@ -1339,6 +1761,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (setting.key === 'magent_notify_push_provider') {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name={setting.key}
|
||||||
|
value={value || 'ntfy'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ntfy">ntfy</option>
|
||||||
|
<option value="gotify">Gotify</option>
|
||||||
|
<option value="pushover">Pushover</option>
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="telegram">Telegram relay</option>
|
||||||
|
<option value="discord">Discord relay</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
setting.key === 'requests_full_sync_time' ||
|
setting.key === 'requests_full_sync_time' ||
|
||||||
setting.key === 'requests_cleanup_time'
|
setting.key === 'requests_cleanup_time'
|
||||||
@@ -1365,10 +1816,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (NUMBER_SETTINGS.has(setting.key)) {
|
||||||
setting.key === 'requests_delta_sync_interval_minutes' ||
|
|
||||||
setting.key === 'requests_cleanup_days'
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
<span className="label-row">
|
<span className="label-row">
|
||||||
@@ -1381,6 +1829,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
name={setting.key}
|
name={setting.key}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
step={1}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormValues((current) => ({
|
setFormValues((current) => ({
|
||||||
@@ -1420,8 +1869,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (TEXTAREA_SETTINGS.has(setting.key)) {
|
if (TEXTAREA_SETTINGS.has(setting.key)) {
|
||||||
|
const isPemField =
|
||||||
|
setting.key === 'magent_ssl_certificate_pem' ||
|
||||||
|
setting.key === 'magent_ssl_private_key_pem'
|
||||||
return (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label
|
||||||
|
key={setting.key}
|
||||||
|
data-helper={helperText || undefined}
|
||||||
|
className={isPemField ? 'field-span-full' : undefined}
|
||||||
|
>
|
||||||
<span className="label-row">
|
<span className="label-row">
|
||||||
<span>{labelFromKey(setting.key)}</span>
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
<span className="meta">
|
<span className="meta">
|
||||||
@@ -1431,11 +1887,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
name={setting.key}
|
name={setting.key}
|
||||||
rows={setting.key === 'site_changelog' ? 6 : 3}
|
rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
|
||||||
placeholder={
|
placeholder={
|
||||||
setting.key === 'site_changelog'
|
setting.key === 'site_changelog'
|
||||||
? 'One update per line.'
|
? 'One update per line.'
|
||||||
: ''
|
: settingPlaceholders[setting.key] ?? ''
|
||||||
}
|
}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1483,7 +1939,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.
|
{section === 'magent'
|
||||||
|
? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.'
|
||||||
|
: 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
@@ -1516,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<section className="admin-section" id="cache">
|
<section className="admin-section" id="cache">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Saved requests (cache)</h2>
|
<h2>Saved requests (cache)</h2>
|
||||||
<div className="log-actions">
|
|
||||||
<label className="recent-filter">
|
|
||||||
<span>Rows to show</span>
|
|
||||||
<select
|
|
||||||
value={cacheCount}
|
|
||||||
onChange={(event) => setCacheCount(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={200}>200</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" onClick={loadCache} disabled={cacheLoading}>
|
|
||||||
{cacheLoading ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner button-spinner" aria-hidden="true" />
|
|
||||||
Loading saved requests
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Load saved requests'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
|
||||||
<div className="cache-table">
|
<div className="cache-table">
|
||||||
<div className="cache-row cache-head">
|
<div className="cache-row cache-head">
|
||||||
<span>Request</span>
|
<span>Request</span>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
|
'magent',
|
||||||
|
'general',
|
||||||
|
'notifications',
|
||||||
'site',
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
211
frontend/app/admin/system/page.tsx
Normal file
211
frontend/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import AdminShell from '../../ui/AdminShell'
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||||
|
|
||||||
|
type FlowStage = {
|
||||||
|
title: string
|
||||||
|
input: string
|
||||||
|
action: string
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_FLOW: FlowStage[] = [
|
||||||
|
{
|
||||||
|
title: 'Identity + access',
|
||||||
|
input: 'Jellyfin/local login',
|
||||||
|
action: 'Magent validates credentials and role',
|
||||||
|
output: 'JWT token + user scope',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Request intake',
|
||||||
|
input: 'Jellyseerr request ID',
|
||||||
|
action: 'Magent snapshots request + media metadata',
|
||||||
|
output: 'Unified request state',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Queue orchestration',
|
||||||
|
input: 'Approved request',
|
||||||
|
action: 'Sonarr/Radarr add/search operations',
|
||||||
|
output: 'Grab decision',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Download execution',
|
||||||
|
input: 'Selected release',
|
||||||
|
action: 'qBittorrent downloads + reports progress',
|
||||||
|
output: 'Import-ready payload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Library import',
|
||||||
|
input: 'Completed download',
|
||||||
|
action: 'Sonarr/Radarr import and finalize',
|
||||||
|
output: 'Available media object',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Playback availability',
|
||||||
|
input: 'Imported media',
|
||||||
|
action: 'Jellyfin refresh + link resolution',
|
||||||
|
output: 'Ready-to-watch state',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminSystemGuidePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
const load = async () => {
|
||||||
|
if (!getToken()) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const me = await response.json()
|
||||||
|
if (!active) return
|
||||||
|
if (me?.role !== 'admin') {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthorized(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
router.push('/')
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <main className="card">Loading system guide...</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rail = (
|
||||||
|
<div className="admin-rail-stack">
|
||||||
|
<div className="admin-rail-card">
|
||||||
|
<span className="admin-rail-eyebrow">Guide map</span>
|
||||||
|
<h2>Quick path</h2>
|
||||||
|
<p>Identity → Intake → Queue → Download → Import → Playback.</p>
|
||||||
|
<span className="small-pill">Admin only</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell
|
||||||
|
title="System guide"
|
||||||
|
subtitle="Admin-only architecture and operational flow for Magent."
|
||||||
|
rail={rail}
|
||||||
|
actions={
|
||||||
|
<button type="button" onClick={() => router.push('/admin')}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="admin-section system-guide">
|
||||||
|
<div className="admin-panel">
|
||||||
|
<h2>End-to-end system flow</h2>
|
||||||
|
<p className="lede">
|
||||||
|
This is the exact runtime path for request processing and availability in the current build.
|
||||||
|
</p>
|
||||||
|
<div className="system-flow-track">
|
||||||
|
{REQUEST_FLOW.map((stage, index) => (
|
||||||
|
<div key={stage.title} className="system-flow-segment">
|
||||||
|
<article className="system-flow-card">
|
||||||
|
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
|
||||||
|
<div className="system-flow-card-row">
|
||||||
|
<span>Input</span>
|
||||||
|
<strong>{stage.input}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="system-flow-card-row">
|
||||||
|
<span>Action</span>
|
||||||
|
<strong>{stage.action}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="system-flow-card-row">
|
||||||
|
<span>Output</span>
|
||||||
|
<strong>{stage.output}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true">→</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-panel">
|
||||||
|
<h2>Operational controls by area</h2>
|
||||||
|
<div className="system-guide-grid">
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>General</h3>
|
||||||
|
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
|
||||||
|
</article>
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
|
||||||
|
</article>
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>Users</h3>
|
||||||
|
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
|
||||||
|
</article>
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>Invite management</h3>
|
||||||
|
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
|
||||||
|
</article>
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>Requests + cache</h3>
|
||||||
|
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
||||||
|
</article>
|
||||||
|
<article className="system-guide-card">
|
||||||
|
<h3>Live request page</h3>
|
||||||
|
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-panel">
|
||||||
|
<h2>Stall recovery path (decision flow)</h2>
|
||||||
|
<ol className="system-decision-list">
|
||||||
|
<li>
|
||||||
|
Request approved but not in Arr queue <span>→</span> run <strong>Re-add to Arr</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
In queue but no release found <span>→</span> run <strong>Search releases</strong> and inspect options.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Release exists and user should not pick manually <span>→</span> run <strong>Search + auto-download</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Download paused/stalled in qBittorrent <span>→</span> run <strong>Resume download</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Imported but not visible to user <span>→</span> validate Jellyfin visibility/link from request page.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</AdminShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4193,7 +4193,7 @@ button:hover:not(:disabled) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-directory-search {
|
.user-directory-search {
|
||||||
@@ -4530,19 +4530,51 @@ button:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-admin-tabbar {
|
.invite-admin-tabbar {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px 12px;
|
gap: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-admin-tabbar .admin-segmented {
|
.invite-admin-tabbar .admin-segmented {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-admin-tab-actions {
|
.invite-admin-tab-actions {
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-bulk-panel .user-bulk-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-list-panel,
|
||||||
|
.invite-admin-form-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form-layout .invite-form-row-control > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form-layout .invite-form-row-control > label > span {
|
||||||
|
color: #9ea7b6;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form-layout .admin-inline-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4685,6 +4717,7 @@ button:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-admin-tabbar {
|
.invite-admin-tabbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4692,3 +4725,925 @@ button:hover:not(:disabled) {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enterprise UI tightening pass */
|
||||||
|
.admin-panel,
|
||||||
|
.user-detail-panel,
|
||||||
|
.user-directory-search-panel,
|
||||||
|
.user-directory-bulk-panel,
|
||||||
|
.user-directory-table,
|
||||||
|
.invite-admin-summary-panel,
|
||||||
|
.invite-admin-summary-row,
|
||||||
|
.invite-form-row,
|
||||||
|
.admin-list-item,
|
||||||
|
.status-banner,
|
||||||
|
.error-banner,
|
||||||
|
.admin-segmented {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-segmented button,
|
||||||
|
.small-pill,
|
||||||
|
.user-bulk-summary span,
|
||||||
|
.admin-inline-actions button,
|
||||||
|
.ghost-button,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-dropdown,
|
||||||
|
.modal-card,
|
||||||
|
.card,
|
||||||
|
.admin-card,
|
||||||
|
.summary-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-button,
|
||||||
|
.theme-toggle {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'filter controls'
|
||||||
|
'summary summary';
|
||||||
|
gap: 10px 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-filter {
|
||||||
|
grid-area: 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 {
|
||||||
|
grid-area: 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-controls {
|
||||||
|
grid-area: controls;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-scope {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-scope > span {
|
||||||
|
color: #9ea7b6;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-view-toggle button {
|
||||||
|
min-width: 90px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #aeb7c4;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-view-toggle button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-view-toggle button.is-active {
|
||||||
|
background: rgba(111, 148, 224, 0.22);
|
||||||
|
color: #eef3fb;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-map {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-graph {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(260px, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-column-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #b5c0d2;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-column-header strong {
|
||||||
|
color: #edf2f8;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-column-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-arrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #c4cfdd;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-arrow.is-root {
|
||||||
|
color: #95a2b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-meta-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-meta-item .label {
|
||||||
|
color: #8f9aac;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-node-meta-item strong {
|
||||||
|
color: #e7edf6;
|
||||||
|
font-size: 0.81rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
grid-template-areas:
|
||||||
|
'filter'
|
||||||
|
'controls'
|
||||||
|
'summary';
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-controls {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-summary {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-graph {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-node-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-trace-row-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile self-service invite management */
|
||||||
|
.profile-tabbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab-panel {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-form {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invites-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invites-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invites-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #edf2f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-lede,
|
||||||
|
.profile-invite-hint {
|
||||||
|
color: #9ea7b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-hint code {
|
||||||
|
color: #d8e2ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-master-banner code {
|
||||||
|
color: #e6eefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bulk-group-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bulk-group-meta strong {
|
||||||
|
color: #e7edf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin system guide */
|
||||||
|
.system-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-track {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-segment {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-card-title {
|
||||||
|
color: #edf2f8;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-card-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 86px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-card-row span {
|
||||||
|
color: #8f9aac;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-card-row strong {
|
||||||
|
color: #dfe8f5;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-flow-arrow {
|
||||||
|
color: #7ea1d8;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-guide-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-guide-card {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-guide-card h3 {
|
||||||
|
color: #eef3fb;
|
||||||
|
font-size: 0.97rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-guide-card p {
|
||||||
|
color: #a7b2c2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-decision-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-decision-list li {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d3dce9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-decision-list li span {
|
||||||
|
color: #7ea1d8;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-decision-list li strong {
|
||||||
|
color: #eff5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.profile-invites-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid label.field-span-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin shell right rail */
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: 3;
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rail-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rail-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.016);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rail-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rail-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #9ba5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-rail-eyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9ba5b5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail .invite-admin-summary-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail .invite-admin-summary-row__value {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-card {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-metrics {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.012);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-metric span {
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-metric strong {
|
||||||
|
color: #eef3f9;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-limit {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-rail-limit > span {
|
||||||
|
color: #9aa4b4;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Users page streamline pass */
|
||||||
|
.users-page-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.016);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9ba5b5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-actions button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-rail-summary .users-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-card {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.014);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-label {
|
||||||
|
color: #a9b3c2;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-value {
|
||||||
|
color: #edf3fb;
|
||||||
|
font-size: 1.12rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: #98a3b4;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-search-panel {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-summary strong {
|
||||||
|
line-height: 1.32;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-actions {
|
||||||
|
align-self: start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-actions button {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr) minmax(270px, 320px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-grid,
|
||||||
|
.users-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-toolbar-actions button {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-directory-bulk-panel .user-bulk-actions button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final header account menu stacking override (must be last) */
|
||||||
|
.page,
|
||||||
|
.header,
|
||||||
|
.header-left,
|
||||||
|
.header-right,
|
||||||
|
.header-nav,
|
||||||
|
.header-actions,
|
||||||
|
.signed-in-menu {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative !important;
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 20 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav,
|
||||||
|
.header-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions a,
|
||||||
|
.header-actions .header-link {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 4000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-menu {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 4500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-dropdown {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 5000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final width scaling */
|
||||||
|
.page {
|
||||||
|
width: min(1680px, calc(100vw - 32px));
|
||||||
|
max-width: 1680px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.page {
|
||||||
|
width: min(1480px, calc(100vw - 24px));
|
||||||
|
max-width: 1480px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: 2;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page {
|
||||||
|
width: min(100%, calc(100vw - 12px));
|
||||||
|
max-width: none;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export default function HowItWorksPage() {
|
|||||||
<main className="card how-page">
|
<main className="card how-page">
|
||||||
<header className="how-hero">
|
<header className="how-hero">
|
||||||
<p className="eyebrow">How this works</p>
|
<p className="eyebrow">How this works</p>
|
||||||
<h1>Your request, step by step</h1>
|
<h1>How Magent works now</h1>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
|
End-to-end request flow, live status updates, and the exact tools available to users and
|
||||||
your request is and what you can safely do next.
|
admins.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -52,90 +52,172 @@ export default function HowItWorksPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>The pipeline in plain English</h2>
|
<h2>The pipeline (request to ready)</h2>
|
||||||
<ol className="how-steps">
|
<ol className="how-steps">
|
||||||
<li>
|
<li>
|
||||||
<strong>You request a title</strong> in Jellyseerr.
|
<strong>Request created</strong> in Jellyseerr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Sonarr/Radarr adds it</strong> to the library list.
|
<strong>Approved</strong> and sent to Sonarr/Radarr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Prowlarr looks for sources</strong> and sends results back.
|
<strong>Search runs</strong> against indexers via Prowlarr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>qBittorrent downloads</strong> the match.
|
<strong>Grabbed</strong> and downloaded by qBittorrent.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Sonarr/Radarr imports</strong> it into your library.
|
<strong>Imported</strong> by Sonarr/Radarr.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Jellyfin shows it</strong> when it is ready to watch.
|
<strong>Available</strong> in Jellyfin.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>Steps and fixes (simple and visual)</h2>
|
<h2>Live updates (no refresh needed)</h2>
|
||||||
|
<div className="how-step-grid">
|
||||||
|
<article className="how-step-card step-arr">
|
||||||
|
<div className="step-badge">1</div>
|
||||||
|
<h3>Request page updates in real time</h3>
|
||||||
|
<p className="step-note">
|
||||||
|
Status, timeline hops, and action history update automatically while you are viewing
|
||||||
|
the request.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-step-card step-qbit">
|
||||||
|
<div className="step-badge">2</div>
|
||||||
|
<h3>Download progress updates live</h3>
|
||||||
|
<p className="step-note">
|
||||||
|
Torrent progress, queue state, and downloader details refresh automatically so users
|
||||||
|
do not need to hard refresh.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-step-card step-jellyfin">
|
||||||
|
<div className="step-badge">3</div>
|
||||||
|
<h3>Ready state appears as soon as import finishes</h3>
|
||||||
|
<p className="step-note">
|
||||||
|
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
|
||||||
|
shows it as ready.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="how-flow">
|
||||||
|
<h2>Request actions and when to use them</h2>
|
||||||
<div className="how-step-grid">
|
<div className="how-step-grid">
|
||||||
<article className="how-step-card step-jellyseerr">
|
<article className="how-step-card step-jellyseerr">
|
||||||
<div className="step-badge">1</div>
|
<div className="step-badge">1</div>
|
||||||
<h3>Request sent</h3>
|
<h3>Re-add to Arr</h3>
|
||||||
<p className="step-note">Jellyseerr holds your request and approval.</p>
|
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
|
||||||
<div className="step-fix-title">Fixes you can try</div>
|
<div className="step-fix-title">Best for</div>
|
||||||
<ul className="step-fix-list">
|
<ul className="step-fix-list">
|
||||||
<li>Add to library queue (if it was approved but never added)</li>
|
<li>Missing NEEDS_ADD / ADDED state transitions</li>
|
||||||
|
<li>Queue repair after Arr-side cleanup</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="how-step-card step-arr">
|
<article className="how-step-card step-arr">
|
||||||
<div className="step-badge">2</div>
|
<div className="step-badge">2</div>
|
||||||
<h3>Added to the library list</h3>
|
<h3>Search releases</h3>
|
||||||
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
|
<p className="step-note">Runs a search and shows concrete release options.</p>
|
||||||
<div className="step-fix-title">Fixes you can try</div>
|
<div className="step-fix-title">Best for</div>
|
||||||
<ul className="step-fix-list">
|
<ul className="step-fix-list">
|
||||||
<li>Search for releases (see options)</li>
|
<li>Manual selection of a specific release/indexer</li>
|
||||||
<li>Search and auto-download (let it pick for you)</li>
|
<li>Checking whether results currently exist</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="how-step-card step-prowlarr">
|
<article className="how-step-card step-prowlarr">
|
||||||
<div className="step-badge">3</div>
|
<div className="step-badge">3</div>
|
||||||
<h3>Searching for sources</h3>
|
<h3>Search + auto-download</h3>
|
||||||
<p className="step-note">Prowlarr checks your torrent providers.</p>
|
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
|
||||||
<div className="step-fix-title">Fixes you can try</div>
|
<div className="step-fix-title">Best for</div>
|
||||||
<ul className="step-fix-list">
|
<ul className="step-fix-list">
|
||||||
<li>Search for releases (show a list to choose)</li>
|
<li>Fast recovery when users have auto-search access</li>
|
||||||
|
<li>Hands-off retry of stalled requests</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="how-step-card step-qbit">
|
<article className="how-step-card step-qbit">
|
||||||
<div className="step-badge">4</div>
|
<div className="step-badge">4</div>
|
||||||
<h3>Downloading the file</h3>
|
<h3>Resume download</h3>
|
||||||
<p className="step-note">qBittorrent downloads the selected match.</p>
|
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
|
||||||
<div className="step-fix-title">Fixes you can try</div>
|
<div className="step-fix-title">Best for</div>
|
||||||
<ul className="step-fix-list">
|
<ul className="step-fix-list">
|
||||||
<li>Resume download (only if it already exists there)</li>
|
<li>Paused queue entries</li>
|
||||||
|
<li>Downloader restarts</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="how-step-card step-jellyfin">
|
<article className="how-step-card step-jellyfin">
|
||||||
<div className="step-badge">5</div>
|
<div className="step-badge">5</div>
|
||||||
<h3>Ready to watch</h3>
|
<h3>Open in Jellyfin</h3>
|
||||||
<p className="step-note">Jellyfin shows it in your library.</p>
|
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
|
||||||
<div className="step-fix-title">What to do next</div>
|
<div className="step-fix-title">Best for</div>
|
||||||
<ul className="step-fix-list">
|
<ul className="step-fix-list">
|
||||||
<li>Open in Jellyfin (watch it)</li>
|
<li>Immediate playback confirmation</li>
|
||||||
|
<li>User handoff from request tracking to watching</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="how-flow">
|
||||||
|
<h2>Invite and account flow</h2>
|
||||||
|
<ol className="how-steps">
|
||||||
|
<li>
|
||||||
|
<strong>Invite created</strong> by admin or eligible user.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>User signs up</strong> and Magent creates/links the account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Admin trace map</strong> can show inviter → invited lineage.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="how-flow">
|
||||||
|
<h2>Admin controls available</h2>
|
||||||
|
<div className="how-grid">
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>General</h3>
|
||||||
|
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>Users</h3>
|
||||||
|
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>Invite management</h3>
|
||||||
|
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>Request sync + cache</h3>
|
||||||
|
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
|
||||||
|
</article>
|
||||||
|
<article className="how-card">
|
||||||
|
<h3>Maintenance + logs</h3>
|
||||||
|
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="how-callout">
|
<section className="how-callout">
|
||||||
<h2>Why Magent sometimes says "waiting"</h2>
|
<h2>Why a request can still wait</h2>
|
||||||
<p>
|
<p>
|
||||||
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
|
If indexers do not return a valid release yet, Magent will show waiting/search states.
|
||||||
That does not mean it is broken. It usually means the release is not available yet.
|
That usually means content availability is the blocker, not a broken pipeline.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -23,3 +23,18 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|||||||
}
|
}
|
||||||
return fetch(input, { ...init, headers })
|
return fetch(input, { ...init, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getEventStreamToken = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/stream-token`)
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || `Stream token request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Stream token not returned')
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
||||||
|
|
||||||
const normalizeRecentResults = (items: any[]) =>
|
const normalizeRecentResults = (items: any[]) =>
|
||||||
items
|
items
|
||||||
@@ -210,64 +210,77 @@ export default function HomePage() {
|
|||||||
setLiveStreamConnected(false)
|
setLiveStreamConnected(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const token = getToken()
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
setLiveStreamConnected(false)
|
setLiveStreamConnected(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const source = new EventSource(streamUrl)
|
let source: EventSource | null = null
|
||||||
|
|
||||||
source.onopen = () => {
|
const connect = async () => {
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const streamToken = await getEventStreamToken()
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (closed) return
|
||||||
return
|
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_recent') {
|
|
||||||
if (Array.isArray(payload.results)) {
|
source.onmessage = (event) => {
|
||||||
setRecent(normalizeRecentResults(payload.results))
|
if (closed) return
|
||||||
setRecentError(null)
|
setLiveStreamConnected(true)
|
||||||
setRecentLoading(false)
|
try {
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
const payload = JSON.parse(event.data)
|
||||||
setRecentError('Recent requests are not available right now.')
|
if (!payload || typeof payload !== 'object') {
|
||||||
setRecentLoading(false)
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_recent') {
|
||||||
|
if (Array.isArray(payload.results)) {
|
||||||
|
setRecent(normalizeRecentResults(payload.results))
|
||||||
|
setRecentError(null)
|
||||||
|
setRecentLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setRecentError('Recent requests are not available right now.')
|
||||||
|
setRecentLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_services') {
|
||||||
|
if (payload.status && typeof payload.status === 'object') {
|
||||||
|
setServicesStatus(payload.status)
|
||||||
|
setServicesError(null)
|
||||||
|
setServicesLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setServicesError('Service status is not available right now.')
|
||||||
|
setServicesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_services') {
|
|
||||||
if (payload.status && typeof payload.status === 'object') {
|
source.onerror = () => {
|
||||||
setServicesStatus(payload.status)
|
if (closed) return
|
||||||
setServicesError(null)
|
setLiveStreamConnected(false)
|
||||||
setServicesLoading(false)
|
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
|
||||||
setServicesError('Service status is not available right now.')
|
|
||||||
setServicesLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (closed) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setLiveStreamConnected(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.onerror = () => {
|
void connect()
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
closed = true
|
||||||
setLiveStreamConnected(false)
|
setLiveStreamConnected(false)
|
||||||
source.close()
|
source?.close()
|
||||||
}
|
}
|
||||||
}, [authReady, recentDays])
|
}, [authReady, recentDays])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ type ProfileInfo = {
|
|||||||
username: string
|
username: string
|
||||||
role: string
|
role: string
|
||||||
auth_provider: string
|
auth_provider: string
|
||||||
|
invite_management_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileStats = {
|
type ProfileStats = {
|
||||||
@@ -47,6 +48,61 @@ type ProfileResponse = {
|
|||||||
activity: ProfileActivity
|
activity: ProfileActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OwnedInvite = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
use_count: number
|
||||||
|
remaining_uses?: number | null
|
||||||
|
enabled: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_expired?: boolean
|
||||||
|
is_usable?: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvitesResponse = {
|
||||||
|
invites?: OwnedInvite[]
|
||||||
|
count?: number
|
||||||
|
invite_access?: {
|
||||||
|
enabled?: boolean
|
||||||
|
managed_by_master?: boolean
|
||||||
|
}
|
||||||
|
master_invite?: {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_usable?: boolean
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInviteForm = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
max_uses: string
|
||||||
|
expires_at: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
||||||
|
|
||||||
|
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
max_uses: '',
|
||||||
|
expires_at: '',
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) return 'Never'
|
if (!value) return 'Never'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -72,8 +128,23 @@ export default function ProfilePage() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||||
|
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||||
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
|
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||||
|
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||||
|
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||||
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
|
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const signupBaseUrl = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return '/signup'
|
||||||
|
return `${window.location.origin}/signup`
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -82,21 +153,32 @@ export default function ProfilePage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile`)
|
const [profileResponse, invitesResponse] = await Promise.all([
|
||||||
if (!response.ok) {
|
authFetch(`${baseUrl}/auth/profile`),
|
||||||
|
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||||
|
])
|
||||||
|
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const [data, inviteData] = (await Promise.all([
|
||||||
|
profileResponse.json(),
|
||||||
|
invitesResponse.json(),
|
||||||
|
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||||
const user = data?.user ?? {}
|
const user = data?.user ?? {}
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
role: user?.role ?? 'user',
|
role: user?.role ?? 'user',
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
|
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||||
})
|
})
|
||||||
setStats(data?.stats ?? null)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load your profile.')
|
setStatus('Could not load your profile.')
|
||||||
@@ -125,18 +207,177 @@ export default function ProfilePage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text()
|
let detail = 'Update failed'
|
||||||
throw new Error(text || 'Update failed')
|
try {
|
||||||
|
const payload = await response.json()
|
||||||
|
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||||||
|
detail = payload.detail
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
if (text?.trim()) detail = text
|
||||||
|
}
|
||||||
|
throw new Error(detail)
|
||||||
}
|
}
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
setCurrentPassword('')
|
setCurrentPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setStatus('Password updated.')
|
setStatus(
|
||||||
|
data?.provider === 'jellyfin'
|
||||||
|
? 'Password updated in Jellyfin (and Magent cache).'
|
||||||
|
: 'Password updated.'
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not update password. Check your current password.')
|
if (err instanceof Error && err.message) {
|
||||||
|
setStatus(`Could not update password. ${err.message}`)
|
||||||
|
} else {
|
||||||
|
setStatus('Could not update password. Check your current password.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetInviteEditor = () => {
|
||||||
|
setInviteEditingId(null)
|
||||||
|
setInviteForm(defaultOwnedInviteForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
const editInvite = (invite: OwnedInvite) => {
|
||||||
|
setInviteEditingId(invite.id)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
setInviteForm({
|
||||||
|
code: invite.code ?? '',
|
||||||
|
label: invite.label ?? '',
|
||||||
|
description: invite.description ?? '',
|
||||||
|
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||||
|
expires_at: invite.expires_at ?? '',
|
||||||
|
enabled: invite.enabled !== false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadInvites = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Invite refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(data?.master_invite ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setInviteSaving(true)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
inviteEditingId == null
|
||||||
|
? `${baseUrl}/auth/profile/invites`
|
||||||
|
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||||||
|
{
|
||||||
|
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: inviteForm.code || null,
|
||||||
|
label: inviteForm.label || null,
|
||||||
|
description: inviteForm.description || null,
|
||||||
|
max_uses: inviteForm.max_uses || null,
|
||||||
|
expires_at: inviteForm.expires_at || null,
|
||||||
|
enabled: inviteForm.enabled,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite save failed')
|
||||||
|
}
|
||||||
|
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||||
|
resetInviteEditor()
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||||||
|
} finally {
|
||||||
|
setInviteSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
|
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite delete failed')
|
||||||
|
}
|
||||||
|
if (inviteEditingId === invite.id) {
|
||||||
|
resetInviteEditor()
|
||||||
|
}
|
||||||
|
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
|
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||||||
|
} else {
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authProvider = profile?.auth_provider ?? 'local'
|
||||||
|
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||||
|
const securityHelpText =
|
||||||
|
authProvider === 'jellyfin'
|
||||||
|
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||||||
|
: authProvider === 'local'
|
||||||
|
? 'Change your Magent account password.'
|
||||||
|
: 'Password changes are not available for this sign-in provider.'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'invites' && !canManageInvites) {
|
||||||
|
setActiveTab('overview')
|
||||||
|
}
|
||||||
|
}, [activeTab, canManageInvites])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
}
|
}
|
||||||
@@ -150,8 +391,51 @@ export default function ProfilePage() {
|
|||||||
{profile.auth_provider}.
|
{profile.auth_provider}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="profile-grid">
|
<div className="profile-tabbar">
|
||||||
<section className="profile-section">
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'overview'}
|
||||||
|
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'activity'}
|
||||||
|
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('activity')}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
{canManageInvites ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'invites'}
|
||||||
|
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('invites')}
|
||||||
|
>
|
||||||
|
My invites
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'security'}
|
||||||
|
className={activeTab === 'security' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Account stats</h2>
|
<h2>Account stats</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -174,6 +458,18 @@ export default function ProfilePage() {
|
|||||||
<div className="stat-label">Declined</div>
|
<div className="stat-label">Declined</div>
|
||||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
<div className="stat-value">{stats?.declined ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Working</div>
|
||||||
|
<div className="stat-value">{stats?.working ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Partial</div>
|
||||||
|
<div className="stat-value">{stats?.partial ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Approved</div>
|
||||||
|
<div className="stat-value">{stats?.approved ?? 0}</div>
|
||||||
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Last request</div>
|
<div className="stat-label">Last request</div>
|
||||||
<div className="stat-value stat-value--small">
|
<div className="stat-value stat-value--small">
|
||||||
@@ -188,6 +484,10 @@ export default function ProfilePage() {
|
|||||||
: '0%'}
|
: '0%'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Total requests (global)</div>
|
||||||
|
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
||||||
|
</div>
|
||||||
{profile?.role === 'admin' ? (
|
{profile?.role === 'admin' ? (
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Most active user</div>
|
<div className="stat-label">Most active user</div>
|
||||||
@@ -200,7 +500,10 @@ export default function ProfilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="profile-section">
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Connection history</h2>
|
<h2>Connection history</h2>
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||||||
@@ -211,6 +514,7 @@ export default function ProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
||||||
<div className="meta">IP: {entry.ip}</div>
|
<div className="meta">IP: {entry.ip}</div>
|
||||||
|
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
||||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="connection-count">{entry.hit_count} visits</div>
|
<div className="connection-count">{entry.hit_count} visits</div>
|
||||||
@@ -221,36 +525,254 @@ export default function ProfilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
)}
|
||||||
{profile?.auth_provider !== 'local' ? (
|
|
||||||
<div className="status-banner">
|
{activeTab === 'invites' && (
|
||||||
Password changes are only available for local Magent accounts.
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||||
</div>
|
<div className="user-directory-panel-header">
|
||||||
) : (
|
<div>
|
||||||
<form onSubmit={submit} className="auth-form">
|
<h2>My invites</h2>
|
||||||
<label>
|
<p className="lede">
|
||||||
Current password
|
{inviteManagedByMaster
|
||||||
<input
|
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
||||||
type="password"
|
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
||||||
value={currentPassword}
|
</p>
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
New password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{status && <div className="status-banner">{status}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit">Update password</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||||
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
|
<div className="profile-invites-layout">
|
||||||
|
<div className="profile-invite-form-card">
|
||||||
|
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||||
|
<p className="meta profile-invite-form-lede">
|
||||||
|
Share the generated signup link with the person you want to invite.
|
||||||
|
</p>
|
||||||
|
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||||
|
<div className="status-banner profile-invite-master-banner">
|
||||||
|
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||||
|
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Identity</span>
|
||||||
|
<small>Optional code and label for easier tracking.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Code (optional)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Family invite"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Description</span>
|
||||||
|
<small>Optional note shown on the signup page.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note shown on the signup page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Limits</span>
|
||||||
|
<small>Usage cap and optional expiry date/time.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Max uses</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Blank = unlimited"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Invite expiry (ISO datetime)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.expires_at}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="2026-03-01T12:00:00+00:00"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Status</span>
|
||||||
|
<small>Enable or disable this invite before sharing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
Invite is enabled
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="submit" disabled={inviteSaving}>
|
||||||
|
{inviteSaving
|
||||||
|
? 'Saving…'
|
||||||
|
: inviteEditingId == null
|
||||||
|
? 'Create invite'
|
||||||
|
: 'Save invite'}
|
||||||
|
</button>
|
||||||
|
{inviteEditingId != null && (
|
||||||
|
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="meta profile-invite-hint">
|
||||||
|
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-invites-list">
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="status-banner">You haven’t created any invites yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div key={invite.id} className="admin-list-item">
|
||||||
|
<div className="admin-list-item-main">
|
||||||
|
<div className="admin-list-item-title-row">
|
||||||
|
<code className="invite-code">{invite.code}</code>
|
||||||
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
|
<span className="small-pill is-muted">
|
||||||
|
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||||
|
{invite.description && (
|
||||||
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
|
{invite.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-meta-row">
|
||||||
|
<span>
|
||||||
|
Uses: {invite.use_count}
|
||||||
|
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
|
<span>Created: {formatDate(invite.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => copyInviteLink(invite)}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => editInvite(invite)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
|
<h2>Security</h2>
|
||||||
|
<div className="status-banner">{securityHelpText}</div>
|
||||||
|
{canChangePassword ? (
|
||||||
|
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||||
|
<label>
|
||||||
|
Current password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{status && <div className="status-banner">{status}</div>}
|
||||||
|
<div className="auth-actions">
|
||||||
|
<button type="submit">
|
||||||
|
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="status-banner">
|
||||||
|
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
|
||||||
|
|
||||||
type TimelineHop = {
|
type TimelineHop = {
|
||||||
service: string
|
service: string
|
||||||
@@ -254,6 +254,64 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
|
|||||||
load()
|
load()
|
||||||
}, [params.id, router])
|
}, [params.id, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
let closed = false
|
||||||
|
let source: EventSource | null = null
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const streamToken = await getEventStreamToken()
|
||||||
|
if (closed) return
|
||||||
|
const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
|
||||||
|
params.id
|
||||||
|
)}/stream?stream_token=${encodeURIComponent(streamToken)}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (String(payload.request_id ?? '') !== String(params.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.snapshot && typeof payload.snapshot === 'object') {
|
||||||
|
setSnapshot(payload.snapshot as Snapshot)
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.history)) {
|
||||||
|
setHistorySnapshots(payload.history as SnapshotHistory[])
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.actions)) {
|
||||||
|
setHistoryActions(payload.actions as ActionHistory[])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (closed) return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (closed) return
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
source?.close()
|
||||||
|
}
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ type AdminShellProps = {
|
|||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
|
rail?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
|
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
<aside className="admin-shell-nav">
|
<aside className="admin-shell-nav">
|
||||||
@@ -26,6 +27,16 @@ export default function AdminShell({ title, subtitle, actions, children }: Admin
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<aside className="admin-shell-rail">
|
||||||
|
{rail ?? (
|
||||||
|
<div className="admin-rail-card admin-rail-card--placeholder">
|
||||||
|
<span className="admin-rail-eyebrow">Insights</span>
|
||||||
|
<h2>Stats rail</h2>
|
||||||
|
<p>Use this column for counters, live status, and quick metrics for this page.</p>
|
||||||
|
<span className="small-pill">{title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Services',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/general', label: 'General' },
|
||||||
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||||
@@ -25,6 +26,8 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/notifications', label: 'Notifications' },
|
||||||
|
{ href: '/admin/system', label: 'System guide' },
|
||||||
{ href: '/admin/site', label: 'Site' },
|
{ href: '/admin/site', label: 'Site' },
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
{ href: '/admin/invites', label: 'Invite management' },
|
||||||
|
|||||||
@@ -25,12 +25,29 @@ type AdminUser = {
|
|||||||
last_login_at?: string | null
|
last_login_at?: string | null
|
||||||
is_blocked?: boolean
|
is_blocked?: boolean
|
||||||
auto_search_enabled?: boolean
|
auto_search_enabled?: boolean
|
||||||
|
invite_management_enabled?: boolean
|
||||||
jellyseerr_user_id?: number | null
|
jellyseerr_user_id?: number | null
|
||||||
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 +102,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 +157,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))
|
||||||
? ''
|
? ''
|
||||||
@@ -221,6 +241,30 @@ export default function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateInviteManagementEnabled = async (enabled: boolean) => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
setActionStatus(null)
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Update failed')
|
||||||
|
}
|
||||||
|
await loadUser()
|
||||||
|
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Could not update invite access.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyProfileToUser = async (profileOverride?: string | null) => {
|
const applyProfileToUser = async (profileOverride?: string | null) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
const profileValue = profileOverride ?? profileSelection
|
const profileValue = profileOverride ?? profileSelection
|
||||||
@@ -315,6 +359,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 +475,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>
|
||||||
@@ -459,16 +564,48 @@ export default function UserDetailPage() {
|
|||||||
/>
|
/>
|
||||||
<span>Allow auto search/download</span>
|
<span>Allow auto search/download</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(user.invite_management_enabled ?? false)}
|
||||||
|
disabled={user.role === 'admin'}
|
||||||
|
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Allow self-service invites</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
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 and invite-management access.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -250,112 +250,152 @@ export default function UsersPage() {
|
|||||||
filteredUsers.length === users.length
|
filteredUsers.length === users.length
|
||||||
? `${users.length} users`
|
? `${users.length} users`
|
||||||
: `${filteredUsers.length} of ${users.length} users`
|
: `${filteredUsers.length} of ${users.length} users`
|
||||||
|
const usersRail = (
|
||||||
|
<div className="admin-rail-stack">
|
||||||
|
<div className="admin-rail-card users-rail-summary">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Directory summary</h2>
|
||||||
|
<p className="lede">A quick view of user access and account state.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="users-summary-grid">
|
||||||
|
<div className="users-summary-card">
|
||||||
|
<div className="users-summary-row">
|
||||||
|
<span className="users-summary-label">Total users</span>
|
||||||
|
<strong className="users-summary-value">{users.length}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="users-summary-meta">{adminCount} admin accounts</p>
|
||||||
|
</div>
|
||||||
|
<div className="users-summary-card">
|
||||||
|
<div className="users-summary-row">
|
||||||
|
<span className="users-summary-label">Auto search</span>
|
||||||
|
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
|
||||||
|
</div>
|
||||||
|
<div className="users-summary-card">
|
||||||
|
<div className="users-summary-row">
|
||||||
|
<span className="users-summary-label">Blocked</span>
|
||||||
|
<strong className="users-summary-value">{blockedCount}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="users-summary-meta">
|
||||||
|
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="users-summary-card">
|
||||||
|
<div className="users-summary-row">
|
||||||
|
<span className="users-summary-label">Expired</span>
|
||||||
|
<strong className="users-summary-value">{expiredCount}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="users-summary-meta">
|
||||||
|
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Users"
|
title="Users"
|
||||||
subtitle="Directory, access status, and request activity."
|
subtitle="Directory, access status, and request activity."
|
||||||
actions={
|
rail={usersRail}
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/admin/invites')}>
|
|
||||||
Invite management
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={loadUsers}>
|
|
||||||
Reload list
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
|
|
||||||
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}>
|
|
||||||
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
<div className="admin-panel users-page-toolbar">
|
||||||
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
|
<div className="users-page-toolbar-grid">
|
||||||
<div className="admin-summary-grid user-summary-grid">
|
<div className="users-page-toolbar-group">
|
||||||
<div className="admin-summary-tile">
|
<span className="users-page-toolbar-label">Directory actions</span>
|
||||||
<span className="label">Total users</span>
|
<div className="users-page-toolbar-actions">
|
||||||
<strong>{users.length}</strong>
|
|
||||||
<small>{adminCount} admin</small>
|
|
||||||
</div>
|
|
||||||
<div className="admin-summary-tile">
|
|
||||||
<span className="label">Auto search</span>
|
|
||||||
<strong>{autoSearchEnabledCount}</strong>
|
|
||||||
<small>of {nonAdminUsers.length} non-admin users</small>
|
|
||||||
</div>
|
|
||||||
<div className="admin-summary-tile">
|
|
||||||
<span className="label">Blocked</span>
|
|
||||||
<strong>{blockedCount}</strong>
|
|
||||||
<small>{blockedCount ? 'Needs review' : 'No blocked users'}</small>
|
|
||||||
</div>
|
|
||||||
<div className="admin-summary-tile">
|
|
||||||
<span className="label">Expired</span>
|
|
||||||
<strong>{expiredCount}</strong>
|
|
||||||
<small>{expiredCount ? 'Access expired' : 'No expiries'}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="user-directory-control-grid">
|
|
||||||
<div className="admin-panel user-directory-search-panel">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>Directory search</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Filter by username, role, login provider, or assigned profile.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="small-pill">{filteredCountLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-directory-toolbar">
|
|
||||||
<div className="user-directory-search">
|
|
||||||
<label>
|
|
||||||
<span className="user-bulk-label">Search users</span>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Search username, login type, role, profile…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-panel user-directory-bulk-panel">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>Bulk controls</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Auto search/download can be enabled or disabled for all non-admin users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-bulk-toolbar">
|
|
||||||
<div className="user-bulk-summary">
|
|
||||||
<strong>Auto search/download</strong>
|
|
||||||
<span>
|
|
||||||
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="user-bulk-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => bulkUpdateAutoSearch(true)}
|
|
||||||
disabled={bulkAutoSearchBusy}
|
|
||||||
>
|
|
||||||
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={() => bulkUpdateAutoSearch(false)}
|
onClick={() => router.push('/admin/invites')}
|
||||||
disabled={bulkAutoSearchBusy}
|
|
||||||
>
|
>
|
||||||
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
|
Invite management
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={loadUsers}>
|
||||||
|
Reload list
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="users-page-toolbar-group">
|
||||||
|
<span className="users-page-toolbar-label">Jellyseerr sync</span>
|
||||||
|
<div className="users-page-toolbar-actions">
|
||||||
|
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
|
||||||
|
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resyncJellyseerrUsers}
|
||||||
|
disabled={jellyseerrResyncBusy}
|
||||||
|
>
|
||||||
|
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
|
||||||
|
<div className="admin-panel user-directory-bulk-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Bulk controls</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Auto search/download can be enabled or disabled for all non-admin users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="user-bulk-toolbar">
|
||||||
|
<div className="user-bulk-summary">
|
||||||
|
<strong>Auto search/download</strong>
|
||||||
|
<span>
|
||||||
|
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-bulk-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bulkUpdateAutoSearch(true)}
|
||||||
|
disabled={bulkAutoSearchBusy}
|
||||||
|
>
|
||||||
|
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => bulkUpdateAutoSearch(false)}
|
||||||
|
disabled={bulkAutoSearchBusy}
|
||||||
|
>
|
||||||
|
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-panel user-directory-search-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Directory search</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Filter by username, role, login provider, or assigned profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="small-pill">{filteredCountLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-directory-toolbar">
|
||||||
|
<div className="user-directory-search">
|
||||||
|
<label>
|
||||||
|
<span className="user-bulk-label">Search users</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search username, login type, role, profile…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0202261541",
|
"version": "2702261314",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user