Compare commits

..

20 Commits

Author SHA1 Message Date
05a3d1e3b0 admin docs and layout refresh, build 2702261314 2026-02-27 13:17:50 +13:00
b84c27c698 Build 2702261153: fix jellyfin sync user visibility 2026-02-27 11:55:00 +13:00
744b213fa0 Build 2602262241: live request page updates 2026-02-26 22:42:38 +13:00
f362676c4e Build 2602262204 2026-02-26 22:05:17 +13:00
7257d32d6c Build 2602262159: restore jellyfin-first user source 2026-02-26 22:00:19 +13:00
1c6b8255c1 Build 2602262049: split magent settings and harden local login 2026-02-26 20:50:38 +13:00
0b73d9f4ee Build 2602262030: add magent settings and hardening 2026-02-26 20:31:26 +13:00
b215e8030c Build 2602261731: fix user resync after nuclear wipe 2026-02-26 17:32:48 +13:00
6a5d2c4310 Build 2602261717: master invite policy and self-service invite controls 2026-02-26 17:18:40 +13:00
23c57da3cc Build 2602261636: self-service invites and count fixes 2026-02-26 16:37:58 +13:00
1b1a3e233b Build 2602261605: invite trace and cross-system user lifecycle 2026-02-26 16:06:09 +13:00
bd3c0bdade Build 2602261536: refine invite layouts and tighten UI 2026-02-26 15:37:34 +13:00
50be0b6b57 Build 2602261523: live updates, invite cleanup and nuclear resync 2026-02-26 15:24:10 +13:00
5dfe614d15 Build 2602261442: tidy users and invite layouts 2026-02-26 14:42:49 +13:00
ec408df2a1 Build 2602261409: unify invite management controls 2026-02-26 14:10:18 +13:00
f78382c019 Build 2602260214: invites profiles and expiry admin controls 2026-02-26 02:15:21 +13:00
9be0ec75ec Build 2602260022: enterprise UI refresh and users bulk auto-search 2026-02-26 00:23:41 +13:00
be7b899837 Build 2502262321: fix auto-search quality and per-user toggle 2026-02-25 23:22:33 +13:00
d045dd0b07 Build 0202261541: allow FQDN service URLs 2026-02-02 15:43:08 +13:00
138069590b Build 3001262148: single container 2026-01-30 21:54:25 +13:00
42 changed files with 11409 additions and 458 deletions

View File

@@ -1 +1 @@
271261539 2702261153

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
FROM node:20-slim AS frontend-builder
WORKDIR /frontend
ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json ./
RUN npm install
COPY frontend/app ./app
COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --from=frontend-builder /frontend/public /app/frontend/public
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
EXPOSE 3000 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]

View File

@@ -1,4 +1,5 @@
from typing import Dict, Any from datetime import datetime, timezone
from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -8,6 +9,21 @@ from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str: def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
@@ -22,11 +38,18 @@ def _extract_client_ip(request: Request) -> str:
return "unknown" return "unknown"
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]: def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
allowed_token_types: Optional[set[str]] = None,
) -> Dict[str, Any]:
try: try:
payload = safe_decode_token(token) payload = safe_decode_token(token)
except TokenError as exc: except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
token_type = str(payload.get("typ") or "access").strip().lower()
if allowed_token_types and token_type not in allowed_token_types:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub") username = payload.get("sub")
if not username: if not username:
@@ -37,6 +60,8 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"): if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
if request is not None: if request is not None:
ip = _extract_client_ip(request) ip = _extract_client_ip(request)
@@ -48,10 +73,48 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
"role": user["role"], "role": user["role"],
"auth_provider": user.get("auth_provider", "local"), "auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"), "jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"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)),
} }
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""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:
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")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None)
return _load_current_user_from_token(
str(stream_query_token),
None,
allowed_token_types={"sse"},
)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin": if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -1,2 +1,4 @@
BUILD_NUMBER = "2901262244" 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' 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

@@ -30,3 +30,25 @@ class ApiClient:
response = await client.post(url, headers=self.headers(), json=payload) response = await client.post(url, headers=self.headers(), json=payload)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> 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.put(url, headers=self.headers(), json=payload)
response.raise_for_status()
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: def configured(self) -> bool:
return bool(self.base_url and self.api_key) return bool(self.base_url and self.api_key)
def _emby_headers(self) -> Dict[str, str]:
return {"X-Emby-Token": self.api_key} if self.api_key else {}
@staticmethod
def _extract_user_id(payload: Any) -> Optional[str]:
if not isinstance(payload, dict):
return None
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
if not isinstance(candidate, dict):
return None
for key in ("Id", "id", "UserId", "userId"):
value = candidate.get(key)
if value is None:
continue
if isinstance(value, (str, int)):
text = str(value).strip()
if text:
return text
return None
async def get_users(self) -> Optional[Dict[str, Any]]: async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url: if not self.base_url:
return None return None
url = f"{self.base_url}/Users" url = f"{self.base_url}/Users"
headers = {"X-Emby-Token": self.api_key} if self.api_key else {} headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
users = await self.get_users()
if not isinstance(users, list):
return None
target = username.strip().lower()
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip().lower()
if name and name == target:
return user
return None
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]: async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
if not self.base_url: if not self.base_url:
return None return None
url = f"{self.base_url}/Users/AuthenticateByName" url = f"{self.base_url}/Users/AuthenticateByName"
headers = {"X-Emby-Token": self.api_key} if self.api_key else {} headers = self._emby_headers()
payload = {"Username": username, "Pw": password} payload = {"Username": username, "Pw": password}
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload) response = await client.post(url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/New"
headers = self._emby_headers()
payload = {"Name": username}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def set_user_password(self, user_id: str, password: str) -> None:
if not self.base_url or not self.api_key:
return None
headers = self._emby_headers()
payloads = [
{"CurrentPw": "", "NewPw": password},
{"CurrentPwd": "", "NewPw": password},
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
{"NewPw": password, "ResetPassword": False},
]
paths = [
f"/Users/{user_id}/Password",
f"/Users/{user_id}/EasyPassword",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=10.0) as client:
for path in paths:
url = f"{self.base_url}{path}"
for payload in payloads:
try:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return
except httpx.HTTPStatusError as exc:
last_error = exc
continue
except Exception as exc:
last_error = exc
continue
if last_error:
raise last_error
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
user = await self.get_user(user_id)
if not isinstance(user, dict):
raise RuntimeError("Jellyfin user details not available")
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
payload = {**policy, "IsDisabled": bool(disabled)}
url = f"{self.base_url}/Users/{user_id}/Policy"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
async def delete_user(self, user_id: str) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=headers)
response.raise_for_status()
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
created = await self.create_user(username)
user_id = self._extract_user_id(created)
if not user_id:
users = await self.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip()
if name.lower() == username.strip().lower():
created = user
user_id = self._extract_user_id(user)
break
if not user_id:
raise RuntimeError("Jellyfin user created but user ID was not returned")
await self.set_user_password(user_id, password)
return created
async def search_items( async def search_items(
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20 self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
@@ -43,7 +174,7 @@ class JellyfinClient(ApiClient):
"Recursive": "true", "Recursive": "true",
"Limit": limit, "Limit": limit,
} }
headers = {"X-Emby-Token": self.api_key} headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params) response = await client.get(url, headers=headers, params=params)
response.raise_for_status() response.raise_for_status()
@@ -53,7 +184,7 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
url = f"{self.base_url}/System/Info" url = f"{self.base_url}/System/Info"
headers = {"X-Emby-Token": self.api_key} headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
@@ -63,7 +194,7 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
url = f"{self.base_url}/Library/Refresh" url = f"{self.base_url}/Library/Refresh"
headers = {"X-Emby-Token": self.api_key} headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"} params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params) response = await client.post(url, headers=headers, params=params)

View File

@@ -44,3 +44,9 @@ class JellyseerrClient(ApiClient):
"skip": skip, "skip": skip,
}, },
) )
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/user/{user_id}")
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.delete(f"/api/v1/user/{user_id}")

View File

@@ -9,6 +9,9 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]: async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id}) return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
async def get_movies(self) -> Optional[Dict[str, Any]]: async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie") return await self.get("/api/v3/movie")
@@ -44,6 +47,9 @@ class RadarrClient(ApiClient):
} }
return await self.post("/api/v3/movie", payload=payload) return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})

View File

@@ -9,6 +9,9 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]: async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id}) return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
async def get_root_folders(self) -> Optional[Dict[str, Any]]: async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder") return await self.get("/api/v3/rootfolder")
@@ -51,6 +54,9 @@ class SonarrClient(ApiClient):
payload["title"] = title payload["title"] = title
return await self.post("/api/v3/series", payload=payload) return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/series", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})

View File

@@ -11,6 +11,16 @@ class Settings(BaseSettings):
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH")) sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
@@ -51,6 +61,126 @@ class Settings(BaseSettings):
) )
site_changelog: Optional[str] = Field(default=CHANGELOG) site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
)
magent_application_port: int = Field(
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
)
magent_api_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
)
magent_api_port: int = Field(
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
)
magent_bind_host: str = Field(
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
)
magent_proxy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
)
magent_proxy_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
)
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
magent_ssl_bind_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
)
magent_ssl_certificate_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
)
magent_ssl_private_key_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
)
magent_ssl_certificate_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
)
magent_ssl_private_key_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
)
magent_notify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
)
magent_notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
)
magent_notify_email_smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
)
magent_notify_email_smtp_port: int = Field(
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
)
magent_notify_email_smtp_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
)
magent_notify_email_smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
)
magent_notify_email_from_address: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
)
magent_notify_email_from_name: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
)
magent_notify_email_use_tls: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
)
magent_notify_email_use_ssl: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
)
magent_notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
)
magent_notify_discord_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
)
magent_notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
)
magent_notify_telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
)
magent_notify_telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
)
magent_notify_push_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
)
magent_notify_push_provider: Optional[str] = Field(
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
)
magent_notify_push_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
)
magent_notify_push_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
)
magent_notify_push_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
)
magent_notify_push_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
)
magent_notify_push_device: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
)
magent_notify_webhook_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
)
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
jellyseerr_base_url: Optional[str] = Field( jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
) )

View File

@@ -24,6 +24,28 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path()) return sqlite3.connect(_db_path())
def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
candidate = value.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _is_datetime_in_past(value: Optional[str]) -> bool:
parsed = _parse_datetime_value(value)
if parsed is None:
return False
return parsed <= datetime.now(timezone.utc)
def _normalize_title_value(title: Optional[str]) -> Optional[str]: def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str): if not isinstance(title, str):
return None return None
@@ -149,11 +171,63 @@ def init_db() -> None:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0, is_blocked INTEGER NOT NULL DEFAULT 0,
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
invite_management_enabled INTEGER NOT NULL DEFAULT 0,
profile_id INTEGER,
expires_at TEXT,
invited_by_code TEXT,
invited_at TEXT,
jellyfin_password_hash TEXT, jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT last_jellyfin_auth_at TEXT
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
role TEXT NOT NULL DEFAULT 'user',
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
account_expires_days INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS signup_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
label TEXT,
description TEXT,
profile_id INTEGER,
role TEXT,
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled
ON signup_invites (enabled)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at
ON signup_invites (expires_at)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -264,6 +338,48 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER") conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
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:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try: try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -386,16 +502,47 @@ def create_user(
role: str = "user", role: str = "user",
auth_provider: str = "local", auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None, jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
invite_management_enabled: bool = False,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> None: ) -> None:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) INSERT INTO users (
VALUES (?, ?, ?, ?, ?, ?) username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
invite_management_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at), (
username,
password_hash,
role,
auth_provider,
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,
created_at if invited_by_code else None,
),
) )
@@ -405,16 +552,47 @@ def create_user_if_missing(
role: str = "user", role: str = "user",
auth_provider: str = "local", auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None, jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
invite_management_enabled: bool = False,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> bool: ) -> bool:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) INSERT OR IGNORE INTO users (
VALUES (?, ?, ?, ?, ?, ?) username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
invite_management_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at), (
username,
password_hash,
role,
auth_provider,
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,
created_at if invited_by_code else None,
),
) )
return cursor.rowcount > 0 return cursor.rowcount > 0
@@ -424,7 +602,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at 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 FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
""", """,
@@ -442,8 +622,15 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"created_at": row[6], "created_at": row[6],
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9], "auto_search_enabled": bool(row[9]),
"last_jellyfin_auth_at": row[10], "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],
} }
@@ -452,7 +639,9 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at 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 FROM users
WHERE id = ? WHERE id = ?
""", """,
@@ -470,22 +659,31 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"created_at": row[6], "created_at": row[6],
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9], "auto_search_enabled": bool(row[9]),
"last_jellyfin_auth_at": row[10], "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]]: def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked SELECT id, username, 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
FROM users FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
).fetchall() ).fetchall()
results: list[Dict[str, Any]] = [] all_rows: list[Dict[str, Any]] = []
for row in rows: for row in rows:
results.append( all_rows.append(
{ {
"id": row[0], "id": row[0],
"username": row[1], "username": row[1],
@@ -495,8 +693,64 @@ def get_all_users() -> list[Dict[str, Any]]:
"created_at": row[5], "created_at": row[5],
"last_login_at": row[6], "last_login_at": row[6],
"is_blocked": bool(row[7]), "is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]),
"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 return results
@@ -520,6 +774,17 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
) )
def set_user_auth_provider(username: str, auth_provider: str) -> None:
provider = (auth_provider or "local").strip().lower() or "local"
with _connect() as conn:
conn.execute(
"""
UPDATE users SET auth_provider = ? WHERE username = ?
""",
(provider, username),
)
def set_last_login(username: str) -> None: def set_last_login(username: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
@@ -541,6 +806,42 @@ def set_user_blocked(username: str, blocked: bool) -> None:
) )
def delete_user_by_username(username: str) -> bool:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM users WHERE username = ? COLLATE NOCASE
""",
(username,),
)
return cursor.rowcount > 0
def delete_user_activity_by_username(username: str) -> int:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM user_activity WHERE username = ? COLLATE NOCASE
""",
(username,),
)
return cursor.rowcount
def disable_signup_invites_by_creator(username: str) -> int:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET enabled = 0, updated_at = ?
WHERE created_by = ? COLLATE NOCASE AND enabled != 0
""",
(timestamp, username),
)
return cursor.rowcount
def set_user_role(username: str, role: str) -> None: def set_user_role(username: str, role: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
@@ -551,13 +852,464 @@ def set_user_role(username: str, role: str) -> None:
) )
def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET auto_search_enabled = ? WHERE username = ?
""",
(1 if enabled else 0, username),
)
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(
"""
UPDATE users SET auto_search_enabled = ? WHERE role != 'admin'
""",
(1 if enabled else 0,),
)
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(
"""
UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE
""",
(profile_id, username),
)
def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE
""",
(expires_at, username),
)
def _row_to_user_profile(row: Any) -> Dict[str, Any]:
return {
"id": row[0],
"name": row[1],
"description": row[2],
"role": row[3],
"auto_search_enabled": bool(row[4]),
"account_expires_days": row[5],
"is_active": bool(row[6]),
"created_at": row[7],
"updated_at": row[8],
}
def list_user_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
return [_row_to_user_profile(row) for row in rows]
def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return _row_to_user_profile(row)
def create_user_profile(
name: str,
description: Optional[str] = None,
role: str = "user",
auto_search_enabled: bool = True,
account_expires_days: Optional[int] = None,
is_active: bool = True,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO user_profiles (
name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
timestamp,
),
)
profile_id = int(cursor.lastrowid)
profile = get_user_profile(profile_id)
if not profile:
raise RuntimeError("Profile creation failed")
return profile
def update_user_profile(
profile_id: int,
*,
name: str,
description: Optional[str],
role: str,
auto_search_enabled: bool,
account_expires_days: Optional[int],
is_active: bool,
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE user_profiles
SET name = ?, description = ?, role = ?, auto_search_enabled = ?,
account_expires_days = ?, is_active = ?, updated_at = ?
WHERE id = ?
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
profile_id,
),
)
if cursor.rowcount <= 0:
return None
return get_user_profile(profile_id)
def delete_user_profile(profile_id: int) -> bool:
with _connect() as conn:
users_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE profile_id = ?",
(profile_id,),
).fetchone()
invites_count = conn.execute(
"SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?",
(profile_id,),
).fetchone()
if int((users_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing users.")
if int((invites_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing invites.")
cursor = conn.execute(
"DELETE FROM user_profiles WHERE id = ?",
(profile_id,),
)
return cursor.rowcount > 0
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = row[6]
use_count = int(row[7] or 0)
expires_at = row[9]
is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return {
"id": row[0],
"code": row[1],
"label": row[2],
"description": row[3],
"profile_id": row[4],
"role": row[5],
"max_uses": max_uses,
"use_count": use_count,
"enabled": bool(row[8]),
"expires_at": expires_at,
"created_by": row[10],
"created_at": row[11],
"updated_at": row[12],
"is_expired": is_expired,
"remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
}
def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [_row_to_signup_invite(row) for row in rows]
def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE id = ?
""",
(invite_id,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE code = ? COLLATE NOCASE
""",
(code,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def create_signup_invite(
*,
code: str,
label: Optional[str] = None,
description: Optional[str] = None,
profile_id: Optional[int] = None,
role: Optional[str] = None,
max_uses: Optional[int] = None,
enabled: bool = True,
expires_at: Optional[str] = None,
created_by: Optional[str] = None,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
created_by,
timestamp,
timestamp,
),
)
invite_id = int(cursor.lastrowid)
invite = get_signup_invite_by_id(invite_id)
if not invite:
raise RuntimeError("Invite creation failed")
return invite
def update_signup_invite(
invite_id: int,
*,
code: str,
label: Optional[str],
description: Optional[str],
profile_id: Optional[int],
role: Optional[str],
max_uses: Optional[int],
enabled: bool,
expires_at: Optional[str],
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
timestamp,
invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
def delete_signup_invite(invite_id: int) -> bool:
with _connect() as conn:
cursor = conn.execute(
"DELETE FROM signup_invites WHERE id = ?",
(invite_id,),
)
return cursor.rowcount > 0
def increment_signup_invite_use(invite_id: int) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE signup_invites
SET use_count = use_count + 1, updated_at = ?
WHERE id = ?
""",
(timestamp, invite_id),
)
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]: def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username) # Resolve case-insensitive duplicates safely by only considering local-provider rows.
if not user: with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
if not rows:
return None return None
if not verify_password(password, user["password_hash"]): for row in rows:
return None provider = str(row[4] or "local").lower()
return user if provider != "local":
continue
if not verify_password(password, row[2]):
continue
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
}
return None
def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
}
)
return results
def set_user_password(username: str, password: str) -> None: def set_user_password(username: str, password: str) -> None:
@@ -1452,6 +2204,29 @@ def clear_history() -> Dict[str, int]:
return {"actions": actions, "snapshots": snapshots} return {"actions": actions, "snapshots": snapshots}
def clear_user_objects_nuclear() -> Dict[str, int]:
with _connect() as conn:
# Preserve admin accounts, but remove invite/profile references so profile rows can be deleted safely.
admin_reset = conn.execute(
"""
UPDATE users
SET profile_id = NULL,
invited_by_code = NULL,
invited_at = NULL
WHERE role = 'admin'
"""
).rowcount
users = conn.execute("DELETE FROM users WHERE role != 'admin'").rowcount
invites = conn.execute("DELETE FROM signup_invites").rowcount
profiles = conn.execute("DELETE FROM user_profiles").rowcount
return {
"users": users,
"invites": invites,
"profiles": profiles,
"adminsReset": admin_reset,
}
def cleanup_history(days: int) -> Dict[str, int]: def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0: if days <= 0:
return {"actions": 0, "snapshots": 0} return {"actions": 0, "snapshots": 0}

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import settings from .config import settings
@@ -13,17 +13,23 @@ from .routers.requests import (
run_daily_db_cleanup, run_daily_db_cleanup,
) )
from .routers.auth import router as auth_router from .routers.auth import router as auth_router
from .routers.admin import router as admin_router from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.images import router as images_router from .routers.images import router as images_router
from .routers.branding import router as branding_router from .routers.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_router from .routers.feedback import router as feedback_router
from .routers.site import router as site_router from .routers.site import router as site_router
from .routers.events import router as events_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
app = FastAPI(title=settings.app_name) app = FastAPI(
title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -34,6 +40,22 @@ app.add_middleware(
) )
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
)
return response
@app.get("/health") @app.get("/health")
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@@ -53,8 +75,10 @@ async def startup() -> None:
app.include_router(requests_router) app.include_router(requests_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router) app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(site_router) app.include_router(site_router)
app.include_router(events_router)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
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,
recent_days: int = 90,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_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)
@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("") @router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict: async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
if not webhook_url: if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured") raise HTTPException(status_code=400, detail="Discord webhook not configured")

