Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87971d1ff0 | |||
| 8f03e315b8 | |||
| a8aa8e38e2 | |||
| 329884b789 | |||
| 0700d37469 | |||
| 2d28047ad7 | |||
| cbac743026 | |||
| 1ce01ec348 | |||
| cc26ed9b2c | |||
| d9ac54a2ff | |||
| 3609f44607 | |||
| f830fc1296 |
+1
-1
@@ -1 +1 @@
|
||||
0803262038
|
||||
0803262237
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Magent CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
- prod
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: magent-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend quality gate
|
||||
run: bash scripts/ci_backend_quality_gate.sh
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
deploy-prod:
|
||||
if: github.ref_name == 'prod'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
fi
|
||||
|
||||
- name: Deploy to AMS-DEV01
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||
run: bash scripts/deploy_ams_dev01.sh
|
||||
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="change-me"
|
||||
JWT_SECRET="replace-with-a-long-random-secret"
|
||||
JWT_EXP_MINUTES="720"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="adminadmin"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
||||
$env:QBIT_USERNAME="..."
|
||||
$env:QBIT_PASSWORD="..."
|
||||
$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:ADMIN_USERNAME="admin"
|
||||
$env:ADMIN_PASSWORD="adminadmin"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
@@ -141,6 +141,26 @@ The frontend proxies `/api/*` to the backend container. Set:
|
||||
|
||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||
|
||||
## Gitea CI/CD
|
||||
|
||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||
|
||||
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
||||
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
|
||||
|
||||
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
||||
|
||||
- `http://127.0.0.1:8000/health`
|
||||
- `http://127.0.0.1:3000/login`
|
||||
|
||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
||||
|
||||
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
||||
|
||||
## History endpoints
|
||||
|
||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||
|
||||
+96
-31
@@ -1,13 +1,15 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .config import settings
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .security import safe_decode_token, TokenError, verify_password
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def _is_expired(expires_at: str | None) -> bool:
|
||||
@@ -24,20 +26,79 @@ def _is_expired(expires_at: str | None) -> bool:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed <= datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _cookie_settings() -> dict[str, Any]:
|
||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
||||
if samesite not in {"lax", "strict", "none"}:
|
||||
samesite = "lax"
|
||||
return {
|
||||
"secure": bool(settings.auth_cookie_secure),
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
def _state_cookie_settings() -> dict[str, Any]:
|
||||
cookie = _cookie_settings()
|
||||
cookie["httponly"] = False
|
||||
return cookie
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, token: str) -> None:
|
||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
||||
response.set_cookie(
|
||||
settings.auth_cookie_name,
|
||||
token,
|
||||
max_age=max_age,
|
||||
**_cookie_settings(),
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
"1",
|
||||
max_age=max_age,
|
||||
**_state_cookie_settings(),
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
||||
response.delete_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
path="/",
|
||||
domain=settings.auth_cookie_domain or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
if oauth_token:
|
||||
return oauth_token
|
||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
||||
return cookie_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(user, dict):
|
||||
return "local"
|
||||
@@ -122,24 +183,28 @@ def _load_current_user_from_token(
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
if not resolved_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return _load_current_user_from_token(resolved_token, request)
|
||||
|
||||
|
||||
def get_current_user_event_stream(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if resolved_token:
|
||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
||||
return _load_current_user_from_token(resolved_token, None)
|
||||
if 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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,24 @@ class JellyseerrClient(ApiClient):
|
||||
},
|
||||
)
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
*,
|
||||
media_type: str,
|
||||
media_id: int,
|
||||
seasons: Optional[list[int]] = None,
|
||||
is_4k: Optional[bool] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaType": media_type,
|
||||
"mediaId": media_id,
|
||||
}
|
||||
if isinstance(seasons, list) and seasons:
|
||||
payload["seasons"] = seasons
|
||||
if isinstance(is_4k, bool):
|
||||
payload["is4k"] = is_4k
|
||||
return await self.post("/api/v1/request", payload=payload)
|
||||
|
||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/user",
|
||||
|
||||
@@ -52,6 +52,17 @@ class QBittorrentClient(ApiClient):
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(self.base_url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def get_torrents(self) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info")
|
||||
|
||||
|
||||
+26
-3
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
||||
sqlite_journal_mode: str = Field(
|
||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||
)
|
||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||
auth_rate_limit_window_seconds: int = Field(
|
||||
@@ -34,7 +34,22 @@ class Settings(BaseSettings):
|
||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
||||
)
|
||||
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="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
auth_cookie_name: str = Field(
|
||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||
)
|
||||
auth_cookie_secure: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||
)
|
||||
auth_cookie_samesite: str = Field(
|
||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||
)
|
||||
auth_cookie_domain: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||
)
|
||||
auth_state_cookie_name: str = Field(
|
||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||
)
|
||||
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_max_bytes: int = Field(
|
||||
@@ -121,6 +136,10 @@ class Settings(BaseSettings):
|
||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
||||
)
|
||||
magent_proxy_trusted_proxies: str = Field(
|
||||
default="127.0.0.1,::1",
|
||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
||||
)
|
||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||
)
|
||||
@@ -216,6 +235,10 @@ class Settings(BaseSettings):
|
||||
magent_notify_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
||||
)
|
||||
magent_allow_private_notification_targets: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
||||
)
|
||||
|
||||
jellyseerr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
@@ -288,7 +311,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
+26
-7
@@ -21,6 +21,8 @@ SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||
SQLITE_CACHE_SIZE_KIB = 32_768
|
||||
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||
_DB_UNSET = object()
|
||||
_DEFAULT_JWT_SECRET = "change-me"
|
||||
_DEFAULT_ADMIN_PASSWORD = "adminadmin"
|
||||
|
||||
|
||||
def _db_path() -> str:
|
||||
@@ -178,6 +180,11 @@ def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
|
||||
return candidate
|
||||
|
||||
|
||||
def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
password = str(settings.admin_password or "")
|
||||
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
@@ -411,12 +418,16 @@ def init_db() -> None:
|
||||
ON requests_cache (updated_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# Older databases may not have requested_by_id until later migrations run.
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
||||
@@ -767,7 +778,7 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
|
||||
|
||||
|
||||
def ensure_admin_user() -> None:
|
||||
if not settings.admin_username or not settings.admin_password:
|
||||
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||
return
|
||||
existing = get_user_by_username(settings.admin_username)
|
||||
if existing:
|
||||
@@ -775,6 +786,14 @@ def ensure_admin_user() -> None:
|
||||
create_user(settings.admin_username, settings.admin_password, role="admin")
|
||||
|
||||
|
||||
def has_admin_user() -> bool:
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
|
||||
+19
-5
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .db import init_db
|
||||
from .db import has_admin_user, init_db
|
||||
from .routers.requests import (
|
||||
router as requests_router,
|
||||
startup_warmup_requests_cache,
|
||||
@@ -165,13 +165,15 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
||||
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
if str(settings.jwt_secret or "").strip() == "change-me":
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
logger.warning(
|
||||
"security configuration warning: JWT_SECRET is still set to the default value"
|
||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
||||
)
|
||||
if str(settings.admin_password or "") == "adminadmin":
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
@@ -179,6 +181,17 @@ def _log_security_configuration_warnings() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _enforce_secure_startup_configuration() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
raise RuntimeError(
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
configure_logging(
|
||||
@@ -192,6 +205,7 @@ async def startup() -> None:
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
configure_logging(
|
||||
runtime.log_level,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .config import settings
|
||||
|
||||
_METADATA_HOSTS = {
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"metadata.azure.internal",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _split_csv(value: object) -> list[str]:
|
||||
raw = _normalize_text(value)
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
||||
return bool(
|
||||
ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_multicast
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
or ip_obj.is_private
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
||||
resolved: list[ipaddress._BaseAddress] = []
|
||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
||||
if family == socket.AF_INET:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
elif family == socket.AF_INET6:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
||||
candidate = _normalize_text(host)
|
||||
if not candidate:
|
||||
return False
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
||||
|
||||
for entry in trusted_proxies:
|
||||
raw = _normalize_text(entry)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
if "/" in raw:
|
||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
||||
return True
|
||||
elif host_ip == ipaddress.ip_address(raw):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
||||
return False
|
||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
||||
if not trusted:
|
||||
return False
|
||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
||||
|
||||
|
||||
def validate_notification_target_url(
|
||||
url: str,
|
||||
*,
|
||||
allow_private: bool | None = None,
|
||||
) -> str:
|
||||
raw = _normalize_text(url)
|
||||
if not raw:
|
||||
raise ValueError("URL cannot be empty.")
|
||||
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("URL must use http:// or https://.")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("URL must not embed credentials.")
|
||||
hostname = _normalize_text(parsed.hostname).lower()
|
||||
if not hostname:
|
||||
raise ValueError("URL must include a valid host.")
|
||||
|
||||
allow_private_targets = (
|
||||
settings.magent_allow_private_notification_targets
|
||||
if allow_private is None
|
||||
else bool(allow_private)
|
||||
)
|
||||
if hostname in _METADATA_HOSTS:
|
||||
raise ValueError("Metadata service targets are not allowed.")
|
||||
if hostname == "localhost" and not allow_private_targets:
|
||||
raise ValueError("Local notification targets are not allowed.")
|
||||
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
host_ip = None
|
||||
|
||||
if host_ip is not None:
|
||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
|
||||
try:
|
||||
resolved_ips = _resolve_host_ips(hostname)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("Host could not be resolved.") from exc
|
||||
if not resolved_ips:
|
||||
raise ValueError("Host could not be resolved.")
|
||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
@@ -20,6 +20,7 @@ from ..auth import (
|
||||
resolve_user_auth_provider,
|
||||
)
|
||||
from ..config import settings as env_settings
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
get_all_users,
|
||||
@@ -153,6 +154,12 @@ URL_SETTING_KEYS = {
|
||||
"qbittorrent_base_url",
|
||||
}
|
||||
|
||||
NOTIFICATION_URL_SETTING_KEYS = {
|
||||
"magent_notify_discord_webhook_url",
|
||||
"magent_notify_push_base_url",
|
||||
"magent_notify_webhook_url",
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
"magent_application_url",
|
||||
"magent_application_port",
|
||||
@@ -659,6 +666,12 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
except ValueError as exc:
|
||||
friendly_key = key.replace("_", " ")
|
||||
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
|
||||
try:
|
||||
value_to_store = validate_notification_target_url(value_to_store)
|
||||
except ValueError as exc:
|
||||
friendly_key = key.replace("_", " ")
|
||||
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
|
||||
set_setting(key, value_to_store)
|
||||
updates += 1
|
||||
changed_keys.append(key)
|
||||
|
||||
+81
-45
@@ -7,7 +7,7 @@ import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
@@ -47,8 +47,15 @@ from ..security import (
|
||||
verify_password,
|
||||
)
|
||||
from ..security import create_stream_token
|
||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..auth import (
|
||||
clear_auth_cookies,
|
||||
get_current_user,
|
||||
normalize_user_auth_provider,
|
||||
resolve_user_auth_provider,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..network_security import request_trusts_forwarded_headers
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
@@ -96,12 +103,14 @@ def _require_recipient_email(value: object) -> str:
|
||||
|
||||
|
||||
def _auth_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if isinstance(forwarded, str) and forwarded.strip():
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
real = request.headers.get("x-real-ip")
|
||||
if isinstance(real, str) and real.strip():
|
||||
return real.strip()
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if isinstance(forwarded, str) and forwarded.strip():
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
real = request.headers.get("x-real-ip")
|
||||
if isinstance(real, str) and real.strip():
|
||||
return real.strip()
|
||||
if request.client and request.client.host:
|
||||
return str(request.client.host)
|
||||
return "unknown"
|
||||
@@ -358,6 +367,15 @@ def _assert_user_can_login(user: dict | None) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
|
||||
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
|
||||
set_auth_cookies(response, token)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"token_type": "cookie",
|
||||
"user": user_payload,
|
||||
}
|
||||
|
||||
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
@@ -580,7 +598,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=local username=%s client=%s",
|
||||
@@ -629,15 +651,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
user["role"],
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user["username"], "role": user["role"]},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": user["username"], "role": user["role"]},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def jellyfin_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=jellyfin username=%s client=%s",
|
||||
@@ -668,13 +694,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
canonical_username,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
try:
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
auth_response = await client.authenticate_by_name(username, password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=jellyfin username=%s client=%s",
|
||||
@@ -682,7 +708,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
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(auth_response, dict) or not auth_response.get("User"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
if not preferred_match:
|
||||
@@ -724,16 +750,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seerr/login")
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def jellyseerr_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=seerr username=%s client=%s",
|
||||
@@ -745,7 +775,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
|
||||
try:
|
||||
response = await client.login_local(form_data.username, form_data.password)
|
||||
auth_response = await client.login_local(form_data.username, form_data.password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=seerr username=%s client=%s",
|
||||
@@ -753,11 +783,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
if not isinstance(auth_response, dict):
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(response)
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||
ci_matches = get_users_by_username_ci(form_data.username)
|
||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||
@@ -791,11 +821,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
jellyseerr_user_id,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -803,6 +833,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response) -> dict:
|
||||
clear_auth_cookies(response)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stream-token")
|
||||
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
token = create_stream_token(
|
||||
@@ -832,7 +868,7 @@ async def invite_details(code: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict) -> dict:
|
||||
async def signup(payload: dict, response: Response) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
invite_code = str(payload.get("invite_code") or "").strip()
|
||||
@@ -908,14 +944,14 @@ async def signup(payload: dict) -> dict:
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
except Exception as auth_exc:
|
||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||
) from exc
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
@@ -987,17 +1023,17 @@ async def signup(payload: dict) -> dict:
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
|
||||
@@ -3,6 +3,7 @@ import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||
@@ -17,6 +18,10 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
|
||||
)
|
||||
if not webhook_url:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
try:
|
||||
webhook_url = validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||
if feedback_type not in {"bug", "feature"}:
|
||||
|
||||
@@ -118,6 +118,7 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
||||
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
||||
_failed_detail_cache.pop(key, None)
|
||||
|
||||
|
||||
def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
|
||||
@@ -169,7 +170,6 @@ async def _request_is_available_in_jellyfin(
|
||||
return True
|
||||
availability_cache[cache_key] = False
|
||||
return False
|
||||
_failed_detail_cache.pop(key, None)
|
||||
|
||||
|
||||
def _failure_cache_has(key: str) -> bool:
|
||||
@@ -421,6 +421,34 @@ def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Option
|
||||
return tmdb_id, media_type
|
||||
|
||||
|
||||
def _normalize_media_type(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"movie", "tv"}:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_seasons(value: Any) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
|
||||
normalized: list[int] = []
|
||||
for raw in value:
|
||||
try:
|
||||
season = int(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="seasons must contain only positive integers"
|
||||
) from exc
|
||||
if season <= 0:
|
||||
raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
|
||||
normalized.append(season)
|
||||
return sorted(set(normalized))
|
||||
|
||||
|
||||
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
|
||||
poster_path, backdrop_path = _extract_artwork_paths(payload)
|
||||
tmdb_id, media_type = _extract_tmdb_lookup(payload)
|
||||
@@ -1864,12 +1892,135 @@ async def search_requests(
|
||||
"statusLabel": status_label,
|
||||
"requestedBy": requested_by,
|
||||
"accessible": accessible,
|
||||
"posterPath": item.get("posterPath") or item.get("poster_path"),
|
||||
"backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
|
||||
}
|
||||
)
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_request(
|
||||
payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Seerr not configured")
|
||||
|
||||
media_type = _normalize_media_type(
|
||||
payload.get("mediaType") or payload.get("type") or payload.get("media_type")
|
||||
)
|
||||
if media_type is None:
|
||||
raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
|
||||
|
||||
raw_tmdb_id = payload.get("tmdbId")
|
||||
if raw_tmdb_id is None:
|
||||
raw_tmdb_id = payload.get("mediaId")
|
||||
if raw_tmdb_id is None:
|
||||
raw_tmdb_id = payload.get("id")
|
||||
try:
|
||||
tmdb_id = int(raw_tmdb_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
|
||||
if tmdb_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
|
||||
|
||||
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
|
||||
raw_is_4k = payload.get("is4k")
|
||||
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
|
||||
raise HTTPException(status_code=400, detail="is4k must be true or false")
|
||||
is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
|
||||
|
||||
try:
|
||||
details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||
|
||||
if not isinstance(details, dict):
|
||||
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
|
||||
|
||||
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
|
||||
requests_list = media_info.get("requests")
|
||||
existing_request: Optional[Dict[str, Any]] = None
|
||||
if isinstance(requests_list, list) and requests_list:
|
||||
first_request = requests_list[0]
|
||||
if isinstance(first_request, dict):
|
||||
existing_request = first_request
|
||||
|
||||
title = details.get("title") or details.get("name")
|
||||
year: Optional[int] = None
|
||||
date_value = details.get("releaseDate") or details.get("firstAirDate")
|
||||
if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
|
||||
year = int(date_value[:4])
|
||||
|
||||
if isinstance(existing_request, dict):
|
||||
existing_request_id = _quality_profile_id(existing_request.get("id"))
|
||||
existing_status = existing_request.get("status")
|
||||
if existing_request_id is not None:
|
||||
request_payload = await _get_request_details(client, existing_request_id)
|
||||
if isinstance(request_payload, dict):
|
||||
parsed_payload = _parse_request_payload(request_payload)
|
||||
upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
|
||||
_cache_set(f"request:{existing_request_id}", request_payload)
|
||||
title = parsed_payload.get("title") or title
|
||||
year = parsed_payload.get("year") or year
|
||||
return {
|
||||
"status": "exists",
|
||||
"requestId": existing_request_id,
|
||||
"type": media_type,
|
||||
"tmdbId": tmdb_id,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"statusCode": existing_status,
|
||||
"statusLabel": _status_label(existing_status),
|
||||
}
|
||||
|
||||
try:
|
||||
created = await client.create_request(
|
||||
media_type=media_type,
|
||||
media_id=tmdb_id,
|
||||
seasons=seasons if media_type == "tv" else None,
|
||||
is_4k=is_4k,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||
|
||||
if not isinstance(created, dict):
|
||||
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
|
||||
|
||||
parsed = _parse_request_payload(created)
|
||||
request_id = _quality_profile_id(parsed.get("request_id"))
|
||||
status_code = parsed.get("status")
|
||||
title = parsed.get("title") or title
|
||||
year = parsed.get("year") or year
|
||||
|
||||
if request_id is not None:
|
||||
upsert_request_cache(**_build_request_cache_record(parsed, created))
|
||||
_cache_set(f"request:{request_id}", created)
|
||||
_recent_cache["updated_at"] = None
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
str(request_id),
|
||||
"request_created",
|
||||
"Create request",
|
||||
"ok",
|
||||
f"{media_type} request created from discovery by {user.get('username')}.",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
"requestId": request_id,
|
||||
"type": media_type,
|
||||
"tmdbId": tmdb_id,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"statusCode": status_code,
|
||||
"statusLabel": _status_label(status_code),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
|
||||
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
|
||||
runtime = get_runtime_settings()
|
||||
|
||||
@@ -26,6 +26,35 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
||||
if not qbittorrent.base_url:
|
||||
return {"name": "qBittorrent", "status": "not_configured"}
|
||||
if not qbittorrent.username or not qbittorrent.password:
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded" if reachable else "not_configured",
|
||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
||||
}
|
||||
try:
|
||||
result = await qbittorrent.get_app_version()
|
||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
||||
except RuntimeError as exc:
|
||||
if "login failed" in str(exc).lower():
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
if reachable:
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded",
|
||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
||||
}
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services_status() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
@@ -71,13 +100,7 @@ async def services_status() -> Dict[str, Any]:
|
||||
prowlarr_status["status"] = "degraded"
|
||||
prowlarr_status["message"] = "Health warnings"
|
||||
services.append(prowlarr_status)
|
||||
services.append(
|
||||
await _check(
|
||||
"qBittorrent",
|
||||
qbittorrent.configured(),
|
||||
qbittorrent.get_app_version,
|
||||
)
|
||||
)
|
||||
services.append(await _check_qbittorrent(qbittorrent))
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
@@ -122,10 +145,12 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||
}
|
||||
|
||||
if service_key == "qbittorrent":
|
||||
return await _check_qbittorrent(qbittorrent)
|
||||
|
||||
if service_key not in checks:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ def _create_token(
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
|
||||
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
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
@@ -55,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..clients.radarr import RadarrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_database_diagnostics
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
||||
|
||||
@@ -97,7 +98,12 @@ def _config_status(detail: str) -> str:
|
||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
||||
return False, "Discord notifications are disabled."
|
||||
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
|
||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Discord webhook URL is required."
|
||||
|
||||
@@ -113,7 +119,12 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
||||
return False, "Generic webhook notifications are disabled."
|
||||
if _clean_text(runtime.magent_notify_webhook_url):
|
||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Generic webhook URL is required."
|
||||
|
||||
@@ -123,11 +134,21 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
return False, "Push notifications are disabled."
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
if provider == "ntfy":
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "ntfy requires a base URL and topic."
|
||||
if provider == "gotify":
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Gotify requires a base URL and app token."
|
||||
if provider == "pushover":
|
||||
@@ -135,7 +156,12 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
return True, "ok"
|
||||
return False, "Pushover requires an application token and user key."
|
||||
if provider == "webhook":
|
||||
if _clean_text(runtime.magent_notify_push_base_url):
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url:
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Webhook relay requires a target URL."
|
||||
if provider == "telegram":
|
||||
@@ -190,6 +216,7 @@ async def _run_http_post(
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -8,6 +8,7 @@ import httpx
|
||||
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_setting
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_generic_email
|
||||
|
||||
@@ -49,6 +50,7 @@ def _portal_item_url(item_id: int) -> str:
|
||||
|
||||
|
||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
@@ -115,6 +117,7 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
|
||||
if provider == "ntfy":
|
||||
if not base_url or not topic:
|
||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
||||
headers = {"Title": title, "Tags": "magent,portal"}
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
@@ -124,6 +127,7 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
|
||||
if provider == "gotify":
|
||||
if not base_url or not token:
|
||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
||||
result = await _http_post_json(url, body)
|
||||
|
||||
@@ -8,8 +8,11 @@ from starlette.requests import Request
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.routers import portal as portal_router
|
||||
from backend.app.routers import requests as requests_router
|
||||
from backend.app.routers import status as status_router
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||
from backend.app.services import password_reset
|
||||
|
||||
@@ -72,6 +75,65 @@ class PasswordPolicyTests(unittest.TestCase):
|
||||
self.assertEqual(validate_password_policy(" password123 "), "password123")
|
||||
|
||||
|
||||
class NetworkSecurityTests(unittest.TestCase):
|
||||
def test_notification_targets_reject_loopback(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
|
||||
validate_notification_target_url("http://127.0.0.1:8080/webhook")
|
||||
|
||||
def test_forwarded_headers_require_trusted_proxy(self) -> None:
|
||||
original_enabled = settings.magent_proxy_enabled
|
||||
original_trust = settings.magent_proxy_trust_forwarded_headers
|
||||
original_proxies = settings.magent_proxy_trusted_proxies
|
||||
settings.magent_proxy_enabled = True
|
||||
settings.magent_proxy_trust_forwarded_headers = True
|
||||
settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
|
||||
try:
|
||||
self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
|
||||
self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
|
||||
finally:
|
||||
settings.magent_proxy_enabled = original_enabled
|
||||
settings.magent_proxy_trust_forwarded_headers = original_trust
|
||||
settings.magent_proxy_trusted_proxies = original_proxies
|
||||
|
||||
|
||||
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
|
||||
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
|
||||
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||
result = await status_router._check_qbittorrent(client)
|
||||
|
||||
self.assertEqual(result["status"], "degraded")
|
||||
self.assertIn("credentials", result["message"].lower())
|
||||
|
||||
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
|
||||
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
|
||||
with patch.object(
|
||||
client,
|
||||
"get_app_version",
|
||||
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
|
||||
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||
result = await status_router._check_qbittorrent(client)
|
||||
|
||||
self.assertEqual(result["status"], "degraded")
|
||||
self.assertIn("credentials", result["message"].lower())
|
||||
|
||||
|
||||
class RequestCacheTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
requests_router._detail_cache.clear()
|
||||
requests_router._failed_detail_cache.clear()
|
||||
|
||||
def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
|
||||
key = "request:123"
|
||||
requests_router._failure_cache_set(key)
|
||||
self.assertTrue(requests_router._failure_cache_has(key))
|
||||
|
||||
requests_router._cache_set(key, {"id": 123})
|
||||
|
||||
self.assertFalse(requests_router._failure_cache_has(key))
|
||||
self.assertEqual(requests_router._cache_get(key), {"id": 123})
|
||||
|
||||
|
||||
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_set_user_email_is_case_insensitive(self) -> None:
|
||||
created = db.create_user_if_missing(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||
|
||||
type Profile = {
|
||||
username?: string
|
||||
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
throw new Error('Could not load profile.')
|
||||
}
|
||||
const data = await response.json()
|
||||
setProfile({ username: data?.username })
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Request failed: ${response.status}`)
|
||||
}
|
||||
setMessage('')
|
||||
setStatus('Thanks! Your message has been sent.')
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
setStatus('That did not send. Please try again.')
|
||||
} finally {
|
||||
|
||||
+131
-1
@@ -3565,12 +3565,14 @@ button:disabled {
|
||||
.user-grid-pill.is-blocked {
|
||||
background: rgba(244, 114, 114, 0.14);
|
||||
border-color: rgba(244, 114, 114, 0.24);
|
||||
color: #ffd5d5;
|
||||
}
|
||||
|
||||
.system-pill-degraded,
|
||||
.user-grid-pill.is-disabled {
|
||||
background: rgba(208, 166, 92, 0.14);
|
||||
border-color: rgba(208, 166, 92, 0.22);
|
||||
color: #ffe3a6;
|
||||
}
|
||||
|
||||
.system-dot {
|
||||
@@ -6565,6 +6567,27 @@ textarea {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.portal-workspace-switch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-workspace-switch button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-workspace-switch button.is-active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
|
||||
background: rgba(107, 146, 255, 0.12);
|
||||
}
|
||||
|
||||
.portal-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -6597,6 +6620,86 @@ textarea {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-discovery-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portal-discovery-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.portal-discovery-form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.portal-discovery-results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.portal-discovery-item {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.portal-discovery-media {
|
||||
width: 56px;
|
||||
height: 84px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.portal-discovery-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.portal-discovery-main {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.portal-discovery-title-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-discovery-main p {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.portal-discovery-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poster-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.portal-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -6609,7 +6712,7 @@ textarea {
|
||||
|
||||
.portal-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
@@ -6756,6 +6859,15 @@ textarea {
|
||||
.portal-item-list {
|
||||
max-height: 460px;
|
||||
}
|
||||
|
||||
.portal-discovery-item {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.portal-discovery-actions {
|
||||
grid-column: span 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -6776,4 +6888,22 @@ textarea {
|
||||
.portal-mine-toggle {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.portal-discovery-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portal-discovery-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portal-discovery-media {
|
||||
width: 72px;
|
||||
height: 108px;
|
||||
}
|
||||
|
||||
.portal-discovery-actions {
|
||||
grid-column: span 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
+72
-12
@@ -1,27 +1,53 @@
|
||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
||||
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||
|
||||
export const getToken = () => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem('magent_token')
|
||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
export const setToken = (token: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem('magent_token', token)
|
||||
const clearCookie = (name: string) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
export const getToken = () => {
|
||||
if (typeof document === 'undefined') return null
|
||||
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
|
||||
if (!marker) return null
|
||||
const [, value] = marker.split('=', 2)
|
||||
return value || null
|
||||
}
|
||||
|
||||
export const setToken = (_token: string) => {
|
||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
||||
}
|
||||
|
||||
export const clearToken = () => {
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.removeItem('magent_token')
|
||||
const baseUrl = getApiBase()
|
||||
void fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const logout = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
await fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const token = getToken()
|
||||
const headers = new Headers(init?.headers || {})
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return fetch(input, { ...init, headers })
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
}
|
||||
|
||||
export const getEventStreamToken = async () => {
|
||||
@@ -38,3 +64,37 @@ export const getEventStreamToken = async () => {
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,14 @@ export default function LoginPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/'
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import PortalClient from '../PortalClient'
|
||||
|
||||
export default function IssuePortalPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
}
|
||||
|
||||
@@ -1,927 +1,6 @@
|
||||
'use client'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type PortalPermissions = {
|
||||
can_edit?: boolean
|
||||
can_comment?: boolean
|
||||
can_moderate?: boolean
|
||||
export default function PortalIndexPage() {
|
||||
redirect('/portal/requests')
|
||||
}
|
||||
|
||||
type PortalItem = {
|
||||
id: number
|
||||
kind: 'request' | 'issue' | 'feature'
|
||||
title: string
|
||||
description: string
|
||||
media_type?: 'movie' | 'tv' | null
|
||||
year?: number | null
|
||||
external_ref?: string | null
|
||||
source_system?: string | null
|
||||
source_request_id?: number | null
|
||||
status: string
|
||||
priority: string
|
||||
created_by_username: string
|
||||
assignee_username?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_activity_at: string
|
||||
permissions?: PortalPermissions
|
||||
workflow?: {
|
||||
request_status?: string
|
||||
media_status?: string
|
||||
stage_label?: string
|
||||
is_terminal?: boolean
|
||||
}
|
||||
issue?: {
|
||||
issue_type?: string
|
||||
related_item_id?: number | null
|
||||
is_resolved?: boolean
|
||||
resolved_at?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type PortalComment = {
|
||||
id: number
|
||||
item_id: number
|
||||
author_username: string
|
||||
author_role: string
|
||||
message: string
|
||||
is_internal: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type PortalOverview = {
|
||||
overview?: {
|
||||
total_items?: number
|
||||
total_comments?: number
|
||||
by_kind?: Record<string, number>
|
||||
by_status?: Record<string, number>
|
||||
}
|
||||
my_items?: number
|
||||
}
|
||||
|
||||
type UserProfile = {
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'issue', label: 'Issue' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
] as const
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'triaging', label: 'Triaging' },
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'in_progress', label: 'In progress' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'pending', label: 'Pending approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'partially_available', label: 'Partially available' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
] as const
|
||||
|
||||
const REQUEST_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
] as const
|
||||
|
||||
const MEDIA_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'partially_available', label: 'Partially available' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'unknown', label: 'Unknown' },
|
||||
] as const
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
] as const
|
||||
|
||||
const MEDIA_TYPE_OPTIONS = [
|
||||
{ value: '', label: 'None' },
|
||||
{ value: 'movie', label: 'Movie' },
|
||||
{ value: 'tv', label: 'TV' },
|
||||
] as const
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return 'Never'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.valueOf())) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
|
||||
const toPositiveInt = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed) || parsed <= 0) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default function PortalPage() {
|
||||
const router = useRouter()
|
||||
const [me, setMe] = useState<UserProfile | null>(null)
|
||||
const [overview, setOverview] = useState<PortalOverview | null>(null)
|
||||
const [items, setItems] = useState<PortalItem[]>([])
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
|
||||
const [comments, setComments] = useState<PortalComment[]>([])
|
||||
const [loadingItems, setLoadingItems] = useState(true)
|
||||
const [loadingItem, setLoadingItem] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [commenting, setCommenting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
|
||||
const [filterKind, setFilterKind] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterMine, setFilterMine] = useState(false)
|
||||
const [filterSearch, setFilterSearch] = useState('')
|
||||
|
||||
const [createKind, setCreateKind] = useState<'request' | 'issue' | 'feature'>('request')
|
||||
const [createTitle, setCreateTitle] = useState('')
|
||||
const [createDescription, setCreateDescription] = useState('')
|
||||
const [createMediaType, setCreateMediaType] = useState('')
|
||||
const [createYear, setCreateYear] = useState('')
|
||||
const [createExternalRef, setCreateExternalRef] = useState('')
|
||||
const [createPriority, setCreatePriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
|
||||
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [editMediaType, setEditMediaType] = useState('')
|
||||
const [editYear, setEditYear] = useState('')
|
||||
const [editExternalRef, setEditExternalRef] = useState('')
|
||||
const [editStatus, setEditStatus] = useState('new')
|
||||
const [editRequestStatus, setEditRequestStatus] = useState('pending')
|
||||
const [editMediaStatus, setEditMediaStatus] = useState('pending')
|
||||
const [editPriority, setEditPriority] = useState('normal')
|
||||
const [editAssignee, setEditAssignee] = useState('')
|
||||
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [commentInternal, setCommentInternal] = useState(false)
|
||||
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
|
||||
|
||||
const isAdmin = me?.role === 'admin'
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const raw = new URLSearchParams(window.location.search).get('item')
|
||||
if (!raw) {
|
||||
setPreselectedItemId(null)
|
||||
return
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
|
||||
}, [])
|
||||
|
||||
const loadMe = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to load session (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const profile: UserProfile = {
|
||||
username: data?.username ?? 'unknown',
|
||||
role: data?.role ?? 'user',
|
||||
}
|
||||
setMe(profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/portal/overview`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to load portal overview (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadItem = async (itemId: number) => {
|
||||
setLoadingItem(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/portal/items/${itemId}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (response.status === 404) {
|
||||
setSelectedItem(null)
|
||||
setComments([])
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to load portal item (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const item = (data?.item ?? null) as PortalItem | null
|
||||
setSelectedItem(item)
|
||||
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Could not load portal item details.')
|
||||
} finally {
|
||||
setLoadingItem(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadItems = async (options?: { preferItemId?: number | null }) => {
|
||||
setLoadingItems(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const params = new URLSearchParams({
|
||||
limit: '60',
|
||||
offset: '0',
|
||||
})
|
||||
if (filterKind) params.set('kind', filterKind)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterMine) params.set('mine', '1')
|
||||
const trimmedSearch = filterSearch.trim()
|
||||
if (trimmedSearch) params.set('search', trimmedSearch)
|
||||
|
||||
const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to load portal items (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : []
|
||||
setItems(loadedItems)
|
||||
setTotalItems(Number(data?.total ?? loadedItems.length ?? 0))
|
||||
setHasMore(Boolean(data?.has_more))
|
||||
|
||||
const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId
|
||||
if (preferred && loadedItems.some((item) => item.id === preferred)) {
|
||||
setSelectedItemId(preferred)
|
||||
} else if (loadedItems.length > 0) {
|
||||
setSelectedItemId(loadedItems[0].id)
|
||||
} else {
|
||||
setSelectedItemId(null)
|
||||
setSelectedItem(null)
|
||||
setComments([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Could not load portal items.')
|
||||
} finally {
|
||||
setLoadingItems(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await loadMe()
|
||||
await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Could not load request portal.')
|
||||
}
|
||||
}
|
||||
void bootstrap()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
return
|
||||
}
|
||||
void loadItems({ preferItemId: preselectedItemId })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterKind, filterStatus, filterMine, filterSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItemId == null) return
|
||||
void loadItem(selectedItemId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedItemId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItem) return
|
||||
setEditTitle(selectedItem.title ?? '')
|
||||
setEditDescription(selectedItem.description ?? '')
|
||||
setEditMediaType(selectedItem.media_type ?? '')
|
||||
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
|
||||
setEditExternalRef(selectedItem.external_ref ?? '')
|
||||
setEditStatus(selectedItem.status ?? 'new')
|
||||
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
|
||||
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
|
||||
setEditPriority(selectedItem.priority ?? 'normal')
|
||||
setEditAssignee(selectedItem.assignee_username ?? '')
|
||||
}, [selectedItem])
|
||||
|
||||
const createItem = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
kind: createKind,
|
||||
title: createTitle,
|
||||
description: createDescription,
|
||||
media_type: createMediaType || null,
|
||||
year: createYear.trim() ? toPositiveInt(createYear) : null,
|
||||
external_ref: createExternalRef || null,
|
||||
priority: createPriority,
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/portal/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Could not create portal item.')
|
||||
}
|
||||
const data = await response.json()
|
||||
const item = data?.item as PortalItem | undefined
|
||||
setStatus('Portal item created.')
|
||||
setCreateTitle('')
|
||||
setCreateDescription('')
|
||||
setCreateMediaType('')
|
||||
setCreateYear('')
|
||||
setCreateExternalRef('')
|
||||
setCreatePriority('normal')
|
||||
await Promise.all([
|
||||
loadItems({ preferItemId: item?.id ?? null }),
|
||||
loadOverview(),
|
||||
])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not create portal item.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveItem = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!selectedItem) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
media_type: editMediaType || null,
|
||||
year: editYear.trim() ? toPositiveInt(editYear) : null,
|
||||
external_ref: editExternalRef || null,
|
||||
}
|
||||
if (selectedItem.permissions?.can_moderate) {
|
||||
if (selectedItem.kind === 'request') {
|
||||
payload.request_status = editRequestStatus
|
||||
payload.media_status = editMediaStatus
|
||||
} else {
|
||||
payload.status = editStatus
|
||||
}
|
||||
payload.priority = editPriority
|
||||
payload.assignee_username = editAssignee || null
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Could not update portal item.')
|
||||
}
|
||||
const data = await response.json()
|
||||
setSelectedItem((data?.item ?? null) as PortalItem | null)
|
||||
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||
setStatus('Portal item updated.')
|
||||
await Promise.all([
|
||||
loadItems({ preferItemId: selectedItem.id }),
|
||||
loadOverview(),
|
||||
])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not update portal item.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const postComment = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!selectedItem) return
|
||||
if (!commentText.trim()) {
|
||||
setError('Comment message is required.')
|
||||
return
|
||||
}
|
||||
setCommenting(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: commentText,
|
||||
is_internal: commentInternal,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Could not add comment.')
|
||||
}
|
||||
setCommentText('')
|
||||
setCommentInternal(false)
|
||||
setStatus('Comment added.')
|
||||
await Promise.all([
|
||||
loadItem(selectedItem.id),
|
||||
loadItems({ preferItemId: selectedItem.id }),
|
||||
loadOverview(),
|
||||
])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not add comment.')
|
||||
} finally {
|
||||
setCommenting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingItems && !items.length) {
|
||||
return <main className="card">Loading request portal...</main>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card portal-page">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h1>Request portal</h1>
|
||||
<p className="lede">
|
||||
Raise requests, issues, and feature ideas. Track progress and keep discussion in one place.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
<section className="portal-overview-grid">
|
||||
<div className="portal-overview-card">
|
||||
<span>Total items</span>
|
||||
<strong>{Number(overview?.overview?.total_items ?? totalItems ?? 0)}</strong>
|
||||
</div>
|
||||
<div className="portal-overview-card">
|
||||
<span>Total comments</span>
|
||||
<strong>{Number(overview?.overview?.total_comments ?? 0)}</strong>
|
||||
</div>
|
||||
<div className="portal-overview-card">
|
||||
<span>My items</span>
|
||||
<strong>{Number(overview?.my_items ?? 0)}</strong>
|
||||
</div>
|
||||
<div className="portal-overview-card">
|
||||
<span>Visible</span>
|
||||
<strong>{items.length}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel portal-create-panel">
|
||||
<h2>Create item</h2>
|
||||
<p className="lede">
|
||||
Use <strong>Request</strong> for new content, <strong>Issue</strong> for broken behavior, and <strong>Feature</strong> for improvements.
|
||||
</p>
|
||||
<form onSubmit={createItem} className="admin-form compact-form portal-form-grid">
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select
|
||||
value={createKind}
|
||||
onChange={(event) =>
|
||||
setCreateKind(event.target.value as 'request' | 'issue' | 'feature')
|
||||
}
|
||||
>
|
||||
{KIND_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Priority</span>
|
||||
<select
|
||||
value={createPriority}
|
||||
onChange={(event) =>
|
||||
setCreatePriority(event.target.value as 'low' | 'normal' | 'high' | 'urgent')
|
||||
}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Title</span>
|
||||
<input
|
||||
required
|
||||
value={createTitle}
|
||||
onChange={(event) => setCreateTitle(event.target.value)}
|
||||
placeholder="Short summary of the request or issue"
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder="Add details, expected behavior, and any context."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Media type</span>
|
||||
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
|
||||
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'none'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Year</span>
|
||||
<input
|
||||
value={createYear}
|
||||
onChange={(event) => setCreateYear(event.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>External reference</span>
|
||||
<input
|
||||
value={createExternalRef}
|
||||
onChange={(event) => setCreateExternalRef(event.target.value)}
|
||||
placeholder="Optional: URL, ticket number, or request id"
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-inline-actions portal-field-span-2">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create portal item'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="portal-toolbar">
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select value={filterKind} onChange={(event) => setFilterKind(event.target.value)}>
|
||||
<option value="">All</option>
|
||||
{KIND_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
|
||||
<option value="">All</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="portal-search-filter">
|
||||
<span>Search</span>
|
||||
<input
|
||||
value={filterSearch}
|
||||
onChange={(event) => setFilterSearch(event.target.value)}
|
||||
placeholder="Title, description, or item id"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox portal-mine-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterMine}
|
||||
onChange={(event) => setFilterMine(event.target.checked)}
|
||||
/>
|
||||
My items only
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="portal-workspace">
|
||||
<section className="admin-panel portal-list-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Items</h2>
|
||||
<p className="lede">
|
||||
{totalItems} total
|
||||
{hasMore ? ' (showing first 60)' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="status-banner">No portal items match this filter.</div>
|
||||
) : (
|
||||
<div className="portal-item-list">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`portal-item-row ${selectedItemId === item.id ? 'is-active' : ''}`}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
>
|
||||
<div className="portal-item-row-main">
|
||||
<div className="portal-item-row-title">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="small-pill">{item.kind}</span>
|
||||
<span className="small-pill is-muted">{item.priority}</span>
|
||||
</div>
|
||||
<p>{item.description}</p>
|
||||
<div className="portal-item-row-meta">
|
||||
<span>#{item.id}</span>
|
||||
<span>
|
||||
Status:{' '}
|
||||
{item.kind === 'request'
|
||||
? item.workflow?.stage_label ?? item.status
|
||||
: item.status}
|
||||
</span>
|
||||
<span>By: {item.created_by_username}</span>
|
||||
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel portal-detail-panel">
|
||||
{!selectedItemId ? (
|
||||
<div className="status-banner">Select an item to view details.</div>
|
||||
) : loadingItem ? (
|
||||
<div className="status-banner">Loading details…</div>
|
||||
) : !selectedItem ? (
|
||||
<div className="status-banner">Item not found.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Item #{selectedItem.id}</h2>
|
||||
<p className="lede">
|
||||
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
||||
</p>
|
||||
{selectedItem.kind === 'request' && (
|
||||
<p className="lede">
|
||||
Pipeline:{' '}
|
||||
<strong>
|
||||
{selectedItem.workflow?.request_status ?? 'pending'} /{' '}
|
||||
{selectedItem.workflow?.media_status ?? 'pending'}
|
||||
</strong>{' '}
|
||||
({selectedItem.workflow?.stage_label ?? 'Pending'})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Title</span>
|
||||
<input
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editDescription}
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Media type</span>
|
||||
<select
|
||||
value={editMediaType}
|
||||
onChange={(event) => setEditMediaType(event.target.value)}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
>
|
||||
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'none'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Year</span>
|
||||
<input
|
||||
value={editYear}
|
||||
onChange={(event) => setEditYear(event.target.value)}
|
||||
inputMode="numeric"
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>External reference</span>
|
||||
<input
|
||||
value={editExternalRef}
|
||||
onChange={(event) => setEditExternalRef(event.target.value)}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
{selectedItem.permissions?.can_moderate && (
|
||||
<>
|
||||
{selectedItem.kind === 'request' ? (
|
||||
<>
|
||||
<label>
|
||||
<span>Request status</span>
|
||||
<select
|
||||
value={editRequestStatus}
|
||||
onChange={(event) => setEditRequestStatus(event.target.value)}
|
||||
>
|
||||
{REQUEST_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Media status</span>
|
||||
<select
|
||||
value={editMediaStatus}
|
||||
onChange={(event) => setEditMediaStatus(event.target.value)}
|
||||
>
|
||||
{MEDIA_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span>Priority</span>
|
||||
<select
|
||||
value={editPriority}
|
||||
onChange={(event) => setEditPriority(event.target.value)}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Assignee username</span>
|
||||
<input
|
||||
value={editAssignee}
|
||||
onChange={(event) => setEditAssignee(event.target.value)}
|
||||
placeholder="Optional assignee"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="admin-inline-actions portal-field-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !selectedItem.permissions?.can_edit}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="portal-comments-block">
|
||||
<h3>Comments</h3>
|
||||
{comments.length === 0 ? (
|
||||
<div className="status-banner">No comments yet.</div>
|
||||
) : (
|
||||
<div className="portal-comment-list">
|
||||
{comments.map((comment) => (
|
||||
<article key={comment.id} className="portal-comment-card">
|
||||
<header>
|
||||
<strong>{comment.author_username}</strong>
|
||||
<span className="small-pill">{comment.author_role}</span>
|
||||
{comment.is_internal && <span className="small-pill is-muted">internal</span>}
|
||||
<span>{formatDate(comment.created_at)}</span>
|
||||
</header>
|
||||
<p>{comment.message}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={postComment} className="admin-form compact-form portal-comment-form">
|
||||
<label>
|
||||
<span>Add comment</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Add an update, troubleshooting note, or next step."
|
||||
/>
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commentInternal}
|
||||
onChange={(event) => setCommentInternal(event.target.checked)}
|
||||
/>
|
||||
Internal comment (admin only)
|
||||
</label>
|
||||
)}
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={commenting}>
|
||||
{commenting ? 'Posting…' : 'Post comment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import PortalClient from '../PortalClient'
|
||||
|
||||
export default function RequestPortalPage() {
|
||||
return <PortalClient workspace="request" />
|
||||
}
|
||||
|
||||
@@ -55,6 +55,44 @@ type ActionHistory = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const readApiError = async (response: Response, fallback: string) => {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||||
return payload.detail
|
||||
}
|
||||
if (typeof payload?.message === 'string' && payload.message.trim()) {
|
||||
return payload.message
|
||||
}
|
||||
} else {
|
||||
const text = await response.text()
|
||||
if (text.trim()) {
|
||||
return text.trim()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const isSnapshotPayload = (value: unknown): value is Snapshot => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const snapshot = value as Partial<Snapshot>
|
||||
return (
|
||||
typeof snapshot.request_id === 'string' &&
|
||||
typeof snapshot.title === 'string' &&
|
||||
typeof snapshot.request_type === 'string' &&
|
||||
typeof snapshot.state === 'string' &&
|
||||
Array.isArray(snapshot.timeline) &&
|
||||
Array.isArray(snapshot.actions)
|
||||
)
|
||||
}
|
||||
|
||||
const percentFromTorrent = (torrent: Record<string, any>) => {
|
||||
const progress = Number(torrent.progress)
|
||||
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) {
|
||||
@@ -201,6 +239,7 @@ export default function RequestTimelinePage() {
|
||||
const router = useRouter()
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null)
|
||||
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
||||
@@ -214,6 +253,9 @@ export default function RequestTimelinePage() {
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
setSnapshot(null)
|
||||
try {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
@@ -226,12 +268,22 @@ export default function RequestTimelinePage() {
|
||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
|
||||
])
|
||||
|
||||
if (snapshotResponse.status === 401) {
|
||||
const authExpired = [snapshotResponse, historyResponse, actionsResponse].some(
|
||||
(response) => response.status === 401
|
||||
)
|
||||
if (authExpired) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!snapshotResponse.ok) {
|
||||
const message = await readApiError(snapshotResponse, 'Unable to load this request.')
|
||||
throw new Error(message)
|
||||
}
|
||||
const snapshotData = await snapshotResponse.json()
|
||||
if (!isSnapshotPayload(snapshotData)) {
|
||||
throw new Error('Unable to load this request.')
|
||||
}
|
||||
setSnapshot(snapshotData)
|
||||
setReleaseOptions([])
|
||||
setSearchRan(false)
|
||||
@@ -251,6 +303,9 @@ export default function RequestTimelinePage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setLoadError(
|
||||
error instanceof Error && error.message ? error.message : 'Unable to load this request.'
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -328,8 +383,12 @@ export default function RequestTimelinePage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return <main className="card">{loadError}</main>
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return <main className="card">Could not load that request.</main>
|
||||
return <main className="card">Unable to load this request.</main>
|
||||
}
|
||||
|
||||
const summary =
|
||||
|
||||
@@ -106,6 +106,7 @@ function SignupPageContent() {
|
||||
const response = await fetch(`${baseUrl}/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
username: username.trim(),
|
||||
@@ -117,12 +118,12 @@ function SignupPageContent() {
|
||||
throw new Error(text || 'Sign-up failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
window.location.href = '/'
|
||||
return
|
||||
}
|
||||
throw new Error('Sign-up did not return a token')
|
||||
throw new Error('Sign-up did not complete')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
||||
|
||||
@@ -42,7 +42,8 @@ export default function HeaderActions() {
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
<a href="/portal">Portal</a>
|
||||
<a href="/portal/requests">Portal</a>
|
||||
<a href="/portal/issues">Issues</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
||||
|
||||
export default function HeaderIdentity() {
|
||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
||||
@@ -49,7 +49,8 @@ export default function HeaderIdentity() {
|
||||
|
||||
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
||||
const initial = identity.username.slice(0, 1).toUpperCase()
|
||||
const signOut = () => {
|
||||
const signOut = async () => {
|
||||
await logout().catch(() => undefined)
|
||||
clearToken()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
@@ -83,7 +84,7 @@ export default function HeaderIdentity() {
|
||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||
Changelog
|
||||
</a>
|
||||
<button type="button" className="signed-in-signout" onClick={signOut}>
|
||||
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"version": "0803262038",
|
||||
"version": "0803262237",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magent-frontend",
|
||||
"version": "0803262038",
|
||||
"version": "0803262237",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0803262038",
|
||||
"version": "0803262237",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
python_bin="${PYTHON_BIN:-python3}"
|
||||
|
||||
echo "Installing backend Python requirements"
|
||||
"$python_bin" -m pip install -r backend/requirements.txt
|
||||
|
||||
echo "Running Python dependency integrity check"
|
||||
"$python_bin" -m pip check
|
||||
|
||||
echo "Running backend unit tests"
|
||||
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||
|
||||
echo "Backend quality gate passed"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||
deploy_user="${DEPLOY_USER:-zak}"
|
||||
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
|
||||
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
mkdir -p '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
cp -a \"\$path\" \"\${backup_root}/\"
|
||||
fi
|
||||
done
|
||||
tar -xf - -C '${deploy_path}'
|
||||
docker compose up -d --build
|
||||
"
|
||||
|
||||
echo "Running remote smoke checks"
|
||||
ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
from urllib import request
|
||||
|
||||
checks = [
|
||||
('http://127.0.0.1:8000/health', 200),
|
||||
('http://127.0.0.1:3000/login', 200),
|
||||
]
|
||||
|
||||
for url, expected in checks:
|
||||
with request.urlopen(url, timeout=20) as response:
|
||||
if response.status != expected:
|
||||
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||
print(url, response.status)
|
||||
PY
|
||||
"
|
||||
|
||||
echo "Deployment completed successfully"
|
||||
Reference in New Issue
Block a user