Compare commits

...

12 Commits

33 changed files with 5461 additions and 684 deletions

View File

@@ -1 +1 @@
2602261523
2702261153

View File

@@ -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]:

View File

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

View File

@@ -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()

View File

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

View File

@@ -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}")

View File

@@ -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")
)

View File

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

View File

@@ -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"}

View File

@@ -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):

View File

@@ -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.",
)

View File

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

View File

@@ -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")

View File

@@ -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",
}

View File

@@ -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]:

View File

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

View File

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

View File

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

View 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>
)
}

View File

@@ -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;
}
}

View File

@@ -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 &quot;waiting&quot;</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>

View File

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

View File

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

View File

@@ -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])

View File

@@ -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 Magents 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 youve issued. New invites use the admin master invite rule.'
: 'Create and manage invite links youve 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 havent 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>
)

View File

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

View File

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

View File

@@ -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>
)
}

View File

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

View File

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

View File

@@ -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 ? (

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0202261541",
"version": "2702261314",
"scripts": {
"dev": "next dev",
"build": "next build",