View File

@@ -120,6 +120,27 @@ def _normalize_username(value: Any) -> Optional[str]:
return normalized if normalized else None return normalized if normalized else None
def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
if user.get("role") == "admin":
return True
return bool(user.get("auto_search_enabled", True))
def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
if _user_can_use_search_auto(user):
return snapshot
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
return snapshot
def _quality_profile_id(value: Any) -> Optional[int]:
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return None
def _request_matches_user(request_data: Any, username: str) -> bool: def _request_matches_user(request_data: Any, username: str) -> bool:
requested_by = None requested_by = None
if isinstance(request_data, dict): if isinstance(request_data, dict):
@@ -1476,7 +1497,8 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
return await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
return _filter_snapshot_actions_for_user(snapshot, user)
@router.get("/recent") @router.get("/recent")
@@ -1747,7 +1769,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
return triage_snapshot(snapshot) return triage_snapshot(snapshot)
@@ -1784,6 +1806,8 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/search_auto") @router.post("/{request_id}/actions/search_auto")
async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
@@ -1797,10 +1821,23 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured") raise HTTPException(status_code=400, detail="Sonarr not configured")
target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
series_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and series_id and current_profile_id != target_profile_id:
series = await client.get_series(series_id)
if not isinstance(series, dict):
raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
series["qualityProfileId"] = target_profile_id
await client.update_series(series)
profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
episodes = await client.get_episodes(int(arr_item["id"])) episodes = await client.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes) missing_by_season = _missing_episode_ids_by_season(episodes)
if not missing_by_season: if not missing_by_season:
message = "No missing monitored episodes found." message = "No missing monitored episodes found."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )
@@ -1814,6 +1851,8 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
{"season": season_number, "episodeCount": len(episode_ids), "response": response} {"season": season_number, "episodeCount": len(episode_ids), "response": response}
) )
message = "Search sent to Sonarr." message = "Search sent to Sonarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )
@@ -1822,8 +1861,21 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured") raise HTTPException(status_code=400, detail="Radarr not configured")
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
movie_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and movie_id and current_profile_id != target_profile_id:
movie = await client.get_movie(movie_id)
if not isinstance(movie, dict):
raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
movie["qualityProfileId"] = target_profile_id
await client.update_movie(movie)
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
response = await client.search(int(arr_item["id"])) response = await client.search(int(arr_item["id"]))
message = "Search sent to Radarr." message = "Search sent to Radarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )

View File

@@ -2,6 +2,8 @@ from .config import settings
from .db import get_settings_overrides from .db import get_settings_overrides
_INT_FIELDS = { _INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"jwt_exp_minutes", "jwt_exp_minutes",
@@ -9,8 +11,20 @@ _INT_FIELDS = {
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_cleanup_days", "requests_cleanup_days",
"magent_notify_email_smtp_port",
} }
_BOOL_FIELDS = { _BOOL_FIELDS = {
"magent_proxy_enabled",
"magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_telegram_enabled",
"magent_notify_push_enabled",
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"site_banner_enabled", "site_banner_enabled",
} }

View File

@@ -18,11 +18,30 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password) return _pwd_context.verify(plain_password, hashed_password)
def _create_token(
subject: str,
role: str,
*,
expires_at: datetime,
token_type: str = "access",
) -> str:
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
minutes = expires_minutes or settings.jwt_exp_minutes minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires} return _create_token(subject, role, expires_at=expires, token_type="access")
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse")
def decode_token(token: str) -> Dict[str, Any]: def decode_token(token: str) -> Dict[str, Any]:

View File

@@ -3,7 +3,12 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..db import create_user_if_missing, set_user_jellyseerr_id from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .user_cache import ( from .user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
@@ -24,6 +29,8 @@ async def sync_jellyfin_users() -> int:
if not isinstance(users, list): if not isinstance(users, list):
return 0 return 0
save_jellyfin_users_cache(users) save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Jellyseerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users() jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0 imported = 0
@@ -43,8 +50,16 @@ async def sync_jellyfin_users() -> int:
) )
if created: if created:
imported += 1 imported += 1
elif matched_id is not None: else:
set_user_jellyseerr_id(name, matched_id) existing = get_user_by_username(name)
if (
existing
and str(existing.get("role") or "user").strip().lower() != "admin"
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
):
set_user_auth_provider(name, "jellyfin")
if matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
return imported return imported

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,3 +142,17 @@ def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]: def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes) return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}

View File

@@ -1,19 +1,10 @@
services: services:
backend: magent:
image: rephl3xnz/magent-backend:latest image: rephl3xnz/magent:latest
env_file: env_file:
- ./.env - ./.env
ports: ports:
- "3000:3000"
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
frontend:
image: rephl3xnz/magent-frontend:latest
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

View File

@@ -1,25 +1,12 @@
services: services:
backend: magent:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
env_file: env_file:
- ./.env - ./.env
ports: ports:
- "3000:3000"
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

