Compare commits

..

1 Commits

Author SHA1 Message Date
Rephl3x cc26ed9b2c hardening 2026-05-16 10:44:20 +00:00
19 changed files with 317 additions and 1090 deletions
+6 -6
View File
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..." QBIT_USERNAME="..."
QBIT_PASSWORD="..." QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db" SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me" JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720" JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin" ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="adminadmin" ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
## Screenshots ## Screenshots
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..." $env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..." $env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db" $env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="change-me" $env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720" $env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="admin" $env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="adminadmin" $env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
### Frontend (Next.js) ### Frontend (Next.js)
+4 -4
View File
@@ -8,10 +8,10 @@ class Settings(BaseSettings):
app_name: str = "Magent" app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000" cors_allow_origin: str = "http://localhost:3000"
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: Optional[str] = Field(default=None, 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"))
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_username: Optional[str] = Field(default=None, validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) admin_password: Optional[str] = Field(default=None, 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"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE")) log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
requests_sync_ttl_minutes: int = Field( requests_sync_ttl_minutes: int = Field(
@@ -102,7 +102,7 @@ class Settings(BaseSettings):
) )
discord_webhook_url: Optional[str] = Field( discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt", default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"), validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
) )
+2
View File
@@ -331,6 +331,8 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
user = get_user_by_username(username) user = get_user_by_username(username)
if not user: if not user:
return None return None
if user.get("auth_provider") != "local":
return None
if not verify_password(password, user["password_hash"]): if not verify_password(password, user["password_hash"]):
return None return None
return user return user
+43 -12
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -22,7 +23,48 @@ 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)
def validate_security_settings() -> None:
issues = []
jwt_secret = (settings.jwt_secret or "").strip()
admin_username = (settings.admin_username or "").strip()
admin_password = (settings.admin_password or "").strip()
if not jwt_secret:
issues.append("JWT_SECRET is required")
if not admin_username:
issues.append("ADMIN_USERNAME is required")
if not admin_password:
issues.append("ADMIN_PASSWORD is required")
if jwt_secret == "change-me":
issues.append("JWT_SECRET must not use the default placeholder")
if admin_password == "adminadmin":
issues.append("ADMIN_PASSWORD must not use the default placeholder")
if issues:
raise RuntimeError("Unsafe Magent security configuration: " + "; ".join(issues))
@asynccontextmanager
async def lifespan(_: FastAPI):
validate_security_settings()
init_db()
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
background_tasks = [
asyncio.create_task(run_daily_jellyfin_sync(), name="daily-jellyfin-sync"),
asyncio.create_task(startup_warmup_requests_cache(), name="startup-warmup-requests-cache"),
asyncio.create_task(run_requests_delta_loop(), name="requests-delta-loop"),
asyncio.create_task(run_daily_requests_full_sync(), name="daily-requests-full-sync"),
asyncio.create_task(run_daily_db_cleanup(), name="daily-db-cleanup"),
]
try:
yield
finally:
for task in background_tasks:
task.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -37,17 +79,6 @@ app.add_middleware(
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@app.on_event("startup")
async def startup() -> None:
init_db()
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup())
app.include_router(requests_router) app.include_router(requests_router)
app.include_router(auth_router) app.include_router(auth_router)
+15 -3
View File
@@ -1,3 +1,5 @@
from secrets import token_urlsafe
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
@@ -21,6 +23,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
if not user: if not user:
existing = get_user_by_username(form_data.username)
if existing and existing.get("auth_provider") != "local":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Use the {existing.get('auth_provider')} sign-in flow for this account",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("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")
@@ -45,10 +53,12 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(form_data.username, token_urlsafe(32), role="user", auth_provider="jellyfin")
user = get_user_by_username(form_data.username) user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"): if user and 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 user and user.get("auth_provider") == "jellyfin":
set_user_password(form_data.username, token_urlsafe(32))
try: try:
users = await client.get_users() users = await client.get_users()
if isinstance(users, list): if isinstance(users, list):
@@ -57,7 +67,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
continue continue
name = user.get("Name") name = user.get("Name")
if isinstance(name, str) and name: if isinstance(name, str) and name:
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(name, token_urlsafe(32), role="user", auth_provider="jellyfin")
except Exception: except Exception:
pass pass
token = create_access_token(form_data.username, "user") token = create_access_token(form_data.username, "user")
@@ -78,10 +88,12 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict): if not isinstance(response, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr") create_user_if_missing(form_data.username, token_urlsafe(32), role="user", auth_provider="jellyseerr")
user = get_user_by_username(form_data.username) user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"): if user and 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 user and user.get("auth_provider") == "jellyseerr":
set_user_password(form_data.username, token_urlsafe(32))
token = create_access_token(form_data.username, "user") token = create_access_token(form_data.username, "user")
set_last_login(form_data.username) set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
+14 -23
View File
@@ -939,15 +939,15 @@ async def _ensure_request_access(
) -> None: ) -> None:
if user.get("role") == "admin": if user.get("role") == "admin":
return return
runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id) cached = get_request_cache_payload(request_id)
if mode != "always_js" and cached is not None: if cached is not None:
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode) logger.debug("access cache hit: request_id=%s", request_id)
if _request_matches_user(cached, user.get("username", "")): if _request_matches_user(cached, user.get("username", "")):
return return
raise HTTPException(status_code=403, detail="Request not accessible for this user") raise HTTPException(status_code=403, detail="Request not accessible for this user")
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode) if not client.configured():
raise HTTPException(status_code=403, detail="Request access cannot be verified")
logger.debug("access cache miss: request_id=%s", request_id)
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
if details is None or not _request_matches_user(details, user.get("username", "")): if details is None or not _request_matches_user(details, user.get("username", "")):
raise HTTPException(status_code=403, detail="Request not accessible for this user") raise HTTPException(status_code=403, detail="Request not accessible for this user")
@@ -1067,8 +1067,7 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name:
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot: async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
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(): await _ensure_request_access(client, int(request_id), user)
await _ensure_request_access(client, int(request_id), user)
return await build_snapshot(request_id) return await build_snapshot(request_id)
@@ -1327,8 +1326,7 @@ async def search_requests(
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult: async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
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(): 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 = await build_snapshot(request_id)
return triage_snapshot(snapshot) return triage_snapshot(snapshot)
@@ -1337,8 +1335,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
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(): 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 = await build_snapshot(request_id)
prowlarr_results: List[Dict[str, Any]] = [] prowlarr_results: List[Dict[str, Any]] = []
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
@@ -1368,8 +1365,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
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:
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(): 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 = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -1418,8 +1414,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
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(): 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 = await build_snapshot(request_id)
queue = snapshot.raw.get("arr", {}).get("queue") queue = snapshot.raw.get("arr", {}).get("queue")
download_ids = _download_ids(_queue_records(queue)) download_ids = _download_ids(_queue_records(queue))
@@ -1465,8 +1460,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
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(): 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 = await build_snapshot(request_id)
jelly = snapshot.raw.get("jellyseerr") or {} jelly = snapshot.raw.get("jellyseerr") or {}
media = jelly.get("media") or {} media = jelly.get("media") or {}
@@ -1578,8 +1572,7 @@ async def request_history(
) -> dict: ) -> dict:
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(): await _ensure_request_access(client, int(request_id), user)
await _ensure_request_access(client, int(request_id), user)
snapshots = await asyncio.to_thread(get_recent_snapshots, request_id, limit) snapshots = await asyncio.to_thread(get_recent_snapshots, request_id, limit)
return {"snapshots": snapshots} return {"snapshots": snapshots}
@@ -1590,8 +1583,7 @@ async def request_actions(
) -> dict: ) -> dict:
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(): await _ensure_request_access(client, int(request_id), user)
await _ensure_request_access(client, int(request_id), user)
actions = await asyncio.to_thread(get_recent_actions, request_id, limit) actions = await asyncio.to_thread(get_recent_actions, request_id, limit)
return {"actions": actions} return {"actions": actions}
@@ -1602,8 +1594,7 @@ async def action_grab(
) -> dict: ) -> dict:
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(): 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 = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
+26 -33
View File
@@ -1,3 +1,4 @@
import asyncio
from typing import Any, Dict from typing import Any, Dict
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -38,53 +39,45 @@ async def services_status() -> Dict[str, Any]:
) )
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
services = [] checks = [
services.append( _check(
await _check(
"Jellyseerr", "Jellyseerr",
jellyseerr.configured(), jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0), lambda: jellyseerr.get_recent_requests(take=1, skip=0),
) ),
) _check(
services.append(
await _check(
"Sonarr", "Sonarr",
sonarr.configured(), sonarr.configured(),
sonarr.get_system_status, sonarr.get_system_status,
) ),
) _check(
services.append(
await _check(
"Radarr", "Radarr",
radarr.configured(), radarr.configured(),
radarr.get_system_status, radarr.get_system_status,
) ),
) _check(
prowlarr_status = await _check( "Prowlarr",
"Prowlarr", prowlarr.configured(),
prowlarr.configured(), prowlarr.get_health,
prowlarr.get_health, ),
) _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
),
_check(
"Jellyfin",
jellyfin.configured(),
jellyfin.get_system_info,
),
]
services = await asyncio.gather(*checks)
prowlarr_status = next(service for service in services if service["name"] == "Prowlarr")
if prowlarr_status.get("status") == "up": if prowlarr_status.get("status") == "up":
health = prowlarr_status.get("detail") health = prowlarr_status.get("detail")
if isinstance(health, list) and health: if isinstance(health, list) and health:
prowlarr_status["status"] = "degraded" prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings" prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status)
services.append(
await _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
)
)
services.append(
await _check(
"Jellyfin",
jellyfin.configured(),
jellyfin.get_system_info,
)
)
overall = "up" overall = "up"
if any(s.get("status") == "down" for s in services): if any(s.get("status") == "down" for s in services):
+4
View File
@@ -19,6 +19,8 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
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:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
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} payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
@@ -26,6 +28,8 @@ def create_access_token(subject: str, role: str, expires_minutes: Optional[int]
def decode_token(token: str) -> Dict[str, Any]: def decode_token(token: str) -> Dict[str, Any]:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM]) return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
+18 -790
View File
@@ -2,7 +2,14 @@
import { useEffect, useMemo, 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,
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type AdminSetting = { type AdminSetting = {
@@ -29,46 +36,9 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request syncing',
invites: 'Invites',
password: 'Password rules',
captcha: 'Captcha',
smtp: 'Email (SMTP)',
notify: 'Notifications',
expiry: 'Account expiry',
} }
const BOOL_SETTINGS = new Set([ const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
'jellyfin_sync_to_arr',
'invites_enabled',
'invites_require_captcha',
'signup_allow_referrals',
'password_require_upper',
'password_require_lower',
'password_require_number',
'password_require_symbol',
'password_reset_enabled',
'smtp_tls',
'smtp_starttls',
'notify_email_enabled',
'notify_discord_enabled',
'notify_telegram_enabled',
'notify_matrix_enabled',
'notify_pushover_enabled',
'notify_pushbullet_enabled',
'notify_gotify_enabled',
'notify_ntfy_enabled',
'jellyseerr_sync_users',
])
const NUMBER_SETTINGS = new Set([
'invite_default_profile_id',
'referral_default_uses',
'password_min_length',
'smtp_port',
'expiry_default_days',
'expiry_warning_days',
'expiry_check_interval_minutes',
'jellyseerr_sync_interval_minutes',
])
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
@@ -81,12 +51,6 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
invites: 'Invite-only sign-ups and default rules.',
password: 'Set global password rules and local reset settings.',
captcha: 'Choose and configure captcha providers.',
smtp: 'Email delivery settings for password resets and notices.',
notify: 'Where system messages should be sent.',
expiry: 'Handle account expiry and automated actions.',
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -98,12 +62,6 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
prowlarr: 'prowlarr', prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent', qbittorrent: 'qbittorrent',
requests: 'requests', requests: 'requests',
invites: 'invites',
password: 'password',
captcha: 'captcha',
smtp: 'smtp',
notifications: 'notify',
expiry: 'expiry',
cache: null, cache: null,
logs: 'log', logs: 'log',
maintenance: null, maintenance: null,
@@ -127,59 +85,6 @@ const labelFromKey = (key: string) =>
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
.replace('invites enabled', 'Allow invite sign-ups')
.replace('invites require captcha', 'Require captcha for invite sign-ups')
.replace('invite default profile id', 'Default invite profile')
.replace('signup allow referrals', 'Allow users to create referral invites')
.replace('referral default uses', 'Default referral invite uses')
.replace('password min length', 'Minimum password length')
.replace('password require upper', 'Require uppercase letters')
.replace('password require lower', 'Require lowercase letters')
.replace('password require number', 'Require numbers')
.replace('password require symbol', 'Require symbols')
.replace('password reset enabled', 'Allow password reset emails')
.replace('captcha provider', 'Captcha provider')
.replace('hcaptcha site key', 'hCaptcha site key')
.replace('hcaptcha secret key', 'hCaptcha secret key')
.replace('recaptcha site key', 'reCAPTCHA site key')
.replace('recaptcha secret key', 'reCAPTCHA secret key')
.replace('turnstile site key', 'Turnstile site key')
.replace('turnstile secret key', 'Turnstile secret key')
.replace('smtp host', 'SMTP host')
.replace('smtp port', 'SMTP port')
.replace('smtp user', 'SMTP username')
.replace('smtp password', 'SMTP password')
.replace('smtp from', 'SMTP from address')
.replace('smtp tls', 'Use TLS (SMTPS)')
.replace('smtp starttls', 'Use STARTTLS')
.replace('notify email enabled', 'Send emails')
.replace('notify discord enabled', 'Send Discord alerts')
.replace('notify telegram enabled', 'Send Telegram alerts')
.replace('notify matrix enabled', 'Send Matrix alerts')
.replace('notify pushover enabled', 'Send Pushover alerts')
.replace('notify pushbullet enabled', 'Send Pushbullet alerts')
.replace('notify gotify enabled', 'Send Gotify alerts')
.replace('notify ntfy enabled', 'Send ntfy alerts')
.replace('telegram bot token', 'Telegram bot token')
.replace('telegram chat id', 'Telegram chat ID')
.replace('matrix homeserver', 'Matrix homeserver')
.replace('matrix user', 'Matrix username')
.replace('matrix password', 'Matrix password')
.replace('matrix access token', 'Matrix access token')
.replace('matrix room id', 'Matrix room ID')
.replace('pushover token', 'Pushover app token')
.replace('pushover user key', 'Pushover user key')
.replace('pushbullet token', 'Pushbullet access token')
.replace('gotify url', 'Gotify URL')
.replace('gotify token', 'Gotify token')
.replace('ntfy url', 'ntfy URL')
.replace('ntfy topic', 'ntfy topic')
.replace('expiry default days', 'Default account expiry (days)')
.replace('expiry default action', 'Expiry action')
.replace('expiry warning days', 'Warn users this many days before expiry')
.replace('expiry check interval minutes', 'Expiry check interval (minutes)')
.replace('jellyseerr sync users', 'Sync Jellyseerr users into Magent')
.replace('jellyseerr sync interval minutes', 'Jellyseerr sync interval (minutes)')
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
@@ -208,45 +113,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null) const [artworkPrefetch, setArtworkPrefetch] = useState<any | 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 [invites, setInvites] = useState<any[]>([])
const [inviteProfiles, setInviteProfiles] = useState<any[]>([])
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteForm, setInviteForm] = useState({
profile_id: '',
max_uses: '1',
require_captcha: false,
allow_referrals: false,
})
const [profileForm, setProfileForm] = useState({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
const [inviteExpiry, setInviteExpiry] = useState({ unit: 'days', value: '7' })
const [profileExpiry, setProfileExpiry] = useState({ unit: 'days', value: '' })
const [profileUserExpiry, setProfileUserExpiry] = useState({ unit: 'days', value: '' })
const [announcementSubject, setAnnouncementSubject] = useState('')
const [announcementBody, setAnnouncementBody] = useState('')
const [announcementChannels, setAnnouncementChannels] = useState<string[]>(['discord'])
const [announcementStatus, setAnnouncementStatus] = useState<string | null>(null)
const loadSettings = async () => { const loadSettings = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`) const response = await authFetchOrThrow(`${baseUrl}/admin/settings`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Failed to load settings') throw new Error('Failed to load settings')
} }
const data = await response.json() const data = await response.json()
@@ -325,11 +196,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'artwork') { if (section === 'artwork') {
await loadArtworkPrefetchStatus() await loadArtworkPrefetchStatus()
} }
if (section === 'invites') {
await loadInviteProfiles()
await loadInvites()
}
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err) console.error(err)
setStatus('Could not load admin settings.') setStatus('Could not load admin settings.')
} finally { } finally {
@@ -382,8 +257,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const showRequestsExtras = section === 'requests' const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork' const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache' const showCacheExtras = section === 'cache'
const showInviteExtras = section === 'invites'
const showNotificationExtras = section === 'notifications'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => { const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true if (showArtworkExtras && sectionGroup.key === 'artwork') return true
@@ -422,59 +295,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.', log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', log_file: 'Where the activity log is stored.',
invites_enabled: 'Allow new users to register with invite links.',
invites_require_captcha: 'Require a captcha on invite sign-up.',
invite_default_profile_id: 'Default invite profile applied when creating invites.',
signup_allow_referrals: 'Let users create referral invites for friends/family.',
referral_default_uses: 'Default number of uses for referral invites.',
password_min_length: 'Minimum length required for passwords.',
password_require_upper: 'Require uppercase letters in passwords.',
password_require_lower: 'Require lowercase letters in passwords.',
password_require_number: 'Require numbers in passwords.',
password_require_symbol: 'Require symbols in passwords.',
password_reset_enabled: 'Allow local users to request password reset emails.',
captcha_provider: 'Choose which captcha provider to use for sign-up.',
hcaptcha_site_key: 'Public hCaptcha site key.',
hcaptcha_secret_key: 'Secret hCaptcha key.',
recaptcha_site_key: 'Public reCAPTCHA site key.',
recaptcha_secret_key: 'Secret reCAPTCHA key.',
turnstile_site_key: 'Public Turnstile site key.',
turnstile_secret_key: 'Secret Turnstile key.',
smtp_host: 'SMTP server hostname.',
smtp_port: 'SMTP server port.',
smtp_user: 'SMTP username (optional).',
smtp_password: 'SMTP password (optional).',
smtp_from: 'Default "from" address for system emails.',
smtp_tls: 'Use TLS for SMTP (SMTPS).',
smtp_starttls: 'Use STARTTLS for SMTP.',
notify_email_enabled: 'Send notices by email.',
notify_discord_enabled: 'Send notices to Discord.',
notify_telegram_enabled: 'Send notices to Telegram.',
notify_matrix_enabled: 'Send notices to Matrix.',
notify_pushover_enabled: 'Send notices to Pushover.',
notify_pushbullet_enabled: 'Send notices to Pushbullet.',
notify_gotify_enabled: 'Send notices to Gotify.',
notify_ntfy_enabled: 'Send notices to ntfy.',
telegram_bot_token: 'Telegram bot token for sending notices.',
telegram_chat_id: 'Default Telegram chat ID.',
matrix_homeserver: 'Matrix server base URL.',
matrix_user: 'Matrix bot username.',
matrix_password: 'Matrix bot password.',
matrix_access_token: 'Matrix access token.',
matrix_room_id: 'Matrix room ID for announcements.',
pushover_token: 'Pushover application token.',
pushover_user_key: 'Pushover user key.',
pushbullet_token: 'Pushbullet access token.',
gotify_url: 'Gotify server URL.',
gotify_token: 'Gotify app token.',
ntfy_url: 'ntfy server URL.',
ntfy_topic: 'ntfy topic for notifications.',
expiry_default_days: 'Default number of days before accounts expire.',
expiry_default_action: 'Action to take when an account expires.',
expiry_warning_days: 'How many days before expiry to warn the user.',
expiry_check_interval_minutes: 'How often expiry checks run.',
jellyseerr_sync_users: 'Sync Jellyseerr users into Magent.',
jellyseerr_sync_interval_minutes: 'How often Jellyseerr user sync runs.',
} }
const buildSelectOptions = ( const buildSelectOptions = (
@@ -498,62 +318,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return list return list
} }
const durationOptions = {
minutes: Array.from({ length: 60 }, (_, i) => String(i + 1)),
hours: Array.from({ length: 24 }, (_, i) => String(i + 1)),
days: Array.from({ length: 365 }, (_, i) => String(i + 1)),
}
const toDays = (choice: { unit: string; value: string }) => {
if (!choice || choice.unit === 'unlimited') return null
const amount = Number(choice.value)
if (!amount || amount <= 0) return null
if (choice.unit === 'minutes') return amount / 1440
if (choice.unit === 'hours') return amount / 24
if (choice.unit === 'months') return amount * 30
return amount
}
const renderDurationControl = (
label: string,
choice: { unit: string; value: string },
setChoice: (next: { unit: string; value: string }) => void
) => (
<label>
{label}
<div className="duration-row">
<input
type="number"
min={1}
placeholder="Amount"
value={choice.value}
onChange={(event) =>
setChoice({
unit: choice.unit,
value: event.target.value,
})
}
disabled={choice.unit === 'unlimited'}
/>
<select
value={choice.unit}
onChange={(event) =>
setChoice({
unit: event.target.value,
value: event.target.value === 'unlimited' ? '' : choice.value,
})
}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="months">Months</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
</label>
)
const submit = async (event: React.FormEvent<HTMLFormElement>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
setStatus(null) setStatus(null)
@@ -664,174 +428,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const loadInviteProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`)
if (!response.ok) {
throw new Error('Profiles unavailable')
}
const data = await response.json()
setInviteProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
} catch (err) {
console.error(err)
setInviteProfiles([])
}
}
const loadInvites = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`)
if (!response.ok) {
throw new Error('Invites unavailable')
}
const data = await response.json()
setInvites(Array.isArray(data?.invites) ? data.invites : [])
} catch (err) {
console.error(err)
setInvites([])
}
}
const createInviteProfile = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...profileForm,
max_uses: profileForm.max_uses ? Number(profileForm.max_uses) : null,
expires_in_days: toDays(profileExpiry),
referral_uses: profileForm.referral_uses ? Number(profileForm.referral_uses) : null,
user_expiry_days: toDays(profileUserExpiry),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile creation failed')
}
setProfileForm({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
setProfileExpiry({ unit: 'days', value: '' })
setProfileUserExpiry({ unit: 'days', value: '' })
setInviteStatus('Invite profile created.')
await loadInviteProfiles()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite profile.')
}
}
const createInviteCode = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: inviteForm.profile_id ? Number(inviteForm.profile_id) : null,
max_uses: inviteForm.max_uses ? Number(inviteForm.max_uses) : null,
expires_in_days: toDays(inviteExpiry),
require_captcha: inviteForm.require_captcha,
allow_referrals: inviteForm.allow_referrals,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite creation failed')
}
setInviteStatus('Invite created.')
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite.')
}
}
const disableInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${encodeURIComponent(code)}/disable`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Disable failed')
}
setInviteStatus(`Invite ${code} disabled.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not disable invite.')
}
}
const deleteInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/invites/${encodeURIComponent(code)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
throw new Error('Delete failed')
}
setInviteStatus(`Invite ${code} deleted.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not delete invite.')
}
}
const toggleAnnouncementChannel = (channel: string) => {
setAnnouncementChannels((current) =>
current.includes(channel) ? current.filter((item) => item !== channel) : [...current, channel]
)
}
const sendAnnouncement = async () => {
setAnnouncementStatus(null)
if (!announcementSubject || !announcementBody) {
setAnnouncementStatus('Enter a subject and a message.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: announcementSubject,
body: announcementBody,
channels: announcementChannels,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Announcement failed')
}
setAnnouncementStatus('Announcement sent.')
setAnnouncementBody('')
setAnnouncementSubject('')
} catch (err) {
console.error(err)
setAnnouncementStatus('Could not send the announcement.')
}
}
const prefetchArtwork = async () => { const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null) setArtworkPrefetchStatus(null)
try { try {
@@ -1495,83 +1091,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'captcha_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 || 'none'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="none">No captcha</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Cloudflare Turnstile</option>
</select>
</label>
)
}
if (setting.key === 'expiry_default_action') {
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 || 'disable'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
)
}
if (NUMBER_SETTINGS.has(setting.key)) {
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>
<input
name={setting.key}
type="number"
min={0}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
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">
@@ -1747,297 +1266,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div> </div>
</section> </section>
)} )}
{showInviteExtras && (
<section className="admin-section" id="invites">
<div className="section-header">
<h2>Invites</h2>
</div>
<div className="status-banner">
Create profiles for common rules, then issue invites in seconds.
</div>
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="invite-stack">
<div className="summary-card invite-card">
<h3>Create a profile</h3>
<p className="meta">Profiles save default rules for new invites.</p>
<div className="invite-grid">
<label>
Profile name
<input
value={profileForm.name}
onChange={(event) =>
setProfileForm((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label>
Description
<input
value={profileForm.description}
onChange={(event) =>
setProfileForm((current) => ({ ...current, description: event.target.value }))
}
/>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={profileForm.max_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', profileExpiry, setProfileExpiry)}
{renderDurationControl(
'User account expires after',
profileUserExpiry,
setProfileUserExpiry
)}
<label>
Expiry action
<select
value={profileForm.user_expiry_action}
onChange={(event) =>
setProfileForm((current) => ({
...current,
user_expiry_action: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
<label>
Referral uses
<input
type="number"
min={0}
value={profileForm.referral_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, referral_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for 1.</span>
</label>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.require_captcha}
onChange={(event) =>
setProfileForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.allow_referrals}
onChange={(event) =>
setProfileForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Let users share their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteProfile}>
Save profile
</button>
</div>
</div>
<div className="summary-card invite-card">
<h3>Create an invite</h3>
<p className="meta">Pick a profile or customize a one-off invite.</p>
<div className="invite-grid">
<label>
Profile
<select
value={inviteForm.profile_id}
onChange={(event) =>
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
}
>
<option value="">No profile</option>
{inviteProfiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', inviteExpiry, setInviteExpiry)}
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.require_captcha}
onChange={(event) =>
setInviteForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.allow_referrals}
onChange={(event) =>
setInviteForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Lets this person create their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteCode}>
Create invite
</button>
<button type="button" className="ghost-button" onClick={loadInvites}>
Refresh invites
</button>
</div>
</div>
</div>
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
<span>Expires</span>
<span>Status</span>
<span>Actions</span>
</div>
{invites.length === 0 ? (
<div className="meta">No invites yet.</div>
) : (
invites.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? 'Unlimited'}
</span>
<span>{invite.expires_at || 'Never'}</span>
<span>{invite.disabled ? 'Disabled' : 'Active'}</span>
<span className="cache-actions">
<button
type="button"
className="ghost-button"
onClick={() => disableInviteCode(invite.code)}
disabled={invite.disabled}
>
Disable
</button>
<button
type="button"
className="danger-button"
onClick={() => deleteInviteCode(invite.code)}
>
Delete
</button>
</span>
</div>
))
)}
</div>
</section>
)}
{showNotificationExtras && (
<section className="admin-section" id="announcements">
<div className="section-header">
<h2>Send an announcement</h2>
</div>
<div className="status-banner">
Send a message to all users through the channels you select.
</div>
{announcementStatus && <div className="status-banner">{announcementStatus}</div>}
<div className="admin-grid">
<label>
Subject
<input
value={announcementSubject}
onChange={(event) => setAnnouncementSubject(event.target.value)}
/>
</label>
<label>
Message
<textarea
rows={5}
value={announcementBody}
onChange={(event) => setAnnouncementBody(event.target.value)}
/>
</label>
<div className="settings-checkbox-grid">
{[
{ id: 'email', label: 'Email' },
{ id: 'discord', label: 'Discord' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'pushover', label: 'Pushover' },
{ id: 'pushbullet', label: 'Pushbullet' },
{ id: 'gotify', label: 'Gotify' },
{ id: 'ntfy', label: 'ntfy' },
].map((channel) => (
<label key={channel.id} className="toggle">
<input
type="checkbox"
checked={announcementChannels.includes(channel.id)}
onChange={() => toggleAnnouncementChannel(channel.id)}
/>
<span>{channel.label}</span>
</label>
))}
</div>
</div>
<div className="admin-actions">
<button type="button" onClick={sendAnnouncement}>
Send announcement
</button>
</div>
</section>
)}
</AdminShell> </AdminShell>
) )
} }
+12 -11
View File
@@ -2,7 +2,7 @@
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 { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = { type Profile = {
username?: string username?: string
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) { if (!response.ok) {
clearToken() throw new Error('Could not load profile.')
router.push('/login')
return
} }
const data = await response.json() const data = await response.json()
setProfile({ username: data?.username }) setProfile({ username: data?.username })
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
} }
} }
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, { const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
}), }),
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text() const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
setMessage('') setMessage('')
setStatus('Thanks! Your message has been sent.') setStatus('Thanks! Your message has been sent.')
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
setStatus('That did not send. Please try again.') setStatus('That did not send. Please try again.')
} finally { } finally {
+1 -142
View File
@@ -659,21 +659,6 @@ button span {
justify-items: end; justify-items: end;
} }
.user-bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.user-bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.toggle { .toggle {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
@@ -805,11 +790,6 @@ button span {
gap: 10px; gap: 10px;
} }
.captcha-wrap {
display: flex;
justify-content: center;
}
.ghost-button { .ghost-button {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink); color: var(--ink);
@@ -957,103 +937,6 @@ button span {
gap: 16px; gap: 16px;
} }
.settings-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.duration-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 1fr);
gap: 10px;
align-items: center;
}
.duration-row input,
.duration-row select {
width: 100%;
}
.invite-stack {
display: grid;
gap: 16px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: stretch;
}
.invite-card h3 {
margin: 0;
font-size: 18px;
}
.invite-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
align-items: start;
}
.invite-card {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.invite-card .admin-actions {
margin-top: auto;
}
.invite-grid label {
display: grid;
gap: 8px;
font-size: 14px;
color: var(--ink-muted);
text-align: left;
}
.invite-grid input,
.invite-grid select {
width: 100%;
}
.toggle-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px 14px;
}
.toggle-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-inline {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(180px, 260px);
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--ink-muted);
}
.field-stack {
display: grid;
gap: 6px;
}
.field-stack .field-help {
text-align: left;
}
.admin-grid label { .admin-grid label {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1175,7 +1058,7 @@ button span {
.cache-row { .cache-row {
display: grid; display: grid;
grid-template-columns: minmax(140px, 1.4fr) 100px 140px 100px 180px; grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
@@ -1183,7 +1066,6 @@ button span {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
font-size: 13px; font-size: 13px;
color: var(--ink); color: var(--ink);
align-items: center;
} }
.cache-row span { .cache-row span {
@@ -1200,13 +1082,6 @@ button span {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.cache-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.maintenance-grid { .maintenance-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1524,22 +1399,6 @@ button span {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.duration-row {
grid-template-columns: 1fr;
}
.field-inline {
grid-template-columns: 1fr;
}
.cache-row {
grid-template-columns: 1fr;
}
.toggle-row {
grid-template-columns: 1fr;
}
.card { .card {
padding: 24px; padding: 24px;
} }
+34
View File
@@ -23,3 +23,37 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
} }
return fetch(input, { ...init, headers }) return fetch(input, { ...init, headers })
} }
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized')
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor() {
super('Forbidden')
this.name = 'ForbiddenError'
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init)
if (response.status === 401) {
clearToken()
throw new UnauthorizedError()
}
if (response.status === 403) {
throw new ForbiddenError()
}
return response
}
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim()
} catch {
return ''
}
}
+36 -26
View File
@@ -2,7 +2,13 @@
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 {
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from './lib/auth'
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
@@ -52,13 +58,8 @@ export default function HomePage() {
setRecentError(null) setRecentError(null)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`) const meResponse = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!meResponse.ok) { if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`) throw new Error(`Auth failed: ${meResponse.status}`)
} }
const me = await meResponse.json() const me = await meResponse.json()
@@ -66,15 +67,10 @@ export default function HomePage() {
setRole(userRole) setRole(userRole)
setAuthReady(true) setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6 const take = userRole === 'admin' ? 50 : 6
const response = await authFetch( const response = await authFetchOrThrow(
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}` `${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
) )
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`) throw new Error(`Recent requests failed: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
@@ -99,6 +95,14 @@ export default function HomePage() {
) )
} }
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error) console.error(error)
setRecentError('Recent requests are not available right now.') setRecentError('Recent requests are not available right now.')
} finally { } finally {
@@ -107,7 +111,7 @@ export default function HomePage() {
} }
load() load()
}, [recentDays]) }, [recentDays, router])
useEffect(() => { useEffect(() => {
if (!authReady) { if (!authReady) {
@@ -118,18 +122,21 @@ export default function HomePage() {
setServicesError(null) setServicesError(null)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`) const response = await authFetchOrThrow(`${baseUrl}/status/services`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`) throw new Error(`Service status failed: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
setServicesStatus(data) setServicesStatus(data)
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error) console.error(error)
setServicesError('Service status is not available right now.') setServicesError('Service status is not available right now.')
} finally { } finally {
@@ -145,13 +152,8 @@ export default function HomePage() {
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) const response = await authFetchOrThrow(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`) throw new Error(`Search failed: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
@@ -168,6 +170,14 @@ export default function HomePage() {
setSearchError(null) setSearchError(null)
} }
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error) console.error(error)
setSearchError('Search failed. Try a request ID instead.') setSearchError('Search failed. Try a request ID instead.')
setSearchResults([]) setSearchResults([])
+18 -5
View File
@@ -2,7 +2,13 @@
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 {
authFetchOrThrow,
getApiBase,
getToken,
readResponseText,
UnauthorizedError,
} from '../lib/auth'
type ProfileInfo = { type ProfileInfo = {
username: string username: string
@@ -26,9 +32,8 @@ export default function ProfilePage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) { if (!response.ok) {
clearToken()
router.push('/login') router.push('/login')
return return
} }
@@ -39,6 +44,10 @@ export default function ProfilePage() {
auth_provider: data?.auth_provider ?? 'local', auth_provider: data?.auth_provider ?? 'local',
}) })
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(err) console.error(err)
setStatus('Could not load your profile.') setStatus('Could not load your profile.')
} finally { } finally {
@@ -57,7 +66,7 @@ export default function ProfilePage() {
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/password`, { const response = await authFetchOrThrow(`${baseUrl}/auth/password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -66,13 +75,17 @@ export default function ProfilePage() {
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await readResponseText(response)
throw new Error(text || 'Update failed') throw new Error(text || 'Update failed')
} }
setCurrentPassword('') setCurrentPassword('')
setNewPassword('') setNewPassword('')
setStatus('Password updated.') setStatus('Password updated.')
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(err) console.error(err)
setStatus('Could not update password. Check your current password.') setStatus('Could not update password. Check your current password.')
} }
+21 -15
View File
@@ -2,7 +2,15 @@
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,
authFetchOrThrow,
clearToken,
getApiBase,
getToken,
readResponseText,
UnauthorizedError,
} from '../../lib/auth'
type TimelineHop = { type TimelineHop = {
service: string service: string
@@ -502,16 +510,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setModalMessage(null) setModalMessage(null)
} }
try { try {
const response = await authFetch(`${baseUrl}/requests/${snapshot.request_id}/${path}`, { const response = await authFetchOrThrow(`${baseUrl}/requests/${snapshot.request_id}/${path}`, {
method: 'POST', method: 'POST',
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { const text = await readResponseText(response)
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
@@ -538,6 +541,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setModalMessage(message) setModalMessage(message)
} }
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
const message = `${action.label} failed. Check the backend logs.` const message = `${action.label} failed. Check the backend logs.`
setActionMessage(message) setActionMessage(message)
@@ -582,7 +589,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
if (!ok) return if (!ok) return
const baseUrl = getApiBase() const baseUrl = getApiBase()
try { try {
const response = await authFetch( const response = await authFetchOrThrow(
`${baseUrl}/requests/${snapshot.request_id}/actions/grab`, `${baseUrl}/requests/${snapshot.request_id}/actions/grab`,
{ {
method: 'POST', method: 'POST',
@@ -595,17 +602,16 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
} }
) )
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { const text = await readResponseText(response)
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
setActionMessage('Download sent to Sonarr/Radarr.') setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to Sonarr/Radarr.') setModalMessage('Download sent to Sonarr/Radarr.')
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
const message = 'Download failed. Check the logs.' const message = 'Download failed. Check the logs.'
setActionMessage(message) setActionMessage(message)
+6 -3
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
export default function HeaderIdentity() { export default function HeaderIdentity() {
const [identity, setIdentity] = useState<string | null>(null) const [identity, setIdentity] = useState<string | null>(null)
@@ -16,9 +16,8 @@ export default function HeaderIdentity() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) { if (!response.ok) {
clearToken()
setIdentity(null) setIdentity(null)
return return
} }
@@ -27,6 +26,10 @@ export default function HeaderIdentity() {
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`) setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
} }
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
setIdentity(null)
return
}
console.error(err) console.error(err)
setIdentity(null) setIdentity(null)
} }
+34 -13
View File
@@ -2,7 +2,13 @@
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 {
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type AdminUser = { type AdminUser = {
@@ -29,17 +35,8 @@ export default function UsersPage() {
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users`) const response = await authFetchOrThrow(`${baseUrl}/admin/users`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Could not load users.') throw new Error('Could not load users.')
} }
const data = await response.json() const data = await response.json()
@@ -58,6 +55,14 @@ export default function UsersPage() {
} }
setError(null) setError(null)
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err) console.error(err)
setError('Could not load user list.') setError('Could not load user list.')
} finally { } finally {
@@ -68,7 +73,7 @@ export default function UsersPage() {
const toggleUserBlock = async (username: string, blocked: boolean) => { const toggleUserBlock = async (username: string, blocked: boolean) => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetchOrThrow(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`, `${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' } { method: 'POST' }
) )
@@ -77,6 +82,14 @@ export default function UsersPage() {
} }
await loadUsers() await loadUsers()
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err) console.error(err)
setError('Could not update user access.') setError('Could not update user access.')
} }
@@ -85,7 +98,7 @@ export default function UsersPage() {
const updateUserRole = async (username: string, role: string) => { const updateUserRole = async (username: string, role: string) => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetchOrThrow(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`, `${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{ {
method: 'POST', method: 'POST',
@@ -98,6 +111,14 @@ export default function UsersPage() {
} }
await loadUsers() await loadUsers()
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err) console.error(err)
setError('Could not update user role.') setError('Could not update user role.')
} }
+3
View File
@@ -1,2 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+20 -4
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2019", "target": "ES2019",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -12,8 +16,20 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true,
"plugins": [
{
"name": "next"
}
]
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }