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"
|
||||
|
||||
|
||||
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:
|
||||
payload = safe_decode_token(token)
|
||||
except TokenError as 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")
|
||||
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"),
|
||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
||||
"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"),
|
||||
"expires_at": user.get("expires_at"),
|
||||
"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]:
|
||||
"""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
|
||||
stream_query_token = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.query_params.get("access_token")
|
||||
if not token:
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if not token and not stream_query_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]:
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -41,3 +41,14 @@ class ApiClient:
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def delete(self, path: str) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.delete(url, headers=self.headers())
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
@@ -10,27 +10,158 @@ class JellyfinClient(ApiClient):
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
def _emby_headers(self) -> Dict[str, str]:
|
||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
for key in ("Id", "id", "UserId", "userId"):
|
||||
value = candidate.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (str, int)):
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
users = await self.get_users()
|
||||
if not isinstance(users, list):
|
||||
return None
|
||||
target = username.strip().lower()
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip().lower()
|
||||
if name and name == target:
|
||||
return user
|
||||
return None
|
||||
|
||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
headers = self._emby_headers()
|
||||
payload = {"Username": username, "Pw": password}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/New"
|
||||
headers = self._emby_headers()
|
||||
payload = {"Name": username}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
headers = self._emby_headers()
|
||||
payloads = [
|
||||
{"CurrentPw": "", "NewPw": password},
|
||||
{"CurrentPwd": "", "NewPw": password},
|
||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
||||
{"NewPw": password, "ResetPassword": False},
|
||||
]
|
||||
paths = [
|
||||
f"/Users/{user_id}/Password",
|
||||
f"/Users/{user_id}/EasyPassword",
|
||||
]
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for path in paths:
|
||||
url = f"{self.base_url}{path}"
|
||||
for payload in payloads:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
user = await self.get_user(user_id)
|
||||
if not isinstance(user, dict):
|
||||
raise RuntimeError("Jellyfin user details not available")
|
||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
async def delete_user(self, user_id: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.delete(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
created = await self.create_user(username)
|
||||
user_id = self._extract_user_id(created)
|
||||
if not user_id:
|
||||
users = await self.get_users()
|
||||
if isinstance(users, list):
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip()
|
||||
if name.lower() == username.strip().lower():
|
||||
created = user
|
||||
user_id = self._extract_user_id(user)
|
||||
break
|
||||
if not user_id:
|
||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
||||
await self.set_user_password(user_id, password)
|
||||
return created
|
||||
|
||||
async def search_items(
|
||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -43,7 +174,7 @@ class JellyfinClient(ApiClient):
|
||||
"Recursive": "true",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
@@ -53,7 +184,7 @@ class JellyfinClient(ApiClient):
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/System/Info"
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
@@ -63,7 +194,7 @@ class JellyfinClient(ApiClient):
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
|
||||
@@ -44,3 +44,9 @@ class JellyseerrClient(ApiClient):
|
||||
"skip": skip,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/user/{user_id}")
|
||||
|
||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.delete(f"/api/v1/user/{user_id}")
|
||||
|
||||
@@ -11,6 +11,16 @@ class Settings(BaseSettings):
|
||||
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_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_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
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)
|
||||
|
||||
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(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
)
|
||||
|
||||
@@ -172,6 +172,7 @@ def init_db() -> None:
|
||||
last_login_at TEXT,
|
||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
invite_management_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
profile_id INTEGER,
|
||||
expires_at 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")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -498,6 +503,7 @@ def create_user(
|
||||
auth_provider: str = "local",
|
||||
jellyseerr_user_id: Optional[int] = None,
|
||||
auto_search_enabled: bool = True,
|
||||
invite_management_enabled: bool = False,
|
||||
profile_id: Optional[int] = None,
|
||||
expires_at: Optional[str] = None,
|
||||
invited_by_code: Optional[str] = None,
|
||||
@@ -515,12 +521,13 @@ def create_user(
|
||||
jellyseerr_user_id,
|
||||
created_at,
|
||||
auto_search_enabled,
|
||||
invite_management_enabled,
|
||||
profile_id,
|
||||
expires_at,
|
||||
invited_by_code,
|
||||
invited_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
@@ -530,6 +537,7 @@ def create_user(
|
||||
jellyseerr_user_id,
|
||||
created_at,
|
||||
1 if auto_search_enabled else 0,
|
||||
1 if invite_management_enabled else 0,
|
||||
profile_id,
|
||||
expires_at,
|
||||
invited_by_code,
|
||||
@@ -545,6 +553,7 @@ def create_user_if_missing(
|
||||
auth_provider: str = "local",
|
||||
jellyseerr_user_id: Optional[int] = None,
|
||||
auto_search_enabled: bool = True,
|
||||
invite_management_enabled: bool = False,
|
||||
profile_id: Optional[int] = None,
|
||||
expires_at: Optional[str] = None,
|
||||
invited_by_code: Optional[str] = None,
|
||||
@@ -562,12 +571,13 @@ def create_user_if_missing(
|
||||
jellyseerr_user_id,
|
||||
created_at,
|
||||
auto_search_enabled,
|
||||
invite_management_enabled,
|
||||
profile_id,
|
||||
expires_at,
|
||||
invited_by_code,
|
||||
invited_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
@@ -577,6 +587,7 @@ def create_user_if_missing(
|
||||
jellyseerr_user_id,
|
||||
created_at,
|
||||
1 if auto_search_enabled else 0,
|
||||
1 if invite_management_enabled else 0,
|
||||
profile_id,
|
||||
expires_at,
|
||||
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,
|
||||
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
|
||||
FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
@@ -612,13 +623,14 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
"last_login_at": row[7],
|
||||
"is_blocked": bool(row[8]),
|
||||
"auto_search_enabled": bool(row[9]),
|
||||
"profile_id": row[10],
|
||||
"expires_at": row[11],
|
||||
"invited_by_code": row[12],
|
||||
"invited_at": row[13],
|
||||
"is_expired": _is_datetime_in_past(row[11]),
|
||||
"jellyfin_password_hash": row[14],
|
||||
"last_jellyfin_auth_at": row[15],
|
||||
"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],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
@@ -648,13 +660,14 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"last_login_at": row[7],
|
||||
"is_blocked": bool(row[8]),
|
||||
"auto_search_enabled": bool(row[9]),
|
||||
"profile_id": row[10],
|
||||
"expires_at": row[11],
|
||||
"invited_by_code": row[12],
|
||||
"invited_at": row[13],
|
||||
"is_expired": _is_datetime_in_past(row[11]),
|
||||
"jellyfin_password_hash": row[14],
|
||||
"last_jellyfin_auth_at": row[15],
|
||||
"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],
|
||||
}
|
||||
|
||||
def get_all_users() -> list[Dict[str, Any]]:
|
||||
@@ -662,15 +675,15 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
|
||||
last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at,
|
||||
invited_by_code, invited_at
|
||||
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
|
||||
profile_id, expires_at, invited_by_code, invited_at
|
||||
FROM users
|
||||
ORDER BY username COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
results: list[Dict[str, Any]] = []
|
||||
all_rows: list[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
all_rows.append(
|
||||
{
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
@@ -681,13 +694,63 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
"last_login_at": row[6],
|
||||
"is_blocked": bool(row[7]),
|
||||
"auto_search_enabled": bool(row[8]),
|
||||
"profile_id": row[9],
|
||||
"expires_at": row[10],
|
||||
"invited_by_code": row[11],
|
||||
"invited_at": row[12],
|
||||
"is_expired": _is_datetime_in_past(row[10]),
|
||||
"invite_management_enabled": bool(row[9]),
|
||||
"profile_id": row[10],
|
||||
"expires_at": row[11],
|
||||
"invited_by_code": row[12],
|
||||
"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
|
||||
|
||||
|
||||
@@ -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:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
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:
|
||||
with _connect() as conn:
|
||||
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:
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -763,6 +883,17 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
||||
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:
|
||||
with _connect() as conn:
|
||||
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]]:
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
# Resolve case-insensitive duplicates safely by only considering local-provider rows.
|
||||
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
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
return user
|
||||
for row in rows:
|
||||
provider = str(row[4] or "local").lower()
|
||||
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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
@@ -24,7 +24,12 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .logging_config import configure_logging
|
||||
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(
|
||||
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")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..db import (
|
||||
get_all_users,
|
||||
get_cached_requests,
|
||||
get_cached_requests_count,
|
||||
get_setting,
|
||||
get_request_cache_overview,
|
||||
get_request_cache_missing_titles,
|
||||
get_request_cache_stats,
|
||||
@@ -30,11 +31,16 @@ from ..db import (
|
||||
set_user_jellyseerr_id,
|
||||
set_setting,
|
||||
set_user_blocked,
|
||||
delete_user_by_username,
|
||||
delete_user_activity_by_username,
|
||||
set_user_auto_search_enabled,
|
||||
set_auto_search_enabled_for_non_admin_users,
|
||||
set_user_invite_management_enabled,
|
||||
set_invite_management_enabled_for_non_admin_users,
|
||||
set_user_profile_id,
|
||||
set_user_expires_at,
|
||||
set_user_password,
|
||||
set_jellyfin_auth_cache,
|
||||
set_user_role,
|
||||
run_integrity_check,
|
||||
vacuum_db,
|
||||
@@ -55,6 +61,8 @@ from ..db import (
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
delete_signup_invite,
|
||||
get_signup_invite_by_code,
|
||||
disable_signup_invites_by_creator,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.sonarr import SonarrClient
|
||||
@@ -79,8 +87,17 @@ from ..routers.branding import save_branding_image
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
|
||||
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",
|
||||
"jellyfin_api_key",
|
||||
"sonarr_api_key",
|
||||
@@ -90,6 +107,11 @@ SENSITIVE_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",
|
||||
"jellyfin_base_url",
|
||||
"jellyfin_public_url",
|
||||
@@ -100,6 +122,44 @@ URL_SETTING_KEYS = {
|
||||
}
|
||||
|
||||
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_api_key",
|
||||
"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]:
|
||||
return {
|
||||
"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")
|
||||
username_norm = _normalize_username(user.get("username") or "")
|
||||
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
|
||||
return {"user": user, "stats": stats}
|
||||
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
|
||||
|
||||
@router.get("/users/id/{user_id}")
|
||||
@@ -845,7 +1035,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
username_norm = _normalize_username(user.get("username") or "")
|
||||
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
|
||||
return {"user": user, "stats": stats}
|
||||
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
|
||||
|
||||
@router.post("/users/{username}/block")
|
||||
@@ -860,6 +1050,98 @@ async def unblock_user(username: str) -> Dict[str, Any]:
|
||||
return {"status": "ok", "username": username, "blocked": False}
|
||||
|
||||
|
||||
@router.post("/users/{username}/system-action")
|
||||
async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
action = str(payload.get("action") or "").strip().lower()
|
||||
if action not in {"ban", "unban", "remove"}:
|
||||
raise HTTPException(status_code=400, detail="action must be ban, unban, or remove")
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.get("role") == "admin":
|
||||
raise HTTPException(status_code=400, detail="Cross-system actions are not allowed for admin users")
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
result: Dict[str, Any] = {
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"username": user.get("username"),
|
||||
"local": {"status": "pending"},
|
||||
"jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
|
||||
"jellyseerr": {"status": "skipped", "detail": "Jellyseerr not configured or no linked user ID"},
|
||||
"invites": {"status": "pending", "disabled": 0},
|
||||
}
|
||||
|
||||
if action == "ban":
|
||||
set_user_blocked(username, True)
|
||||
result["local"] = {"status": "ok", "blocked": True}
|
||||
elif action == "unban":
|
||||
set_user_blocked(username, False)
|
||||
result["local"] = {"status": "ok", "blocked": False}
|
||||
else:
|
||||
result["local"] = {"status": "pending-delete"}
|
||||
|
||||
if action in {"ban", "remove"}:
|
||||
result["invites"] = {"status": "ok", "disabled": disable_signup_invites_by_creator(username)}
|
||||
else:
|
||||
result["invites"] = {"status": "ok", "disabled": 0}
|
||||
|
||||
if jellyfin.configured():
|
||||
try:
|
||||
jellyfin_user = await jellyfin.find_user_by_name(username)
|
||||
if not jellyfin_user:
|
||||
result["jellyfin"] = {"status": "not_found"}
|
||||
else:
|
||||
jellyfin_user_id = jellyfin._extract_user_id(jellyfin_user) # type: ignore[attr-defined]
|
||||
if not jellyfin_user_id:
|
||||
raise RuntimeError("Could not determine Jellyfin user ID")
|
||||
if action == "ban":
|
||||
await jellyfin.set_user_disabled(jellyfin_user_id, True)
|
||||
result["jellyfin"] = {"status": "ok", "action": "disabled", "user_id": jellyfin_user_id}
|
||||
elif action == "unban":
|
||||
await jellyfin.set_user_disabled(jellyfin_user_id, False)
|
||||
result["jellyfin"] = {"status": "ok", "action": "enabled", "user_id": jellyfin_user_id}
|
||||
else:
|
||||
await jellyfin.delete_user(jellyfin_user_id)
|
||||
result["jellyfin"] = {"status": "ok", "action": "deleted", "user_id": jellyfin_user_id}
|
||||
except Exception as exc:
|
||||
result["jellyfin"] = {"status": "error", "detail": _http_error_detail(exc)}
|
||||
|
||||
jellyseerr_user_id = user.get("jellyseerr_user_id")
|
||||
if jellyseerr.configured() and jellyseerr_user_id is not None:
|
||||
try:
|
||||
if action == "remove":
|
||||
await jellyseerr.delete_user(int(jellyseerr_user_id))
|
||||
result["jellyseerr"] = {"status": "ok", "action": "deleted", "user_id": int(jellyseerr_user_id)}
|
||||
elif action == "ban":
|
||||
result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-disable", "user_id": int(jellyseerr_user_id)}
|
||||
else:
|
||||
result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-enable", "user_id": int(jellyseerr_user_id)}
|
||||
except Exception as exc:
|
||||
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
|
||||
|
||||
if action == "remove":
|
||||
deleted = delete_user_by_username(username)
|
||||
activity_deleted = delete_user_activity_by_username(username)
|
||||
result["local"] = {
|
||||
"status": "ok" if deleted else "not_found",
|
||||
"deleted": bool(deleted),
|
||||
"activity_deleted": activity_deleted,
|
||||
}
|
||||
|
||||
if any(
|
||||
isinstance(system, dict) and system.get("status") == "error"
|
||||
for system in (result.get("jellyfin"), result.get("jellyseerr"))
|
||||
):
|
||||
result["status"] = "partial"
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/users/{username}/role")
|
||||
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
role = payload.get("role")
|
||||
@@ -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}
|
||||
|
||||
|
||||
@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")
|
||||
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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")
|
||||
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Password changes are only available for local users."
|
||||
)
|
||||
set_user_password(username, new_password.strip())
|
||||
return {"status": "ok", "username": username}
|
||||
new_password_clean = new_password.strip()
|
||||
auth_provider = str(user.get("auth_provider") or "local").lower()
|
||||
if auth_provider == "local":
|
||||
set_user_password(username, new_password_clean)
|
||||
return {"status": "ok", "username": username, "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=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")
|
||||
@@ -1158,6 +1490,68 @@ async def get_invites() -> Dict[str, Any]:
|
||||
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")
|
||||
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):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 ..db import (
|
||||
@@ -9,10 +15,17 @@ from ..db import (
|
||||
create_user_if_missing,
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
get_users_by_username_ci,
|
||||
set_user_password,
|
||||
set_jellyfin_auth_cache,
|
||||
set_user_jellyseerr_id,
|
||||
set_user_auth_provider,
|
||||
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,
|
||||
get_user_profile,
|
||||
get_user_activity,
|
||||
@@ -20,12 +33,15 @@ from ..db import (
|
||||
get_user_request_stats,
|
||||
get_global_request_leader,
|
||||
get_global_request_total,
|
||||
get_setting,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..security import create_access_token, verify_password
|
||||
from ..security import create_stream_token
|
||||
from ..auth import get_current_user
|
||||
from ..config import settings
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
get_cached_jellyseerr_users,
|
||||
@@ -34,6 +50,106 @@ from ..services.user_cache import (
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -84,6 +200,29 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_http_error_detail(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
response = exc.response
|
||||
try:
|
||||
text = response.text.strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if text:
|
||||
return text
|
||||
return f"HTTP {response.status_code}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
|
||||
try:
|
||||
users = await client.get_users()
|
||||
if isinstance(users, list):
|
||||
save_jellyfin_users_cache(users)
|
||||
except Exception:
|
||||
# Cache refresh is best-effort and should not block auth/signup.
|
||||
return
|
||||
|
||||
|
||||
def _is_user_expired(user: dict | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
@@ -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")
|
||||
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)
|
||||
if not user:
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||
)
|
||||
_assert_user_can_login(user)
|
||||
token = create_access_token(user["username"], user["role"])
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(user["username"])
|
||||
return {
|
||||
"access_token": token,
|
||||
@@ -148,7 +503,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
|
||||
|
||||
@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()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
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 [])
|
||||
username = form_data.username
|
||||
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)
|
||||
if user and _has_valid_jellyfin_cache(user, password):
|
||||
token = create_access_token(username, "user")
|
||||
set_last_login(username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(canonical_username)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
try:
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
except Exception as 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"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
user = get_user_by_username(username)
|
||||
if not preferred_match:
|
||||
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)
|
||||
try:
|
||||
users = await client.get_users()
|
||||
if isinstance(users, list):
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
set_user_jellyseerr_id(username, matched_id)
|
||||
token = create_access_token(username, "user")
|
||||
set_last_login(username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
||||
set_user_jellyseerr_id(canonical_username, matched_id)
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(canonical_username)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
@@ -206,22 +579,32 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
create_user_if_missing(
|
||||
form_data.username,
|
||||
"jellyseerr-user",
|
||||
role="user",
|
||||
auth_provider="jellyseerr",
|
||||
jellyseerr_user_id=jellyseerr_user_id,
|
||||
)
|
||||
user = get_user_by_username(form_data.username)
|
||||
ci_matches = get_users_by_username_ci(form_data.username)
|
||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||
if not preferred_match:
|
||||
create_user_if_missing(
|
||||
canonical_username,
|
||||
"jellyseerr-user",
|
||||
role="user",
|
||||
auth_provider="jellyseerr",
|
||||
jellyseerr_user_id=jellyseerr_user_id,
|
||||
)
|
||||
user = get_user_by_username(canonical_username)
|
||||
_assert_user_can_login(user)
|
||||
if jellyseerr_user_id is not None:
|
||||
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
|
||||
token = create_access_token(form_data.username, "user")
|
||||
set_last_login(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
|
||||
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(canonical_username)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -229,6 +612,20 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
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}")
|
||||
async def invite_details(code: str) -> dict:
|
||||
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:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
password_value = password.strip()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
matched_jellyseerr_user_id: int | None = None
|
||||
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
auth_provider = "jellyfin"
|
||||
local_password_value = "jellyfin-user"
|
||||
try:
|
||||
await jellyfin_client.create_user_with_password(username, password_value)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
except Exception as auth_exc:
|
||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||
) from exc
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
) from exc
|
||||
else:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||
) from exc
|
||||
|
||||
await _refresh_jellyfin_user_cache(jellyfin_client)
|
||||
jellyseerr_users = get_cached_jellyseerr_users()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
if candidate_map:
|
||||
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
|
||||
|
||||
try:
|
||||
create_user(
|
||||
username,
|
||||
password.strip(),
|
||||
local_password_value,
|
||||
role=role,
|
||||
auth_provider="local",
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
@@ -315,6 +761,15 @@ async def signup(payload: dict) -> dict:
|
||||
|
||||
increment_signup_invite_use(int(invite["id"]))
|
||||
created_user = get_user_by_username(username)
|
||||
if auth_provider == "jellyfin":
|
||||
set_jellyfin_auth_cache(username, password_value)
|
||||
if (
|
||||
created_user
|
||||
and created_user.get("jellyseerr_user_id") is None
|
||||
and matched_jellyseerr_user_id is not None
|
||||
):
|
||||
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
||||
created_user = get_user_by_username(username)
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
@@ -324,6 +779,7 @@ async def signup(payload: dict) -> dict:
|
||||
"user": {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
@@ -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")
|
||||
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
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
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(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||
)
|
||||
user = verify_user_password(current_user["username"], current_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||
set_user_password(current_user["username"], new_password.strip())
|
||||
return {"status": "ok"}
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
auth_provider = str(current_user.get("auth_provider") or "local").lower()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
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 typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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")
|
||||
async def events_stream(
|
||||
request: Request,
|
||||
@@ -110,3 +162,88 @@ async def events_stream(
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
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("")
|
||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from .config import settings
|
||||
from .db import get_settings_overrides
|
||||
|
||||
_INT_FIELDS = {
|
||||
"magent_application_port",
|
||||
"magent_api_port",
|
||||
"sonarr_quality_profile_id",
|
||||
"radarr_quality_profile_id",
|
||||
"jwt_exp_minutes",
|
||||
@@ -9,8 +11,20 @@ _INT_FIELDS = {
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_cleanup_days",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_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",
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
|
||||
|
||||
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]:
|
||||
|
||||
@@ -3,7 +3,12 @@ import logging
|
||||
from fastapi import HTTPException
|
||||
|
||||
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 .user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
@@ -24,6 +29,8 @@ async def sync_jellyfin_users() -> int:
|
||||
if not isinstance(users, list):
|
||||
return 0
|
||||
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()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
imported = 0
|
||||
@@ -43,8 +50,16 @@ async def sync_jellyfin_users() -> int:
|
||||
)
|
||||
if created:
|
||||
imported += 1
|
||||
elif matched_id is not None:
|
||||
set_user_jellyseerr_id(name, matched_id)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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'
|
||||
|
||||
type AdminSetting = {
|
||||
@@ -19,6 +19,9 @@ type ServiceOptions = {
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
magent: 'Magent',
|
||||
general: 'General',
|
||||
notifications: 'Notifications',
|
||||
jellyseerr: 'Jellyseerr',
|
||||
jellyfin: 'Jellyfin',
|
||||
artwork: 'Artwork cache',
|
||||
@@ -32,9 +35,34 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
site: 'Site',
|
||||
}
|
||||
|
||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
|
||||
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
|
||||
const BOOL_SETTINGS = new Set([
|
||||
'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([
|
||||
'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',
|
||||
'jellyfin_base_url',
|
||||
'jellyfin_public_url',
|
||||
@@ -43,9 +71,24 @@ const URL_SETTINGS = new Set([
|
||||
'prowlarr_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 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.',
|
||||
jellyfin: 'Control Jellyfin login and availability checks.',
|
||||
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> = {
|
||||
magent: 'magent',
|
||||
general: 'magent',
|
||||
notifications: 'magent',
|
||||
jellyseerr: 'jellyseerr',
|
||||
jellyfin: 'jellyfin',
|
||||
artwork: null,
|
||||
@@ -74,7 +120,162 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
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) =>
|
||||
SETTING_LABEL_OVERRIDES[key] ??
|
||||
key
|
||||
.replaceAll('_', ' ')
|
||||
.replace('base url', 'URL')
|
||||
@@ -115,6 +316,13 @@ type SettingsPageProps = {
|
||||
section: string
|
||||
}
|
||||
|
||||
type SettingsSectionGroup = {
|
||||
key: string
|
||||
title: string
|
||||
items: AdminSetting[]
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const router = useRouter()
|
||||
const [settings, setSettings] = useState<AdminSetting[]>([])
|
||||
@@ -285,6 +493,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [settings])
|
||||
|
||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
||||
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||
const visibleSections = settingsSection ? [settingsSection] : []
|
||||
const isCacheSection = section === 'cache'
|
||||
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 artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
||||
const settingsSections = isCacheSection
|
||||
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||||
? [
|
||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||||
]
|
||||
: 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)
|
||||
: isMagentGroupedSection
|
||||
? (() => {
|
||||
if (section === 'magent') {
|
||||
return []
|
||||
}
|
||||
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 showMaintenance = section === 'maintenance'
|
||||
const showRequestsExtras = section === 'requests'
|
||||
@@ -350,6 +582,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [artworkPrefetch])
|
||||
|
||||
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:
|
||||
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
||||
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> = {
|
||||
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',
|
||||
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
||||
jellyfin_public_url: 'https://jelly.example.com',
|
||||
@@ -599,83 +913,101 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
|
||||
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
|
||||
const source = new EventSource(streamUrl)
|
||||
let source: EventSource | null = null
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
const connect = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || payload.type !== 'admin_live_state') {
|
||||
return
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) 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 =
|
||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||
? payload.requestsSync
|
||||
: null
|
||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||
const prevSync = requestsSyncRef.current
|
||||
requestsSyncRef.current = nextSync
|
||||
setRequestsSync(nextSync)
|
||||
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||
}
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || payload.type !== 'admin_live_state') {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
const rawSync =
|
||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||
? payload.requestsSync
|
||||
: null
|
||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||
const prevSync = requestsSyncRef.current
|
||||
requestsSyncRef.current = nextSync
|
||||
setRequestsSync(nextSync)
|
||||
if (
|
||||
prevSync?.status === 'running' &&
|
||||
nextSync?.status &&
|
||||
nextSync.status !== 'running'
|
||||
) {
|
||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||
}
|
||||
|
||||
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') {
|
||||
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)
|
||||
}
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed) return
|
||||
console.error(err)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
source?.close()
|
||||
}
|
||||
}, [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) {
|
||||
return <main className="card">Loading admin settings...</main>
|
||||
}
|
||||
@@ -938,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<AdminShell
|
||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||
rail={cacheRail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
@@ -1000,8 +1413,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
|
||||
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
|
||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||||
(!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 && (
|
||||
<div className="error-banner">{sonarrError}</div>
|
||||
@@ -1339,6 +1761,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</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 (
|
||||
setting.key === 'requests_full_sync_time' ||
|
||||
setting.key === 'requests_cleanup_time'
|
||||
@@ -1365,10 +1816,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (
|
||||
setting.key === 'requests_delta_sync_interval_minutes' ||
|
||||
setting.key === 'requests_cleanup_days'
|
||||
) {
|
||||
if (NUMBER_SETTINGS.has(setting.key)) {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
@@ -1381,6 +1829,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
name={setting.key}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
@@ -1420,8 +1869,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
)
|
||||
}
|
||||
if (TEXTAREA_SETTINGS.has(setting.key)) {
|
||||
const isPemField =
|
||||
setting.key === 'magent_ssl_certificate_pem' ||
|
||||
setting.key === 'magent_ssl_private_key_pem'
|
||||
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>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
@@ -1431,11 +1887,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</span>
|
||||
<textarea
|
||||
name={setting.key}
|
||||
rows={setting.key === 'site_changelog' ? 6 : 3}
|
||||
rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
|
||||
placeholder={
|
||||
setting.key === 'site_changelog'
|
||||
? 'One update per line.'
|
||||
: ''
|
||||
: settingPlaceholders[setting.key] ?? ''
|
||||
}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
@@ -1483,7 +1939,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</form>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{showLogs && (
|
||||
@@ -1516,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<section className="admin-section" id="cache">
|
||||
<div className="section-header">
|
||||
<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>
|
||||
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
||||
<div className="cache-table">
|
||||
<div className="cache-row cache-head">
|
||||
<span>Request</span>
|
||||
|
||||
@@ -13,6 +13,9 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
'magent',
|
||||
'general',
|
||||
'notifications',
|
||||
'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;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.user-directory-search {
|
||||
@@ -4530,19 +4530,51 @@ button:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.invite-admin-tabbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-admin-tabbar .admin-segmented {
|
||||
margin-bottom: 0;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -4685,6 +4717,7 @@ button:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.invite-admin-tabbar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -4692,3 +4725,925 @@ button:hover:not(:disabled) {
|
||||
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">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">How this works</p>
|
||||
<h1>Your request, step by step</h1>
|
||||
<h1>How Magent works now</h1>
|
||||
<p className="lede">
|
||||
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
|
||||
your request is and what you can safely do next.
|
||||
End-to-end request flow, live status updates, and the exact tools available to users and
|
||||
admins.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -52,90 +52,172 @@ export default function HowItWorksPage() {
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>The pipeline in plain English</h2>
|
||||
<h2>The pipeline (request to ready)</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>You request a title</strong> in Jellyseerr.
|
||||
<strong>Request created</strong> in Jellyseerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sonarr/Radarr adds it</strong> to the library list.
|
||||
<strong>Approved</strong> and sent to Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Prowlarr looks for sources</strong> and sends results back.
|
||||
<strong>Search runs</strong> against indexers via Prowlarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>qBittorrent downloads</strong> the match.
|
||||
<strong>Grabbed</strong> and downloaded by qBittorrent.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sonarr/Radarr imports</strong> it into your library.
|
||||
<strong>Imported</strong> by Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jellyfin shows it</strong> when it is ready to watch.
|
||||
<strong>Available</strong> in Jellyfin.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<article className="how-step-card step-jellyseerr">
|
||||
<div className="step-badge">1</div>
|
||||
<h3>Request sent</h3>
|
||||
<p className="step-note">Jellyseerr holds your request and approval.</p>
|
||||
<div className="step-fix-title">Fixes you can try</div>
|
||||
<h3>Re-add to Arr</h3>
|
||||
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<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>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-arr">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Added to the library list</h3>
|
||||
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
|
||||
<div className="step-fix-title">Fixes you can try</div>
|
||||
<h3>Search releases</h3>
|
||||
<p className="step-note">Runs a search and shows concrete release options.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Search for releases (see options)</li>
|
||||
<li>Search and auto-download (let it pick for you)</li>
|
||||
<li>Manual selection of a specific release/indexer</li>
|
||||
<li>Checking whether results currently exist</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-prowlarr">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Searching for sources</h3>
|
||||
<p className="step-note">Prowlarr checks your torrent providers.</p>
|
||||
<div className="step-fix-title">Fixes you can try</div>
|
||||
<h3>Search + auto-download</h3>
|
||||
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<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>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">4</div>
|
||||
<h3>Downloading the file</h3>
|
||||
<p className="step-note">qBittorrent downloads the selected match.</p>
|
||||
<div className="step-fix-title">Fixes you can try</div>
|
||||
<h3>Resume download</h3>
|
||||
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<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>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">5</div>
|
||||
<h3>Ready to watch</h3>
|
||||
<p className="step-note">Jellyfin shows it in your library.</p>
|
||||
<div className="step-fix-title">What to do next</div>
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
</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">
|
||||
<h2>Why Magent sometimes says "waiting"</h2>
|
||||
<h2>Why a request can still wait</h2>
|
||||
<p>
|
||||
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
|
||||
That does not mean it is broken. It usually means the release is not available yet.
|
||||
If indexers do not return a valid release yet, Magent will show waiting/search states.
|
||||
That usually means content availability is the blocker, not a broken pipeline.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -23,3 +23,18 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
}
|
||||
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">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<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">
|
||||
<label>
|
||||
Username
|
||||
@@ -86,7 +86,7 @@ export default function LoginPage() {
|
||||
Sign in with Magent account
|
||||
</button>
|
||||
<a className="ghost-button" href="/signup">
|
||||
Have an invite? Create a Magent account
|
||||
Have an invite? Create your account (Jellyfin + Magent)
|
||||
</a>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
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[]) =>
|
||||
items
|
||||
@@ -210,64 +210,77 @@ export default function HomePage() {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
if (!getToken()) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||
let closed = false
|
||||
const source = new EventSource(streamUrl)
|
||||
let source: EventSource | null = null
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
const connect = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) 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)) {
|
||||
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)
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
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') {
|
||||
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)
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed) return
|
||||
console.error(error)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
source?.close()
|
||||
}
|
||||
}, [authReady, recentDays])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
@@ -8,6 +8,7 @@ type ProfileInfo = {
|
||||
username: string
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
}
|
||||
|
||||
type ProfileStats = {
|
||||
@@ -47,6 +48,61 @@ type ProfileResponse = {
|
||||
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) => {
|
||||
if (!value) return 'Never'
|
||||
const date = new Date(value)
|
||||
@@ -72,8 +128,23 @@ export default function ProfilePage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
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 signupBaseUrl = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '/signup'
|
||||
return `${window.location.origin}/signup`
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
@@ -82,21 +153,32 @@ export default function ProfilePage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/profile`)
|
||||
if (!response.ok) {
|
||||
const [profileResponse, invitesResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/profile`),
|
||||
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||
])
|
||||
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
const [data, inviteData] = (await Promise.all([
|
||||
profileResponse.json(),
|
||||
invitesResponse.json(),
|
||||
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||
const user = data?.user ?? {}
|
||||
setProfile({
|
||||
username: user?.username ?? 'Unknown',
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
})
|
||||
setStats(data?.stats ?? 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) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
@@ -125,18 +207,177 @@ export default function ProfilePage() {
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Update failed')
|
||||
let detail = '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('')
|
||||
setNewPassword('')
|
||||
setStatus('Password updated.')
|
||||
setStatus(
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated in Jellyfin (and Magent cache).'
|
||||
: 'Password updated.'
|
||||
)
|
||||
} catch (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) {
|
||||
return <main className="card">Loading profile...</main>
|
||||
}
|
||||
@@ -150,8 +391,51 @@ export default function ProfilePage() {
|
||||
{profile.auth_provider}.
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-grid">
|
||||
<section className="profile-section">
|
||||
<div className="profile-tabbar">
|
||||
<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>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
@@ -174,6 +458,18 @@ export default function ProfilePage() {
|
||||
<div className="stat-label">Declined</div>
|
||||
<div className="stat-value">{stats?.declined ?? 0}</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-label">Last request</div>
|
||||
<div className="stat-value stat-value--small">
|
||||
@@ -188,6 +484,10 @@ export default function ProfilePage() {
|
||||
: '0%'}
|
||||
</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' ? (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Most active user</div>
|
||||
@@ -200,7 +500,10 @@ export default function ProfilePage() {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="profile-section">
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Connection history</h2>
|
||||
<div className="status-banner">
|
||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||||
@@ -211,6 +514,7 @@ export default function ProfilePage() {
|
||||
<div>
|
||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</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>
|
||||
<div className="connection-count">{entry.hit_count} visits</div>
|
||||
@@ -221,36 +525,254 @@ export default function ProfilePage() {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{profile?.auth_provider !== 'local' ? (
|
||||
<div className="status-banner">
|
||||
Password changes are only available for local Magent accounts.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="auth-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">Update password</button>
|
||||
)}
|
||||
|
||||
{activeTab === 'invites' && (
|
||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>My invites</h2>
|
||||
<p className="lede">
|
||||
{inviteManagedByMaster
|
||||
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
||||
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
|
||||
|
||||
type TimelineHop = {
|
||||
service: string
|
||||
@@ -254,6 +254,64 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
|
||||
load()
|
||||
}, [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) {
|
||||
return (
|
||||
<main className="card">
|
||||
|
||||
@@ -135,7 +135,7 @@ function SignupPageContent() {
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<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">
|
||||
<label>
|
||||
Invite code
|
||||
@@ -203,7 +203,7 @@ function SignupPageContent() {
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
||||
|
||||
@@ -7,10 +7,11 @@ type AdminShellProps = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
rail?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
|
||||
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-shell-nav">
|
||||
@@ -26,6 +27,16 @@ export default function AdminShell({ title, subtitle, actions, children }: Admin
|
||||
</div>
|
||||
{children}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Services',
|
||||
items: [
|
||||
{ href: '/admin/general', label: 'General' },
|
||||
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||
@@ -25,6 +26,8 @@ const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/system', label: 'System guide' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
|
||||
@@ -25,12 +25,29 @@ type AdminUser = {
|
||||
last_login_at?: string | null
|
||||
is_blocked?: boolean
|
||||
auto_search_enabled?: boolean
|
||||
invite_management_enabled?: boolean
|
||||
jellyseerr_user_id?: number | null
|
||||
profile_id?: number | null
|
||||
expires_at?: string | null
|
||||
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 = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -85,7 +102,9 @@ export default function UserDetailPage() {
|
||||
const [expiryInput, setExpiryInput] = useState('')
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
const [savingExpiry, setSavingExpiry] = useState(false)
|
||||
const [systemActionBusy, setSystemActionBusy] = useState(false)
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null)
|
||||
const [lineage, setLineage] = useState<UserLineage>(null)
|
||||
|
||||
const loadProfiles = async () => {
|
||||
try {
|
||||
@@ -138,6 +157,7 @@ export default function UserDetailPage() {
|
||||
const nextUser = data?.user ?? null
|
||||
setUser(nextUser)
|
||||
setStats(normalizeStats(data?.stats))
|
||||
setLineage((data?.lineage ?? null) as UserLineage)
|
||||
setProfileSelection(
|
||||
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) => {
|
||||
if (!user) return
|
||||
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(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
@@ -378,6 +475,14 @@ export default function UserDetailPage() {
|
||||
<span className="label">Assigned profile</span>
|
||||
<strong>{user.profile_id ?? 'None'}</strong>
|
||||
</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">
|
||||
<span className="label">Last login</span>
|
||||
<strong>{formatDateTime(user.last_login_at)}</strong>
|
||||
@@ -459,16 +564,48 @@ export default function UserDetailPage() {
|
||||
/>
|
||||
<span>Allow auto search/download</span>
|
||||
</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
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => toggleUserBlock(!user.is_blocked)}
|
||||
disabled={systemActionBusy}
|
||||
>
|
||||
{user.is_blocked ? 'Allow access' : 'Block access'}
|
||||
</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' && (
|
||||
<div className="user-detail-helper">
|
||||
Admins always have auto search/download access.
|
||||
Admins always have auto search/download and invite-management access.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -250,112 +250,152 @@ export default function UsersPage() {
|
||||
filteredUsers.length === users.length
|
||||
? `${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 (
|
||||
<AdminShell
|
||||
title="Users"
|
||||
subtitle="Directory, access status, and request activity."
|
||||
actions={
|
||||
<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>
|
||||
}
|
||||
rail={usersRail}
|
||||
>
|
||||
<section className="admin-section">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
|
||||
<div className="admin-summary-grid user-summary-grid">
|
||||
<div className="admin-summary-tile">
|
||||
<span className="label">Total users</span>
|
||||
<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>
|
||||
<div className="admin-panel users-page-toolbar">
|
||||
<div className="users-page-toolbar-grid">
|
||||
<div className="users-page-toolbar-group">
|
||||
<span className="users-page-toolbar-label">Directory actions</span>
|
||||
<div className="users-page-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => bulkUpdateAutoSearch(false)}
|
||||
disabled={bulkAutoSearchBusy}
|
||||
onClick={() => router.push('/admin/invites')}
|
||||
>
|
||||
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
|
||||
Invite management
|
||||
</button>
|
||||
<button type="button" onClick={loadUsers}>
|
||||
Reload list
|
||||
</button>
|
||||
</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>
|
||||
{filteredUsers.length === 0 ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0202261541",
|
||||
"version": "2702261314",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
Reference in New Issue
Block a user