28
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:backend]
directory=/app
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
[program:frontend]
directory=/app/frontend
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type AdminSetting = { type AdminSetting = {
@@ -19,6 +19,9 @@ type ServiceOptions = {
} }
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
general: 'General',
notifications: 'Notifications',
jellyseerr: 'Jellyseerr', jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
artwork: 'Artwork cache', artwork: 'Artwork cache',
@@ -32,11 +35,60 @@ const SECTION_LABELS: Record<string, string> = {
site: 'Site', site: 'Site',
} }
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) const BOOL_SETTINGS = new Set([
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) 'jellyfin_sync_to_arr',
'site_banner_enabled',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
'magent_notify_enabled',
'magent_notify_email_enabled',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
'magent_notify_discord_enabled',
'magent_notify_telegram_enabled',
'magent_notify_push_enabled',
'magent_notify_webhook_enabled',
])
const TEXTAREA_SETTINGS = new Set([
'site_banner_message',
'site_changelog',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
])
const URL_SETTINGS = new Set([
'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',
'sonarr_base_url',
'radarr_base_url',
'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 BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
magent:
'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
general:
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
notifications:
'Notification providers and delivery channel settings used by Magent messaging features.',
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.', jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.', artwork: 'Cache posters/backdrops and review artwork coverage.',
@@ -51,6 +103,9 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent',
general: 'magent',
notifications: 'magent',
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
artwork: null, artwork: null,
@@ -65,7 +120,162 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
site: 'site', site: 'site',
} }
const MAGENT_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'magent-runtime',
title: 'Application',
description:
'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
keys: [
'magent_application_url',
'magent_application_port',
'magent_api_url',
'magent_api_port',
'magent_bind_host',
],
},
{
key: 'magent-proxy',
title: 'Proxy',
description:
'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
keys: [
'magent_proxy_enabled',
'magent_proxy_base_url',
'magent_proxy_trust_forwarded_headers',
'magent_proxy_forwarded_prefix',
],
},
{
key: 'magent-ssl',
title: 'Manual SSL Bind',
description:
'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
keys: [
'magent_ssl_bind_enabled',
'magent_ssl_certificate_path',
'magent_ssl_private_key_path',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
],
},
{
key: 'magent-notify-core',
title: 'Notifications',
description:
'Global notification controls and provider-independent defaults used by Magent messaging features.',
keys: ['magent_notify_enabled'],
},
{
key: 'magent-notify-email',
title: 'Email',
description: 'SMTP configuration for email notifications.',
keys: [
'magent_notify_email_enabled',
'magent_notify_email_smtp_host',
'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username',
'magent_notify_email_smtp_password',
'magent_notify_email_from_address',
'magent_notify_email_from_name',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
],
},
{
key: 'magent-notify-discord',
title: 'Discord',
description: 'Webhook settings for Discord notifications and feedback routing.',
keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
},
{
key: 'magent-notify-telegram',
title: 'Telegram',
description: 'Bot token and chat target for Telegram notifications.',
keys: [
'magent_notify_telegram_enabled',
'magent_notify_telegram_bot_token',
'magent_notify_telegram_chat_id',
],
},
{
key: 'magent-notify-push',
title: 'Push / Mobile',
description:
'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
keys: [
'magent_notify_push_enabled',
'magent_notify_push_provider',
'magent_notify_push_base_url',
'magent_notify_push_topic',
'magent_notify_push_token',
'magent_notify_push_user_key',
'magent_notify_push_device',
'magent_notify_webhook_enabled',
'magent_notify_webhook_url',
],
},
]
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
notifications: new Set([
'magent-notify-core',
'magent-notify-email',
'magent-notify-discord',
'magent-notify-telegram',
'magent-notify-push',
]),
}
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_application_url: 'Application URL',
magent_application_port: 'Application port',
magent_api_url: 'API URL',
magent_api_port: 'API port',
magent_bind_host: 'Bind host',
magent_proxy_enabled: 'Proxy support enabled',
magent_proxy_base_url: 'Proxy base URL',
magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
magent_proxy_forwarded_prefix: 'Forwarded path prefix',
magent_ssl_bind_enabled: 'Manual SSL bind enabled',
magent_ssl_certificate_path: 'Certificate path',
magent_ssl_private_key_path: 'Private key path',
magent_ssl_certificate_pem: 'Certificate (PEM)',
magent_ssl_private_key_pem: 'Private key (PEM)',
magent_notify_enabled: 'Notifications enabled',
magent_notify_email_enabled: 'Email notifications enabled',
magent_notify_email_smtp_host: 'SMTP host',
magent_notify_email_smtp_port: 'SMTP port',
magent_notify_email_smtp_username: 'SMTP username',
magent_notify_email_smtp_password: 'SMTP password',
magent_notify_email_from_address: 'From email address',
magent_notify_email_from_name: 'From display name',
magent_notify_email_use_tls: 'Use STARTTLS',
magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
magent_notify_discord_enabled: 'Discord notifications enabled',
magent_notify_discord_webhook_url: 'Discord webhook URL',
magent_notify_telegram_enabled: 'Telegram notifications enabled',
magent_notify_telegram_bot_token: 'Telegram bot token',
magent_notify_telegram_chat_id: 'Telegram chat ID',
magent_notify_push_enabled: 'Push notifications enabled',
magent_notify_push_provider: 'Push provider',
magent_notify_push_base_url: 'Push provider/base URL',
magent_notify_push_topic: 'Topic / channel',
magent_notify_push_token: 'API token / password',
magent_notify_push_user_key: 'User key / recipient key',
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
}
const labelFromKey = (key: string) => const labelFromKey = (key: string) =>
SETTING_LABEL_OVERRIDES[key] ??
key key
.replaceAll('_', ' ') .replaceAll('_', ' ')
.replace('base url', 'URL') .replace('base url', 'URL')
@@ -106,6 +316,13 @@ type SettingsPageProps = {
section: string section: string
} }
type SettingsSectionGroup = {
key: string
title: string
items: AdminSetting[]
description?: string
}
export default function SettingsPage({ section }: SettingsPageProps) { export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter() const router = useRouter()
const [settings, setSettings] = useState<AdminSetting[]>([]) const [settings, setSettings] = useState<AdminSetting[]>([])
@@ -132,6 +349,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null) const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -273,6 +493,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [settings]) }, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const visibleSections = settingsSection ? [settingsSection] : [] const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache' const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source']) const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
@@ -296,26 +517,49 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key)) const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection const settingsSections: SettingsSectionGroup[] = isCacheSection
? [ ? [
{ key: 'cache', title: 'Cache control', items: cacheSettings }, { key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings }, { key: 'artwork', title: 'Artwork cache', items: artworkSettings },
] ]
: visibleSections.map((sectionKey) => ({ : isMagentGroupedSection
key: sectionKey, ? (() => {
title: SECTION_LABELS[sectionKey] ?? sectionKey, if (section === 'magent') {
items: (() => { return []
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
} }
return filtered const magentItems = groupedSettings.magent ?? []
})(), const byKey = new Map(magentItems.map((item) => [item.key, item]))
})) const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
allowedGroupKeys.has(group.key),
).map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
return groups
})()
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
return filtered
})(),
}))
const showLogs = section === 'logs' const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance' const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests' const showRequestsExtras = section === 'requests'
@@ -329,27 +573,99 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false return false
} }
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = { const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.', 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.', jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.', jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.', jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.', jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.', artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.', sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.', sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.', sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.', radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.', radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.', radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.', prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.', prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.', qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
@@ -371,6 +687,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_changelog: 'One update per line for the public changelog.', site_changelog: 'One update per line for the public changelog.',
} }
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',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
const buildSelectOptions = ( const buildSelectOptions = (
currentValue: string, currentValue: string,
options: { id: number; label: string; path?: string }[], options: { id: number; label: string; path?: string }[],
@@ -552,7 +901,118 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') { const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
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)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
}
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)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (err) {
if (closed) return
console.error(err)
setLiveStreamConnected(false)
}
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return return
} }
let active = true let active = true
@@ -578,7 +1038,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [artworkPrefetch, loadArtworkSummary]) }, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') { if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -591,7 +1051,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch]) }, [artworkPrefetch])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') { if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return return
} }
let active = true let active = true
@@ -616,7 +1076,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [requestsSync]) }, [liveStreamConnected, requestsSync])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') { if (!requestsSync || requestsSync.status === 'running') {
@@ -659,12 +1119,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (!showLogs) { if (!showLogs) {
return return
} }
if (liveStreamConnected) {
return
}
void loadLogs() void loadLogs()
const timer = setInterval(() => { const timer = setInterval(() => {
void loadLogs() void loadLogs()
}, 5000) }, 5000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [loadLogs, showLogs]) }, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => { const loadCache = async () => {
setCacheStatus(null) setCacheStatus(null)
@@ -739,7 +1202,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true) setMaintenanceBusy(true)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const ok = window.confirm( const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?' 'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
) )
if (!ok) { if (!ok) {
setMaintenanceBusy(false) setMaintenanceBusy(false)
@@ -748,7 +1211,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...') setMaintenanceStatus('Running nuclear flush...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, { const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST', method: 'POST',
}) })
@@ -756,12 +1219,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text() const text = await flushResponse.text()
throw new Error(text || 'Flush failed') throw new Error(text || 'Flush failed')
} }
setMaintenanceStatus('Database flushed. Starting re-sync...') const flushData = await flushResponse.json()
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!usersResyncResponse.ok) {
const text = await usersResyncResponse.text()
throw new Error(text || 'User resync failed')
}
const usersResyncData = await usersResyncResponse.json()
setMaintenanceStatus(
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
)
await syncRequests() await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.') setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setMaintenanceStatus('Flush + resync failed.') setMaintenanceStatus('Nuclear flush + resync failed.')
} finally { } finally {
setMaintenanceBusy(false) setMaintenanceBusy(false)
} }
@@ -786,6 +1262,86 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const cacheSourceLabel =
formValues.requests_data_source === 'always_js'
? 'Jellyseerr direct'
: formValues.requests_data_source === 'prefer_cache'
? 'Saved requests only'
: 'Saved requests only'
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
const cacheRail = showCacheExtras ? (
<div className="admin-rail-stack">
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Cache control</span>
<h2>Saved requests</h2>
<p>Load and inspect cached request entries from the right rail.</p>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Data source</span>
<strong>{cacheSourceLabel}</strong>
</div>
<div className="cache-rail-metric">
<span>Refresh TTL</span>
<strong>{cacheTtlLabel} min</strong>
</div>
<div className="cache-rail-metric">
<span>Rows loaded</span>
<strong>{cacheRows.length}</strong>
</div>
<div className="cache-rail-metric">
<span>Live updates</span>
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
</div>
</div>
<label className="cache-rail-limit">
<span>Rows to load</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
</div>
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Artwork</span>
<h2>Cache stats</h2>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Missing artwork</span>
<strong>{artworkSummary?.missing_artwork ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Cache size</span>
<strong>{formatBytes(artworkSummary?.cache_bytes)}</strong>
</div>
<div className="cache-rail-metric">
<span>Cached files</span>
<strong>{artworkSummary?.cache_files ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Mode</span>
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
</div>
</div>
</div>
</div>
) : undefined
if (loading) { if (loading) {
return <main className="card">Loading admin settings...</main> return <main className="card">Loading admin settings...</main>
} }
@@ -794,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell <AdminShell
title={SECTION_LABELS[section] ?? 'Settings'} title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'} subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={cacheRail}
actions={ actions={
<button type="button" onClick={() => router.push('/admin')}> <button type="button" onClick={() => router.push('/admin')}>
Back to settings Back to settings
@@ -856,8 +1413,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div> </div>
)} )}
</div> </div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && ( {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p> (!settingsSection || isMagentGroupedSection) && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
)}
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
<div className="status-banner">
Runtime host/port and SSL values are configuration settings. Container/process
restarts may still be required before bind/port changes take effect.
</div>
)} )}
{sectionGroup.key === 'sonarr' && sonarrError && ( {sectionGroup.key === 'sonarr' && sonarrError && (
<div className="error-banner">{sonarrError}</div> <div className="error-banner">{sonarrError}</div>
@@ -982,6 +1548,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder' const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key) const isBoolSetting = BOOL_SETTINGS.has(setting.key)
const isUrlSetting = URL_SETTINGS.has(setting.key)
const inputPlaceholder = setting.sensitive && setting.isSet
? 'Configured (enter to replace)'
: settingPlaceholders[setting.key] ?? ''
if (isBoolSetting) { if (isBoolSetting) {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
@@ -1191,6 +1761,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'magent_notify_push_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'ntfy'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="ntfy">ntfy</option>
<option value="gotify">Gotify</option>
<option value="pushover">Pushover</option>
<option value="webhook">Webhook</option>
<option value="telegram">Telegram relay</option>
<option value="discord">Discord relay</option>
</select>
</label>
)
}
if ( if (
setting.key === 'requests_full_sync_time' || setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time' setting.key === 'requests_cleanup_time'
@@ -1217,10 +1816,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if ( if (NUMBER_SETTINGS.has(setting.key)) {
setting.key === 'requests_delta_sync_interval_minutes' ||
setting.key === 'requests_cleanup_days'
) {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row"> <span className="label-row">
@@ -1233,6 +1829,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
name={setting.key} name={setting.key}
type="number" type="number"
min={1} min={1}
step={1}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setFormValues((current) => ({ setFormValues((current) => ({
@@ -1272,8 +1869,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) )
} }
if (TEXTAREA_SETTINGS.has(setting.key)) { if (TEXTAREA_SETTINGS.has(setting.key)) {
const isPemField =
setting.key === 'magent_ssl_certificate_pem' ||
setting.key === 'magent_ssl_private_key_pem'
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label
key={setting.key}
data-helper={helperText || undefined}
className={isPemField ? 'field-span-full' : undefined}
>
<span className="label-row"> <span className="label-row">
<span>{labelFromKey(setting.key)}</span> <span>{labelFromKey(setting.key)}</span>
<span className="meta"> <span className="meta">
@@ -1283,11 +1887,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span> </span>
<textarea <textarea
name={setting.key} name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3} rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
placeholder={ placeholder={
setting.key === 'site_changelog' setting.key === 'site_changelog'
? 'One update per line.' ? 'One update per line.'
: '' : settingPlaceholders[setting.key] ?? ''
} }
value={value} value={value}
onChange={(event) => onChange={(event) =>
@@ -1312,9 +1916,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input <input
name={setting.key} name={setting.key}
type={setting.sensitive ? 'password' : 'text'} type={setting.sensitive ? 'password' : 'text'}
placeholder={ placeholder={inputPlaceholder}
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : '' autoComplete={isUrlSetting ? 'url' : undefined}
}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setFormValues((current) => ({ setFormValues((current) => ({
@@ -1336,7 +1939,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</form> </form>
) : ( ) : (
<div className="status-banner"> <div className="status-banner">
No settings to show here yet. Try the Cache Control page for artwork and saved-request controls. {section === 'magent'
? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.'
: 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
</div> </div>
)} )}
{showLogs && ( {showLogs && (
@@ -1369,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<section className="admin-section" id="cache"> <section className="admin-section" id="cache">
<div className="section-header"> <div className="section-header">
<h2>Saved requests (cache)</h2> <h2>Saved requests (cache)</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Rows to show</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
</div>
</div> </div>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<div className="cache-table"> <div className="cache-table">
<div className="cache-row cache-head"> <div className="cache-row cache-head">
<span>Request</span> <span>Request</span>
@@ -1425,7 +2005,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2> <h2>Maintenance</h2>
</div> </div>
<div className="status-banner"> <div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history. Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
</div> </div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>} {maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid"> <div className="maintenance-grid">
@@ -1444,7 +2024,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={runFlushAndResync} onClick={runFlushAndResync}
disabled={maintenanceBusy} disabled={maintenanceBusy}
> >
Flush database + resync Nuclear flush + resync
</button> </button>
</div> </div>
</section> </section>

View File

@@ -13,6 +13,9 @@ const ALLOWED_SECTIONS = new Set([
'cache', 'cache',
'logs', 'logs',
'maintenance', 'maintenance',
'magent',
'general',
'notifications',
'site', 'site',
]) ])

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,10 @@ export default function HowItWorksPage() {
<main className="card how-page"> <main className="card how-page">
<header className="how-hero"> <header className="how-hero">
<p className="eyebrow">How this works</p> <p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1> <h1>How Magent works now</h1>
<p className="lede"> <p className="lede">
Magent is a friendly status checker. It looks at a few helper apps, then shows you where End-to-end request flow, live status updates, and the exact tools available to users and
your request is and what you can safely do next. admins.
</p> </p>
</header> </header>
@@ -52,90 +52,172 @@ export default function HowItWorksPage() {
</section> </section>
<section className="how-flow"> <section className="how-flow">
<h2>The pipeline in plain English</h2> <h2>The pipeline (request to ready)</h2>
<ol className="how-steps"> <ol className="how-steps">
<li> <li>
<strong>You request a title</strong> in Jellyseerr. <strong>Request created</strong> in Jellyseerr.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr adds it</strong> to the library list. <strong>Approved</strong> and sent to Sonarr/Radarr.
</li> </li>
<li> <li>
<strong>Prowlarr looks for sources</strong> and sends results back. <strong>Search runs</strong> against indexers via Prowlarr.
</li> </li>
<li> <li>
<strong>qBittorrent downloads</strong> the match. <strong>Grabbed</strong> and downloaded by qBittorrent.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr imports</strong> it into your library. <strong>Imported</strong> by Sonarr/Radarr.
</li> </li>
<li> <li>
<strong>Jellyfin shows it</strong> when it is ready to watch. <strong>Available</strong> in Jellyfin.
</li> </li>
</ol> </ol>
</section> </section>
<section className="how-flow"> <section className="how-flow">
<h2>Steps and fixes (simple and visual)</h2> <h2>Live updates (no refresh needed)</h2>
<div className="how-step-grid">
<article className="how-step-card step-arr">
<div className="step-badge">1</div>
<h3>Request page updates in real time</h3>
<p className="step-note">
Status, timeline hops, and action history update automatically while you are viewing
the request.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Download progress updates live</h3>
<p className="step-note">
Torrent progress, queue state, and downloader details refresh automatically so users
do not need to hard refresh.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as import finishes</h3>
<p className="step-note">
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
shows it as ready.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<div className="how-step-grid"> <div className="how-step-grid">
<article className="how-step-card step-jellyseerr"> <article className="how-step-card step-jellyseerr">
<div className="step-badge">1</div> <div className="step-badge">1</div>
<h3>Request sent</h3> <h3>Re-add to Arr</h3>
<p className="step-note">Jellyseerr holds your request and approval.</p> <p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Add to library queue (if it was approved but never added)</li> <li>Missing NEEDS_ADD / ADDED state transitions</li>
<li>Queue repair after Arr-side cleanup</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-arr"> <article className="how-step-card step-arr">
<div className="step-badge">2</div> <div className="step-badge">2</div>
<h3>Added to the library list</h3> <h3>Search releases</h3>
<p className="step-note">Sonarr/Radarr decide what quality to get.</p> <p className="step-note">Runs a search and shows concrete release options.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Search for releases (see options)</li> <li>Manual selection of a specific release/indexer</li>
<li>Search and auto-download (let it pick for you)</li> <li>Checking whether results currently exist</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-prowlarr"> <article className="how-step-card step-prowlarr">
<div className="step-badge">3</div> <div className="step-badge">3</div>
<h3>Searching for sources</h3> <h3>Search + auto-download</h3>
<p className="step-note">Prowlarr checks your torrent providers.</p> <p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Search for releases (show a list to choose)</li> <li>Fast recovery when users have auto-search access</li>
<li>Hands-off retry of stalled requests</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-qbit"> <article className="how-step-card step-qbit">
<div className="step-badge">4</div> <div className="step-badge">4</div>
<h3>Downloading the file</h3> <h3>Resume download</h3>
<p className="step-note">qBittorrent downloads the selected match.</p> <p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Resume download (only if it already exists there)</li> <li>Paused queue entries</li>
<li>Downloader restarts</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-jellyfin"> <article className="how-step-card step-jellyfin">
<div className="step-badge">5</div> <div className="step-badge">5</div>
<h3>Ready to watch</h3> <h3>Open in Jellyfin</h3>
<p className="step-note">Jellyfin shows it in your library.</p> <p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
<div className="step-fix-title">What to do next</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Open in Jellyfin (watch it)</li> <li>Immediate playback confirmation</li>
<li>User handoff from request tracking to watching</li>
</ul> </ul>
</article> </article>
</div> </div>
</section> </section>
<section className="how-flow">
<h2>Invite and account flow</h2>
<ol className="how-steps">
<li>
<strong>Invite created</strong> by admin or eligible user.
</li>
<li>
<strong>User signs up</strong> and Magent creates/links the account.
</li>
<li>
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
</li>
<li>
<strong>Admin trace map</strong> can show inviter invited lineage.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Admin controls available</h2>
<div className="how-grid">
<article className="how-card">
<h3>General</h3>
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
</article>
<article className="how-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
</article>
<article className="how-card">
<h3>Users</h3>
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
</article>
<article className="how-card">
<h3>Invite management</h3>
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
</article>
<article className="how-card">
<h3>Request sync + cache</h3>
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
</article>
<article className="how-card">
<h3>Maintenance + logs</h3>
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
</article>
</div>
</section>
<section className="how-callout"> <section className="how-callout">
<h2>Why Magent sometimes says &quot;waiting&quot;</h2> <h2>Why a request can still wait</h2>
<p> <p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab. If indexers do not return a valid release yet, Magent will show waiting/search states.
That does not mean it is broken. It usually means the release is not available yet. That usually means content availability is the blocker, not a broken pipeline.
</p> </p>
</section> </section>
</main> </main>

View File

@@ -23,3 +23,18 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
} }
return fetch(input, { ...init, headers }) return fetch(input, { ...init, headers })
} }
export const getEventStreamToken = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/stream-token`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Stream token request failed: ${response.status}`)
}
const data = await response.json()
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
if (!token) {
throw new Error('Stream token not returned')
}
return token
}

View File

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

View File

@@ -2,7 +2,25 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth' import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
@@ -33,6 +51,7 @@ export default function HomePage() {
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({}) const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({}) const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -137,25 +156,7 @@ export default function HomePage() {
} }
const data = await response.json() const data = await response.json()
if (Array.isArray(data?.results)) { if (Array.isArray(data?.results)) {
setRecent( setRecent(normalizeRecentResults(data.results))
data.results
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' &&
rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -196,10 +197,92 @@ export default function HomePage() {
} }
} }
load() void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000) const timer = setInterval(load, 30000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [authReady, router]) }, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
if (!getToken()) {
setLiveStreamConnected(false)
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/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
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)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays])
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -8,6 +8,7 @@ type ProfileInfo = {
username: string username: string
role: string role: string
auth_provider: string auth_provider: string
invite_management_enabled?: boolean
} }
type ProfileStats = { type ProfileStats = {
@@ -47,6 +48,61 @@ type ProfileResponse = {
activity: ProfileActivity activity: ProfileActivity
} }
type OwnedInvite = {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
}
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
}
type OwnedInviteForm = {
code: string
label: string
description: string
max_uses: string
expires_at: string
enabled: boolean
}
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
max_uses: '',
expires_at: '',
enabled: true,
})
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
if (!value) return 'Never' if (!value) return 'Never'
const date = new Date(value) const date = new Date(value)
@@ -72,8 +128,23 @@ export default function ProfilePage() {
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -82,21 +153,32 @@ export default function ProfilePage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile`) const [profileResponse, invitesResponse] = await Promise.all([
if (!response.ok) { authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
clearToken() clearToken()
router.push('/login') router.push('/login')
return return
} }
const data = await response.json() const [data, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const user = data?.user ?? {} const user = data?.user ?? {}
setProfile({ setProfile({
username: user?.username ?? 'Unknown', username: user?.username ?? 'Unknown',
role: user?.role ?? 'user', role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local', auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
}) })
setStats(data?.stats ?? null) setStats(data?.stats ?? null)
setActivity(data?.activity ?? null) setActivity(data?.activity ?? null)
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('Could not load your profile.') setStatus('Could not load your profile.')
@@ -125,18 +207,177 @@ export default function ProfilePage() {
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const text = await response.text() let detail = 'Update failed'
throw new Error(text || 'Update failed') try {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
detail = payload.detail
}
} catch {
const text = await response.text().catch(() => '')
if (text?.trim()) detail = text
}
throw new Error(detail)
} }
const data = await response.json().catch(() => ({}))
setCurrentPassword('') setCurrentPassword('')
setNewPassword('') setNewPassword('')
setStatus('Password updated.') setStatus(
data?.provider === 'jellyfin'
? 'Password updated in Jellyfin (and Magent cache).'
: 'Password updated.'
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('Could not update password. Check your current password.') if (err instanceof Error && err.message) {
setStatus(`Could not update password. ${err.message}`)
} else {
setStatus('Could not update password. Check your current password.')
}
} }
} }
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
resetInviteEditor()
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const authProvider = profile?.auth_provider ?? 'local'
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
const securityHelpText =
authProvider === 'jellyfin'
? 'Changing your password here updates your Jellyfin account and refreshes 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) { if (loading) {
return <main className="card">Loading profile...</main> return <main className="card">Loading profile...</main>
} }
@@ -150,8 +391,51 @@ export default function ProfilePage() {
{profile.auth_provider}. {profile.auth_provider}.
</div> </div>
)} )}
<div className="profile-grid"> <div className="profile-tabbar">
<section className="profile-section"> <div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'overview'}
className={activeTab === 'overview' ? 'is-active' : ''}
onClick={() => setActiveTab('overview')}
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'activity'}
className={activeTab === 'activity' ? 'is-active' : ''}
onClick={() => setActiveTab('activity')}
>
Activity
</button>
{canManageInvites ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
My invites
</button>
) : null}
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={activeTab === 'security' ? 'is-active' : ''}
onClick={() => setActiveTab('security')}
>
Security
</button>
</div>
</div>
{activeTab === 'overview' && (
<section className="profile-section profile-tab-panel">
<h2>Account stats</h2> <h2>Account stats</h2>
<div className="stat-grid"> <div className="stat-grid">
<div className="stat-card"> <div className="stat-card">
@@ -174,6 +458,18 @@ export default function ProfilePage() {
<div className="stat-label">Declined</div> <div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div> <div className="stat-value">{stats?.declined ?? 0}</div>
</div> </div>
<div className="stat-card">
<div className="stat-label">Working</div>
<div className="stat-value">{stats?.working ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Partial</div>
<div className="stat-value">{stats?.partial ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Approved</div>
<div className="stat-value">{stats?.approved ?? 0}</div>
</div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-label">Last request</div> <div className="stat-label">Last request</div>
<div className="stat-value stat-value--small"> <div className="stat-value stat-value--small">
@@ -188,6 +484,10 @@ export default function ProfilePage() {
: '0%'} : '0%'}
</div> </div>
</div> </div>
<div className="stat-card">
<div className="stat-label">Total requests (global)</div>
<div className="stat-value">{stats?.global_total ?? 0}</div>
</div>
{profile?.role === 'admin' ? ( {profile?.role === 'admin' ? (
<div className="stat-card"> <div className="stat-card">
<div className="stat-label">Most active user</div> <div className="stat-label">Most active user</div>
@@ -200,7 +500,10 @@ export default function ProfilePage() {
) : null} ) : null}
</div> </div>
</section> </section>
<section className="profile-section"> )}
{activeTab === 'activity' && (
<section className="profile-section profile-tab-panel">
<h2>Connection history</h2> <h2>Connection history</h2>
<div className="status-banner"> <div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}. Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
@@ -211,6 +514,7 @@ export default function ProfilePage() {
<div> <div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div> <div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div> <div className="meta">IP: {entry.ip}</div>
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div> <div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div> </div>
<div className="connection-count">{entry.hit_count} visits</div> <div className="connection-count">{entry.hit_count} visits</div>
@@ -221,36 +525,254 @@ export default function ProfilePage() {
) : null} ) : null}
</div> </div>
</section> </section>
</div> )}
{profile?.auth_provider !== 'local' ? (
<div className="status-banner"> {activeTab === 'invites' && (
Password changes are only available for local Magent accounts. <section className="profile-section profile-invites-section profile-tab-panel">
</div> <div className="user-directory-panel-header">
) : ( <div>
<form onSubmit={submit} className="auth-form"> <h2>My invites</h2>
<label> <p className="lede">
Current password {inviteManagedByMaster
<input ? 'Create and manage invite links youve issued. New invites use the admin master invite rule.'
type="password" : 'Create and manage invite links youve issued. New invites use your account defaults.'}
value={currentPassword} </p>
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Update password</button>
</div> </div>
</form> </div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Share the generated signup link with the person you want to invite.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You 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> </main>
) )

View File

@@ -3,7 +3,7 @@
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
type TimelineHop = { type TimelineHop = {
service: string service: string
@@ -254,6 +254,64 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
load() load()
}, [params.id, router]) }, [params.id, router])
useEffect(() => {
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
params.id
)}/stream?stream_token=${encodeURIComponent(streamToken)}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
return
}
if (String(payload.request_id ?? '') !== String(params.id)) {
return
}
if (payload.snapshot && typeof payload.snapshot === 'object') {
setSnapshot(payload.snapshot as Snapshot)
}
if (Array.isArray(payload.history)) {
setHistorySnapshots(payload.history as SnapshotHistory[])
}
if (Array.isArray(payload.actions)) {
setHistoryActions(payload.actions as ActionHistory[])
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [params.id])
if (loading) { if (loading) {
return ( return (
<main className="card"> <main className="card">

View File

@@ -0,0 +1,223 @@
'use client'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
code: string
label?: string | null
description?: string | null
enabled: boolean
is_expired?: boolean
is_usable?: boolean
expires_at?: string | null
max_uses?: number | null
use_count?: number | null
remaining_uses?: number | null
profile?: {
id: number
name: string
description?: string | null
} | null
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
function SignupPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
const [invite, setInvite] = useState<InviteInfo | null>(null)
const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && username.trim() && password && !loading)
}, [invite, username, password, loading])
const lookupInvite = async (code: string) => {
const trimmed = code.trim()
if (!trimmed) {
setInvite(null)
return
}
setInviteLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite not found')
}
const data = await response.json()
setInvite(data?.invite ?? null)
setStatus('Invite loaded.')
} catch (err) {
console.error(err)
setInvite(null)
setError('Invite code not found or unavailable.')
} finally {
setInviteLoading(false)
}
}
useEffect(() => {
const initialCode = searchParams.get('code') ?? ''
if (initialCode) {
setInviteCode(initialCode)
void lookupInvite(initialCode)
}
}, [searchParams])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (!inviteCode.trim()) {
setError('Invite code is required.')
return
}
if (!invite?.is_usable) {
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
clearToken()
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
password,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
window.location.href = '/'
return
}
throw new Error('Sign-up did not return a token')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
} finally {
setLoading(false)
}
}
return (
<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 your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? 'Checking…' : 'Check invite'}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div>
)}
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<SignupPageContent />
</Suspense>
)
}

View File

@@ -7,10 +7,11 @@ type AdminShellProps = {
title: string title: string
subtitle?: string subtitle?: string
actions?: ReactNode actions?: ReactNode
rail?: ReactNode
children: ReactNode children: ReactNode
} }
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) { export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return ( return (
<div className="admin-shell"> <div className="admin-shell">
<aside className="admin-shell-nav"> <aside className="admin-shell-nav">
@@ -26,6 +27,16 @@ export default function AdminShell({ title, subtitle, actions, children }: Admin
</div> </div>
{children} {children}
</main> </main>
<aside className="admin-shell-rail">
{rail ?? (
<div className="admin-rail-card admin-rail-card--placeholder">
<span className="admin-rail-eyebrow">Insights</span>
<h2>Stats rail</h2>
<p>Use this column for counters, live status, and quick metrics for this page.</p>
<span className="small-pill">{title}</span>
</div>
)}
</aside>
</div> </div>
) )
} }

View File

@@ -6,6 +6,7 @@ const NAV_GROUPS = [
{ {
title: 'Services', title: 'Services',
items: [ items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' }, { href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' }, { href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' }, { href: '/admin/sonarr', label: 'Sonarr' },
@@ -25,8 +26,11 @@ const NAV_GROUPS = [
{ {
title: 'Admin', title: 'Admin',
items: [ items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'System guide' },
{ href: '/admin/site', label: 'Site' }, { href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
{ href: '/admin/logs', label: 'Activity log' }, { href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/maintenance', label: 'Maintenance' },
], ],

View File

@@ -24,7 +24,34 @@ type AdminUser = {
auth_provider?: string | null auth_provider?: string | null
last_login_at?: string | null last_login_at?: string | null
is_blocked?: boolean is_blocked?: boolean
auto_search_enabled?: boolean
invite_management_enabled?: boolean
jellyseerr_user_id?: number | null 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
is_active?: boolean
} }
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
@@ -34,6 +61,22 @@ const formatDateTime = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ''
const offsetMs = date.getTimezoneOffset() * 60_000
const local = new Date(date.getTime() - offsetMs)
return local.toISOString().slice(0, 16)
}
const fromLocalDateTimeInput = (value: string) => {
if (!value.trim()) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return null
return date.toISOString()
}
const normalizeStats = (stats: any): UserStats => ({ const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0), total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0), ready: Number(stats?.ready ?? 0),
@@ -54,6 +97,38 @@ export default function UserDetailPage() {
const [stats, setStats] = useState<UserStats | null>(null) const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
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 {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
return
}
const data = await response.json()
if (!Array.isArray(data?.profiles)) {
setProfiles([])
return
}
setProfiles(
data.profiles.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed profile'),
is_active: Boolean(profile.is_active ?? true),
}))
)
} catch (err) {
console.error(err)
}
}
const loadUser = async () => { const loadUser = async () => {
if (!idParam) return if (!idParam) return
@@ -79,8 +154,16 @@ export default function UserDetailPage() {
throw new Error('Could not load user.') throw new Error('Could not load user.')
} }
const data = await response.json() const data = await response.json()
setUser(data?.user ?? null) const nextUser = data?.user ?? null
setUser(nextUser)
setStats(normalizeStats(data?.stats)) setStats(normalizeStats(data?.stats))
setLineage((data?.lineage ?? null) as UserLineage)
setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? ''
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setError(null) setError(null)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -93,6 +176,7 @@ export default function UserDetailPage() {
const toggleUserBlock = async (blocked: boolean) => { const toggleUserBlock = async (blocked: boolean) => {
if (!user) return if (!user) return
try { try {
setActionStatus(null)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`, `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
@@ -102,6 +186,7 @@ export default function UserDetailPage() {
throw new Error('Update failed') throw new Error('Update failed')
} }
await loadUser() await loadUser()
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not update user access.') setError('Could not update user access.')
@@ -111,6 +196,7 @@ export default function UserDetailPage() {
const updateUserRole = async (role: string) => { const updateUserRole = async (role: string) => {
if (!user) return if (!user) return
try { try {
setActionStatus(null)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`, `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
@@ -124,18 +210,215 @@ export default function UserDetailPage() {
throw new Error('Update failed') throw new Error('Update failed')
} }
await loadUser() await loadUser()
setActionStatus(`Role updated to ${role}.`)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not update user role.') setError('Could not update user role.')
} }
} }
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) {
console.error(err)
setError('Could not update auto search access.')
}
}
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
setSavingProfile(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileValue || null }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile update failed')
}
await loadUser()
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
} catch (err) {
console.error(err)
setError('Could not update user profile.')
} finally {
setSavingProfile(false)
}
}
const saveUserExpiry = async () => {
if (!user) return
const expiresAt = fromLocalDateTimeInput(expiryInput)
if (expiryInput.trim() && !expiresAt) {
setError('Invalid expiry date/time.')
return
}
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expires_at: expiresAt }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry update failed')
}
await loadUser()
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not update user expiry.')
} finally {
setSavingExpiry(false)
}
}
const clearUserExpiry = async () => {
if (!user) return
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry clear failed')
}
setExpiryInput('')
await loadUser()
setActionStatus('User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not clear user expiry.')
} finally {
setSavingExpiry(false)
}
}
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
)
if (!confirmed) return
}
setSystemActionBusy(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Cross-system action failed')
}
const state = data?.status === 'partial' ? 'partial' : 'complete'
if (action === 'remove') {
setActionStatus(`User removed (${state}).`)
router.push('/users')
return
}
await loadUser()
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
} finally {
setSystemActionBusy(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
return return
} }
void loadUser() void loadUser()
void loadProfiles()
}, [router, idParam]) }, [router, idParam])
if (loading) { if (loading) {
@@ -154,22 +437,116 @@ export default function UserDetailPage() {
> >
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{actionStatus && <div className="status-banner">{actionStatus}</div>}
{!user ? ( {!user ? (
<div className="status-banner">No user data found.</div> <div className="status-banner">No user data found.</div>
) : ( ) : (
<> <div className="user-detail-page-grid">
<div className="user-detail-card"> <div className="user-detail-main-column">
<div className="user-detail-header"> <div className="admin-panel user-detail-panel">
<div> <div className="user-detail-panel-header">
<strong>{user.username}</strong> <div className="user-detail-title-row">
<div className="user-detail-meta"> <strong className="user-detail-name">{user.username}</strong>
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span> <span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
<span className="meta">Role: {user.role}</span> {user.is_blocked ? 'Blocked' : 'Active'}
<span className="meta">Login type: {user.auth_provider || 'local'}</span> </span>
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span> <span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
</span>
</div>
<p className="lede">
User identity, access state, and request history for this account.
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Jellyseerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
<strong>{user.role}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Login type</span>
<strong>{user.auth_provider || 'local'}</strong>
</div>
<div className="user-detail-meta-item">
<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>
</div>
<div className="user-detail-meta-item">
<span className="label">Account expiry</span>
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
</div> </div>
</div> </div>
<div className="user-actions"> </div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Request statistics</h2>
<p className="lede">Snapshot of request states and recent activity for this user.</p>
</div>
<div className="user-detail-grid">
<div className="user-detail-stat">
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div className="user-detail-stat user-detail-stat--wide">
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
<p className="lede">Role, login access, and auto-download behavior.</p>
</div>
<div className="user-detail-control-stack">
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"
@@ -178,55 +555,135 @@ export default function UserDetailPage() {
/> />
<span>Make admin</span> <span>Make admin</span>
</label> </label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<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 <button
type="button" type="button"
className="ghost-button" className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)} onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
> >
{user.is_blocked ? 'Allow access' : 'Block access'} {user.is_blocked ? 'Allow access' : 'Block access'}
</button> </button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download and invite-management access.
</div>
)}
</div> </div>
</div> </div>
<div className="user-detail-grid">
<div> <div className="admin-panel user-detail-panel">
<span className="label">Total</span> <div className="user-detail-panel-header">
<span className="value">{stats?.total ?? 0}</span> <h2>Profile defaults</h2>
<p className="lede">Assign or clear an invite profile for this user.</p>
</div> </div>
<div> <div className="user-detail-actions user-detail-actions--stacked">
<span className="label">Ready</span> <label className="admin-select">
<span className="value">{stats?.ready ?? 0}</span> <span>Assigned profile</span>
<select
value={profileSelection}
onChange={(event) => setProfileSelection(event.target.value)}
disabled={savingProfile}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setProfileSelection('')
void applyProfileToUser('')
}}
disabled={savingProfile}
>
Clear profile
</button>
</div>
</div> </div>
<div> </div>
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span> <div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Account expiry</h2>
<p className="lede">Set a specific expiry date/time for this user account.</p>
</div> </div>
<div> <div className="user-detail-actions user-detail-actions--stacked">
<span className="label">Approved</span> <label>
<span className="value">{stats?.approved ?? 0}</span> <span className="user-bulk-label">Account expiry</span>
</div> <input
<div> type="datetime-local"
<span className="label">Working</span> value={expiryInput}
<span className="value">{stats?.working ?? 0}</span> onChange={(event) => setExpiryInput(event.target.value)}
</div> disabled={savingExpiry}
<div> />
<span className="label">Partial</span> </label>
<span className="value">{stats?.partial ?? 0}</span> <div className="admin-inline-actions">
</div> <button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
<div> {savingExpiry ? 'Saving...' : 'Save expiry'}
<span className="label">Declined</span> </button>
<span className="value">{stats?.declined ?? 0}</span> <button
</div> type="button"
<div> className="ghost-button"
<span className="label">In progress</span> onClick={clearUserExpiry}
<span className="value">{stats?.in_progress ?? 0}</span> disabled={savingExpiry}
</div> >
<div> Clear expiry
<span className="label">Last request</span> </button>
<span className="value">{formatDateTime(stats?.last_request_at)}</span> </div>
</div> </div>
</div> </div>
</div> </div>
</> </div>
)} )}
</section> </section>
</AdminShell> </AdminShell>

View File

@@ -13,6 +13,10 @@ type AdminUser = {
authProvider?: string | null authProvider?: string | null
lastLoginAt?: string | null lastLoginAt?: string | null
isBlocked?: boolean isBlocked?: boolean
autoSearchEnabled?: boolean
profileId?: number | null
expiresAt?: string | null
isExpired?: boolean
stats?: UserStats stats?: UserStats
} }
@@ -42,6 +46,13 @@ const formatLastRequest = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const formatExpiry = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = { const emptyStats: UserStats = {
total: 0, total: 0,
ready: 0, ready: 0,
@@ -71,9 +82,11 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null) const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -100,6 +113,13 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local', authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked), isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
profileId:
user.profile_id == null || Number.isNaN(Number(user.profile_id))
? null
: Number(user.profile_id),
expiresAt: user.expires_at ?? null,
isExpired: Boolean(user.is_expired),
id: Number(user.id ?? 0), id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats), stats: normalizeStats(user.stats ?? emptyStats),
})) }))
@@ -116,44 +136,6 @@ export default function UsersPage() {
} }
} }
const toggleUserBlock = async (username: string, blocked: boolean) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (username: string, role: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
const syncJellyseerrUsers = async () => { const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null) setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true) setJellyseerrSyncBusy(true)
@@ -208,6 +190,33 @@ export default function UsersPage() {
} }
} }
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update auto search/download for all users.')
} finally {
setBulkAutoSearchBusy(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -220,70 +229,236 @@ export default function UsersPage() {
return <main className="card">Loading users...</main> return <main className="card">Loading users...</main>
} }
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
const normalizedQuery = query.trim().toLowerCase()
const filteredUsers = normalizedQuery
? users.filter((user) => {
const fields = [
user.username,
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
]
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
})
: users
const filteredCountLabel =
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 ( return (
<AdminShell <AdminShell
title="Users" title="Users"
subtitle="Manage who can use Magent." subtitle="Directory, access status, and request activity."
actions={ rail={usersRail}
<>
<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>
</>
}
> >
<section className="admin-section"> <section className="admin-section">
<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={() => router.push('/admin/invites')}
>
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>} {error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>} {jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
{users.length === 0 ? ( <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 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>
) : ( ) : (
<div className="user-grid"> <div className="user-directory-list">
{users.map((user) => ( <div className="user-directory-header">
<span>User</span>
<span>Access</span>
<span>Requests</span>
<span>Activity</span>
</div>
{filteredUsers.map((user) => (
<Link <Link
key={user.username} key={user.username}
className="user-grid-card" className="user-directory-row"
href={`/users/${user.id}`} href={`/users/${user.id}`}
> >
<div className="user-grid-header"> <div className="user-directory-cell user-directory-cell--identity">
<div> <div className="user-directory-title-row">
<strong>{user.username}</strong> <strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span> <span className="user-grid-meta">{user.role}</span>
</div> </div>
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}> <div className="user-directory-subtext">
{user.isBlocked ? 'Blocked' : 'Active'} Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</span>
</div>
<div className="user-grid-stats">
<div>
<span className="label">Total</span>
<span className="value">{user.stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{user.stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{user.stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{user.stats?.in_progress ?? 0}</span>
</div> </div>
</div> </div>
<div className="user-grid-footer"> <div className="user-directory-cell">
<span className="meta">Login: {user.authProvider || 'local'}</span> <div className="user-directory-pill-row">
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span> <span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
<span className="meta"> {user.isBlocked ? 'Blocked' : 'Active'}
</span>
<span
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
>
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
</span>
</div>
<div className="user-directory-subtext">
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-stats-inline">
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-subtext">
Last login: {formatLastLogin(user.lastLoginAt)}
</div>
<div className="user-directory-subtext">
Last request: {formatLastRequest(user.stats?.last_request_at)} Last request: {formatLastRequest(user.stats?.last_request_at)}
</span> </div>
</div>
<div className="user-directory-row-chevron" aria-hidden="true">
Open
</div> </div>
</Link> </Link>
))} ))}

View File

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