Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f830fc1296 | ||
|
|
3989e90a9a | ||
|
|
4e2b902760 | ||
|
|
494b79ed26 | ||
|
|
d30a2473ce | ||
|
|
4e64f79e64 | ||
|
|
c6bc31f27e | ||
|
|
1ad4823830 | ||
|
|
caa6aa76d6 | ||
|
|
d80b1e5e4f | ||
|
|
1ff54690fc | ||
|
|
4f2b5e0922 | ||
|
|
96333c0d85 | ||
|
|
bac96c7db3 | ||
|
|
dda17a20a5 | ||
|
|
e582ff4ef7 | ||
|
|
42d4caa474 | ||
|
|
5f2dc52771 | ||
|
|
9c69d9fd17 | ||
|
|
b0ef455498 | ||
|
|
821f518bb3 | ||
|
|
eeba143b41 | ||
|
|
b068a6066e | ||
|
|
aae2c3d418 | ||
|
|
d1c9acbb8d | ||
|
|
12d3777e76 | ||
|
|
c205df4367 | ||
|
|
05a3d1e3b0 | ||
|
|
b84c27c698 | ||
|
|
744b213fa0 | ||
|
|
f362676c4e | ||
|
|
7257d32d6c | ||
|
|
1c6b8255c1 | ||
|
|
0b73d9f4ee | ||
|
|
b215e8030c | ||
|
|
6a5d2c4310 | ||
|
|
23c57da3cc | ||
|
|
1b1a3e233b | ||
|
|
bd3c0bdade | ||
|
|
50be0b6b57 | ||
|
|
5dfe614d15 | ||
|
|
ec408df2a1 | ||
|
|
f78382c019 | ||
|
|
9be0ec75ec | ||
|
|
be7b899837 | ||
|
|
d045dd0b07 | ||
|
|
138069590b | ||
|
|
8125b766c7 | ||
|
|
d53e2917aa | ||
|
|
d7847652db | ||
|
|
24ac54d606 | ||
|
|
62f392ad37 | ||
|
|
e42ae8585d | ||
|
|
06e0797722 | ||
|
|
914f478178 | ||
|
|
fb65d646f2 | ||
|
|
3493bf715e | ||
|
|
b98239ab3e | ||
|
|
40dc46c0c5 | ||
|
|
d23d84ea42 | ||
|
|
7d6cdcbe02 | ||
|
|
0e95f94025 | ||
|
|
8b1a09fbd4 | ||
|
|
fe0c108363 | ||
|
|
9e8d22ba85 | ||
|
|
7863658a19 | ||
|
|
7c97934bb9 | ||
|
|
3f51e24181 | ||
|
|
ab27ebfadf | ||
|
|
b93b41713a | ||
|
|
ceb8c1c9eb | ||
|
|
86ca3bdeb2 | ||
|
|
22f90b7e07 | ||
|
|
57a4883931 | ||
|
|
6ba41b854b | ||
|
|
580b335268 | ||
|
|
23549f1e45 | ||
|
|
2c45dd0065 | ||
|
|
92959d80ab | ||
|
|
615c4c1c29 | ||
|
|
38eee2407b | ||
|
|
cf4277d10c | ||
|
|
030480410b | ||
|
|
3d414b4aeb | ||
|
|
18bbcbf660 | ||
|
|
5fa3aa6665 | ||
|
|
52e3d680f7 | ||
|
|
00bccfa8b6 | ||
|
|
aa3532dd83 | ||
|
|
4ec2351241 | ||
|
|
6480478167 | ||
|
|
3739e11016 | ||
|
|
132e02e06e | ||
|
|
cc79685eaf | ||
|
|
b20cf0a9d2 | ||
|
|
eab212ea8d | ||
|
|
24685a5371 | ||
|
|
49e9ee771f | ||
|
|
69dc7febe2 | ||
|
|
7b8fc1d99b | ||
|
|
7a7d570852 | ||
|
|
3eb4b3f09f | ||
|
|
6425345c69 | ||
|
|
fe43a81175 |
+1
-1
@@ -1 +1 @@
|
||||
0803262237
|
||||
0803262216
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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
|
||||
|
||||
deploy-beta:
|
||||
if: github.ref_name == 'beta'
|
||||
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 beta to AMS-DEV01
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="replace-with-a-long-random-secret"
|
||||
JWT_SECRET="change-me"
|
||||
JWT_EXP_MINUTES="720"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="adminadmin"
|
||||
```
|
||||
|
||||
## 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="replace-with-a-long-random-secret"
|
||||
$env:JWT_SECRET="change-me"
|
||||
$env:JWT_EXP_MINUTES="720"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
$env:ADMIN_USERNAME="admin"
|
||||
$env:ADMIN_PASSWORD="adminadmin"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
@@ -141,26 +141,6 @@ 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
|
||||
|
||||
+19
-84
@@ -1,15 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .config import settings
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
from .security import safe_decode_token, TokenError, verify_password
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
|
||||
def _is_expired(expires_at: str | None) -> bool:
|
||||
@@ -26,10 +24,7 @@ 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:
|
||||
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()]
|
||||
@@ -38,67 +33,11 @@ def _extract_client_ip(request: Request) -> str:
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
if request.client and request.client.host:
|
||||
return request.client.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"
|
||||
@@ -183,28 +122,24 @@ def _load_current_user_from_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(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,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
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."""
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
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 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:
|
||||
if not token and not stream_query_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
if token:
|
||||
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
|
||||
return _load_current_user_from_token(token, None)
|
||||
return _load_current_user_from_token(
|
||||
str(stream_query_token),
|
||||
None,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-313
@@ -4,249 +4,6 @@ import time
|
||||
import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
_SERVICE_NAMES = {
|
||||
"JellyseerrClient": "Seerr",
|
||||
"SonarrClient": "Sonarr",
|
||||
"RadarrClient": "Radarr",
|
||||
"BazarrClient": "Bazarr",
|
||||
"ProwlarrClient": "Prowlarr",
|
||||
"JellyfinClient": "Jellyfin",
|
||||
"QBittorrentClient": "qBittorrent",
|
||||
}
|
||||
|
||||
|
||||
def _result_items(result: Any, *keys: str) -> list[Any]:
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
for key in keys:
|
||||
value = result.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
|
||||
|
||||
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
candidates = result if isinstance(result, list) else [result]
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
title = str(candidate.get("title") or candidate.get("name") or "").strip()
|
||||
if title:
|
||||
return title
|
||||
if isinstance(payload, dict):
|
||||
title = str(payload.get("title") or payload.get("name") or "").strip()
|
||||
if title:
|
||||
return title
|
||||
return None
|
||||
|
||||
|
||||
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
|
||||
noun = singular if count == 1 else (plural or f"{singular}s")
|
||||
return f"{count} {noun}"
|
||||
|
||||
|
||||
def _queue_result_message(service: str, result: Any) -> str:
|
||||
records = _result_items(result, "records", "items")
|
||||
total = result.get("totalRecords") if isinstance(result, dict) else None
|
||||
count = int(total) if isinstance(total, int) else len(records)
|
||||
if count == 0:
|
||||
return f"{service} has no matching downloads in its queue."
|
||||
first = next((item for item in records if isinstance(item, dict)), None)
|
||||
progress_text = ""
|
||||
if first:
|
||||
size = first.get("size")
|
||||
size_left = first.get("sizeleft")
|
||||
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
|
||||
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
|
||||
progress_text = f" The first is {progress}% complete."
|
||||
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
|
||||
|
||||
|
||||
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
|
||||
raw_name = str((payload or {}).get("name") or "").strip()
|
||||
names = {
|
||||
"MoviesSearch": "movie search",
|
||||
"SeriesSearch": "series search",
|
||||
"EpisodeSearch": "episode search",
|
||||
"DownloadRelease": "release download",
|
||||
"RefreshMovie": "movie refresh",
|
||||
"RescanMovie": "movie rescan",
|
||||
"RefreshSeries": "series refresh",
|
||||
"RescanSeries": "series rescan",
|
||||
}
|
||||
return names.get(raw_name, "command")
|
||||
|
||||
|
||||
def _operation_result_message(
|
||||
service: str,
|
||||
method: str,
|
||||
path: str,
|
||||
result: Any,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
|
||||
normalized_method = method.upper()
|
||||
title = _result_title(result, payload)
|
||||
title_text = f' "{title}"' if title else ""
|
||||
|
||||
if service == "Seerr":
|
||||
if normalized_path.endswith("/request") and normalized_method == "POST":
|
||||
request_id = result.get("id") if isinstance(result, dict) else None
|
||||
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
|
||||
return f"Seerr created the request{suffix} and passed it into the collection workflow."
|
||||
if "/request/" in normalized_path and normalized_method == "GET":
|
||||
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
|
||||
status = result.get("status") if isinstance(result, dict) else None
|
||||
status_text = status_names.get(status)
|
||||
return (
|
||||
f"Seerr found the request; it is currently {status_text}."
|
||||
if status_text
|
||||
else "Seerr found the request and returned its current status."
|
||||
)
|
||||
|
||||
if service in {"Radarr", "Sonarr"}:
|
||||
media_name = "movie" if service == "Radarr" else "series"
|
||||
media_path = "/movie" if service == "Radarr" else "/series"
|
||||
if "/queue" in normalized_path and normalized_method == "GET":
|
||||
return _queue_result_message(service, result)
|
||||
if "/command" in normalized_path and normalized_method == "POST":
|
||||
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
|
||||
if "/release" in normalized_path:
|
||||
if normalized_method == "GET":
|
||||
count = len(_result_items(result, "records", "items"))
|
||||
return (
|
||||
f"{service} found {_count_message(count, 'download option')}."
|
||||
if count
|
||||
else f"{service} could not find a suitable download option."
|
||||
)
|
||||
return f"{service} accepted the selected release and sent it to the download client."
|
||||
if "/qualityprofile" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} returned {_count_message(count, 'available quality profile')}."
|
||||
if "/rootfolder" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} returned {_count_message(count, 'configured library location')}."
|
||||
if "/indexer" in normalized_path and normalized_method == "GET":
|
||||
count = len(_result_items(result))
|
||||
return f"{service} reports {_count_message(count, 'configured search source')}."
|
||||
if service == "Sonarr" and "/episodefile" in normalized_path:
|
||||
if normalized_method == "DELETE":
|
||||
return "Sonarr removed the existing episode file so it can be replaced."
|
||||
count = len(_result_items(result))
|
||||
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
|
||||
if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
|
||||
return "Sonarr marked the selected episodes as wanted."
|
||||
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
|
||||
episodes = _result_items(result)
|
||||
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
|
||||
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
|
||||
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
|
||||
return "Radarr removed the existing movie file so it can be replaced."
|
||||
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
|
||||
if is_media_endpoint:
|
||||
if normalized_method == "GET":
|
||||
found = bool(result) if not isinstance(result, list) else len(result) > 0
|
||||
return (
|
||||
f"{service} found{title_text} in its library list."
|
||||
if found
|
||||
else f"This {media_name} is not currently in {service}."
|
||||
)
|
||||
if normalized_method == "POST":
|
||||
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
|
||||
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
|
||||
search_text = " and started looking for a download" if search_requested else ""
|
||||
subject = title_text or f" the {media_name}"
|
||||
return f"{service} added{subject}{search_text}."
|
||||
if normalized_method == "PUT":
|
||||
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
|
||||
if "/system/status" in normalized_path:
|
||||
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
|
||||
return f"Connected to {service}{f' version {version}' if version else ''}."
|
||||
|
||||
if service == "Prowlarr":
|
||||
if "/health" in normalized_path:
|
||||
issues = _result_items(result)
|
||||
if not issues:
|
||||
return "Prowlarr reports that all configured indexers are healthy."
|
||||
first = next((item for item in issues if isinstance(item, dict)), {})
|
||||
detail = str(first.get("message") or first.get("source") or "").strip()
|
||||
suffix = f" First issue: {detail}" if detail else ""
|
||||
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
|
||||
if "/search" in normalized_path:
|
||||
results = _result_items(result, "results", "records")
|
||||
return (
|
||||
f"Prowlarr found {_count_message(len(results), 'possible release')}."
|
||||
if results
|
||||
else "Prowlarr did not find any possible releases."
|
||||
)
|
||||
|
||||
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
|
||||
target = "movie" if "/movies/" in normalized_path else "selected episode"
|
||||
language = str((params or {}).get("language") or "the requested language").upper()
|
||||
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
|
||||
|
||||
if normalized_method == "GET":
|
||||
return f"{service} completed the check successfully."
|
||||
if normalized_method == "POST":
|
||||
return f"{service} accepted the request and started processing it."
|
||||
if normalized_method == "PUT":
|
||||
return f"{service} saved the requested changes."
|
||||
if normalized_method == "DELETE":
|
||||
return f"{service} confirmed the item was removed."
|
||||
return f"{service} completed the request successfully."
|
||||
|
||||
|
||||
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
|
||||
explanations = {
|
||||
400: "rejected the request because some details were invalid",
|
||||
401: "rejected Magent's login details",
|
||||
403: "refused permission for this action",
|
||||
404: "could not find the requested item",
|
||||
409: "reported a conflict, usually because the item already exists",
|
||||
422: "could not use the details Magent supplied",
|
||||
429: "is busy and asked Magent to try again later",
|
||||
500: "encountered an internal error while processing the request",
|
||||
502: "could not reach one of its own dependent services",
|
||||
503: "is temporarily unavailable",
|
||||
504: "did not finish before the request timed out",
|
||||
}
|
||||
explanation = explanations.get(status_code)
|
||||
if explanation:
|
||||
return f"{service} {explanation}."
|
||||
if status_code:
|
||||
return f"{service} could not complete the request (response code {status_code})."
|
||||
return f"Magent could not get a usable response from {service}."
|
||||
|
||||
|
||||
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
|
||||
normalized_path = path.lower()
|
||||
normalized_method = method.upper()
|
||||
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
|
||||
return "Reading the request from Seerr…", "Seerr returned the current request record"
|
||||
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
|
||||
return "Checking Radarr for the movie…", "Radarr returned the movie record"
|
||||
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
|
||||
return "Checking Sonarr for the series…", "Sonarr returned the series record"
|
||||
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
|
||||
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
|
||||
if service == "Sonarr" and "/episode" in normalized_path:
|
||||
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
|
||||
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
||||
return f"Sending a command to {service}…", f"{service} accepted the command"
|
||||
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
||||
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
||||
if service == "Prowlarr" and "/health" in normalized_path:
|
||||
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
|
||||
return f"Contacting {service}…", f"{service} responded"
|
||||
|
||||
|
||||
class ApiClient:
|
||||
@@ -272,24 +29,6 @@ class ApiClient:
|
||||
return f"{payload[:500]}..."
|
||||
return payload
|
||||
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
return await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -297,16 +36,12 @@ class ApiClient:
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
started_at = time.perf_counter()
|
||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||
active_message, _ = _operation_messages(service_name, method, path)
|
||||
operation_event_id = start_remote_call(service_name, active_message)
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
@@ -316,14 +51,13 @@ class ApiClient:
|
||||
sanitize_headers(self.headers()),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
response = await self._send_request(
|
||||
client,
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self.headers(),
|
||||
params=params,
|
||||
payload=payload,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
@@ -334,21 +68,9 @@ class ApiClient:
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
result = response.json() if response.content else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_operation_result_message(
|
||||
service_name,
|
||||
method,
|
||||
path,
|
||||
result,
|
||||
params=params,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
return result
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response = exc.response
|
||||
@@ -362,15 +84,6 @@ class ApiClient:
|
||||
duration_ms,
|
||||
self._response_summary(response),
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status if isinstance(status, int) else None,
|
||||
message=_operation_error_message(
|
||||
service_name,
|
||||
status if isinstance(status, int) else None,
|
||||
),
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
@@ -380,22 +93,10 @@ class ApiClient:
|
||||
url,
|
||||
duration_ms,
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
message=_operation_error_message(service_name, None),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"GET", path, params=params, timeout_seconds=timeout_seconds
|
||||
)
|
||||
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("GET", path, params=params)
|
||||
|
||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("POST", path, payload=payload)
|
||||
@@ -403,9 +104,5 @@ class ApiClient:
|
||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("PUT", path, payload=payload)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
return await self._request("DELETE", path, params=params)
|
||||
async def delete(self, path: str) -> Optional[Any]:
|
||||
return await self._request("DELETE", path)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class BazarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Any]:
|
||||
return await self._request("GET", "/api/system/status")
|
||||
|
||||
async def search_movie_subtitles(
|
||||
self,
|
||||
radarr_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/movies/subtitles",
|
||||
params={
|
||||
"radarrid": radarr_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search_episode_subtitles(
|
||||
self,
|
||||
series_id: int,
|
||||
episode_id: int,
|
||||
*,
|
||||
language: str,
|
||||
forced: bool = False,
|
||||
) -> Optional[Any]:
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
"/api/episodes/subtitles",
|
||||
params={
|
||||
"seriesid": series_id,
|
||||
"episodeid": episode_id,
|
||||
"language": language,
|
||||
"forced": str(forced).lower(),
|
||||
"hi": "false",
|
||||
},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
@@ -1,24 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _availability_message(result: Any) -> str:
|
||||
if not isinstance(result, dict):
|
||||
return "Jellyfin did not return any matching library items."
|
||||
total = result.get("TotalRecordCount")
|
||||
items = result.get("Items")
|
||||
available = (
|
||||
(isinstance(total, int) and total > 0)
|
||||
or (isinstance(items, list) and len(items) > 0)
|
||||
)
|
||||
return (
|
||||
"The title is available to watch in Jellyfin."
|
||||
if available
|
||||
else "The title is not currently available in Jellyfin."
|
||||
)
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -185,60 +167,18 @@ class JellyfinClient(ApiClient):
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
"IncludeItemTypes": ",".join(item_types or []),
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,MediaSources",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = self._emby_headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_availability_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key or not str(series_id).strip():
|
||||
return []
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"ParentId": str(series_id).strip(),
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Recursive": "true",
|
||||
"Fields": "Path,ProviderIds,MediaSources",
|
||||
"Limit": 10000,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.get(url, headers=self._emby_headers(), params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = payload.get("Items") or payload.get("items") or []
|
||||
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
||||
return response.json()
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
@@ -250,43 +190,12 @@ class JellyfinClient(ApiClient):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Sessions"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("Jellyfin", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyseerrClient(ApiClient):
|
||||
async def _send_request(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]],
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> httpx.Response:
|
||||
request_headers = dict(headers)
|
||||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
|
||||
# Seerr's optional CSRF protection also applies to API-key writes.
|
||||
# Seed its secret/token cookie pair, then echo the readable token in
|
||||
# the header Seerr's own web client uses.
|
||||
csrf_response = await client.get(
|
||||
f"{self.base_url}/api/v1/auth/me",
|
||||
headers=self.headers(),
|
||||
)
|
||||
csrf_response.raise_for_status()
|
||||
csrf_token = client.cookies.get("XSRF-TOKEN")
|
||||
if csrf_token:
|
||||
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
|
||||
parsed_base = urlsplit(self.base_url)
|
||||
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||
return await super()._send_request(
|
||||
client,
|
||||
method,
|
||||
url,
|
||||
headers=request_headers,
|
||||
params=params,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/status")
|
||||
|
||||
@@ -61,15 +26,13 @@ class JellyseerrClient(ApiClient):
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
# Seerr rejects the `+` encoding that standard query builders use for
|
||||
# spaces. Build this query explicitly so multi-word titles are sent as
|
||||
# percent-encoded values.
|
||||
encoded_query = quote(query, safe="")
|
||||
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
|
||||
|
||||
async def get_service_settings(self, media_type: str) -> Optional[Any]:
|
||||
service = "sonarr" if media_type == "tv" else "radarr"
|
||||
return await self.get(f"/api/v1/settings/{service}")
|
||||
return await self.get(
|
||||
"/api/v1/search",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
@@ -78,9 +41,6 @@ class JellyseerrClient(ApiClient):
|
||||
media_id: int,
|
||||
seasons: Optional[list[int]] = None,
|
||||
is_4k: Optional[bool] = None,
|
||||
server_id: Optional[int] = None,
|
||||
profile_id: Optional[int] = None,
|
||||
root_folder: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaType": media_type,
|
||||
@@ -90,12 +50,6 @@ class JellyseerrClient(ApiClient):
|
||||
payload["seasons"] = seasons
|
||||
if isinstance(is_4k, bool):
|
||||
payload["is4k"] = is_4k
|
||||
if isinstance(server_id, int):
|
||||
payload["serverId"] = server_id
|
||||
if isinstance(profile_id, int):
|
||||
payload["profileId"] = profile_id
|
||||
if isinstance(root_folder, str) and root_folder.strip():
|
||||
payload["rootFolder"] = root_folder.strip()
|
||||
return await self.post("/api/v1/request", payload=payload)
|
||||
|
||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -1,59 +1,7 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
def _torrent_state_text(state: Any) -> str:
|
||||
normalized = str(state or "").strip().lower()
|
||||
if "pause" in normalized:
|
||||
return "paused"
|
||||
if "stall" in normalized:
|
||||
return "stalled"
|
||||
if normalized.startswith("queued"):
|
||||
return "waiting in the queue"
|
||||
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
|
||||
return "downloading"
|
||||
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
|
||||
return "finished and seeding"
|
||||
if normalized in {"completed", "missingfiles"}:
|
||||
return "finished" if normalized == "completed" else "missing files"
|
||||
if "error" in normalized:
|
||||
return "in an error state"
|
||||
return "present"
|
||||
|
||||
|
||||
def _torrent_result_message(result: Any) -> str:
|
||||
torrents = result if isinstance(result, list) else []
|
||||
if not torrents:
|
||||
return "qBittorrent found no matching downloads."
|
||||
first = next((item for item in torrents if isinstance(item, dict)), {})
|
||||
if len(torrents) == 1:
|
||||
name = str(first.get("name") or "the matching download").strip()
|
||||
progress = first.get("progress")
|
||||
progress_text = (
|
||||
f" and {max(0, min(100, round(progress * 100)))}% complete"
|
||||
if isinstance(progress, (int, float))
|
||||
else ""
|
||||
)
|
||||
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
|
||||
active = sum(
|
||||
1
|
||||
for item in torrents
|
||||
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
|
||||
)
|
||||
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
|
||||
|
||||
|
||||
def _torrent_action_message(path: str) -> str:
|
||||
normalized_path = path.lower()
|
||||
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
|
||||
return "qBittorrent accepted the request to resume the download."
|
||||
if normalized_path.endswith("/add"):
|
||||
return "qBittorrent accepted the release and added it to the download queue."
|
||||
return "qBittorrent accepted the requested download action."
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
@@ -75,109 +23,34 @@ class QBittorrentClient(ApiClient):
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip().lower()
|
||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
||||
if response.text.strip().lower() != "ok.":
|
||||
raise RuntimeError("qBittorrent login failed")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_result_message(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
return response.json()
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
return response.text.strip()
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=_torrent_action_message(path),
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=_operation_error_message("qBittorrent", status_code),
|
||||
)
|
||||
raise
|
||||
|
||||
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")
|
||||
@@ -188,9 +61,6 @@ class QBittorrentClient(ApiClient):
|
||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||
|
||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
||||
|
||||
async def get_app_version(self) -> Optional[Any]:
|
||||
return await self._get_text("/api/v2/app/version")
|
||||
|
||||
@@ -203,9 +73,7 @@ class QBittorrentClient(ApiClient):
|
||||
return
|
||||
raise
|
||||
|
||||
async def add_torrent_url(
|
||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
||||
) -> None:
|
||||
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
|
||||
url_host = None
|
||||
if isinstance(url, str) and "://" in url:
|
||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
@@ -217,6 +85,4 @@ class QBittorrentClient(ApiClient):
|
||||
data: Dict[str, Any] = {"urls": url}
|
||||
if category:
|
||||
data["category"] = category
|
||||
if tags:
|
||||
data["tags"] = tags
|
||||
await self._post_form("/api/v2/torrents/add", data=data)
|
||||
|
||||
@@ -9,10 +9,6 @@ class RadarrClient(ApiClient):
|
||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||
|
||||
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||
|
||||
@@ -28,32 +24,12 @@ class RadarrClient(ApiClient):
|
||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
||||
|
||||
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
||||
return await self.get(
|
||||
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
|
||||
)
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
|
||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||
|
||||
async def monitor_movie(
|
||||
self, movie_id: int, monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
movie = await self.get_movie(movie_id)
|
||||
if not isinstance(movie, dict):
|
||||
raise ValueError("Radarr did not return the movie before updating its monitored state")
|
||||
movie["monitored"] = monitored
|
||||
return await self.update_movie(movie)
|
||||
|
||||
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/moviefile/{movie_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_movie(
|
||||
self,
|
||||
tmdb_id: int,
|
||||
@@ -61,15 +37,9 @@ class RadarrClient(ApiClient):
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
search_for_movie: bool = True,
|
||||
title: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Radarr could not resolve a title for this TMDB ID")
|
||||
payload = {
|
||||
"tmdbId": tmdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
|
||||
@@ -9,20 +9,6 @@ class SonarrClient(ApiClient):
|
||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||
|
||||
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
|
||||
if not isinstance(result, list):
|
||||
return None
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
if int(item.get("tvdbId")) == tvdb_id:
|
||||
return item
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return next((item for item in result if isinstance(item, dict)), None)
|
||||
|
||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/series/{series_id}")
|
||||
|
||||
@@ -41,36 +27,12 @@ class SonarrClient(ApiClient):
|
||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||
|
||||
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
|
||||
|
||||
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
|
||||
return await self.get(
|
||||
"/api/v3/release",
|
||||
params={"seriesId": series_id, "seasonNumber": season_number},
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||
|
||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||
|
||||
async def monitor_episodes(
|
||||
self, episode_ids: list[int], monitored: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.put(
|
||||
"/api/v3/episode/monitor",
|
||||
payload={"episodeIds": episode_ids, "monitored": monitored},
|
||||
)
|
||||
|
||||
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
|
||||
return await self.delete(
|
||||
f"/api/v3/episodefile/{episode_file_id}",
|
||||
params={"deleteFromClient": "true"},
|
||||
)
|
||||
|
||||
async def add_series(
|
||||
self,
|
||||
tvdb_id: int,
|
||||
@@ -80,19 +42,16 @@ class SonarrClient(ApiClient):
|
||||
title: Optional[str] = None,
|
||||
search_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
|
||||
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
|
||||
if not resolved_title:
|
||||
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
|
||||
payload = {
|
||||
"tvdbId": tvdb_id,
|
||||
"title": resolved_title,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"seasonFolder": True,
|
||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
return await self.post("/api/v3/series", payload=payload)
|
||||
|
||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+3
-48
@@ -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="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||
auth_rate_limit_window_seconds: int = Field(
|
||||
@@ -34,22 +34,7 @@ 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="", 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")
|
||||
)
|
||||
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
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(
|
||||
@@ -85,15 +70,6 @@ class Settings(BaseSettings):
|
||||
requests_data_source: str = Field(
|
||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||
)
|
||||
issue_confirmation_contact_attempts: int = Field(
|
||||
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
|
||||
)
|
||||
issue_confirmation_interval_value: int = Field(
|
||||
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
|
||||
)
|
||||
issue_confirmation_interval_unit: str = Field(
|
||||
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
|
||||
)
|
||||
artwork_cache_mode: str = Field(
|
||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||
)
|
||||
@@ -119,9 +95,6 @@ class Settings(BaseSettings):
|
||||
site_login_show_signup_link: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
||||
)
|
||||
site_nav_show_requests: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
||||
)
|
||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||
|
||||
magent_application_url: Optional[str] = Field(
|
||||
@@ -148,10 +121,6 @@ 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")
|
||||
)
|
||||
@@ -247,10 +216,6 @@ 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")
|
||||
@@ -305,16 +270,6 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
bazarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
|
||||
)
|
||||
bazarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
|
||||
)
|
||||
bazarr_default_language: str = Field(
|
||||
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
|
||||
)
|
||||
|
||||
prowlarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||
)
|
||||
@@ -333,7 +288,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
discord_webhook_url: Optional[str] = Field(
|
||||
default=None,
|
||||
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
|
||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||
)
|
||||
|
||||
|
||||
+2
-213
@@ -21,8 +21,6 @@ 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:
|
||||
@@ -180,11 +178,6 @@ 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(
|
||||
@@ -400,27 +393,6 @@ def init_db() -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portal_item_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
|
||||
ON portal_item_activity (item_id, created_at ASC, id ASC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||
@@ -439,16 +411,12 @@ def init_db() -> None:
|
||||
ON requests_cache (updated_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
|
||||
@@ -712,18 +680,6 @@ def save_snapshot(snapshot: Snapshot) -> None:
|
||||
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
latest = conn.execute(
|
||||
"""
|
||||
SELECT state, state_reason
|
||||
FROM snapshots
|
||||
WHERE request_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(snapshot.request_id,),
|
||||
).fetchone()
|
||||
if latest and latest[0] == snapshot.state.value and latest[1] == snapshot.state_reason:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
|
||||
@@ -758,7 +714,6 @@ def save_action(
|
||||
|
||||
|
||||
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
bounded_limit = max(1, min(int(limit or 10), 100))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
@@ -768,15 +723,10 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(request_id, min(bounded_limit * 20, 500)),
|
||||
(request_id, limit),
|
||||
).fetchall()
|
||||
results = []
|
||||
previous_signature: tuple[str, Optional[str]] | None = None
|
||||
for row in rows:
|
||||
signature = (row[1], row[2])
|
||||
if signature == previous_signature:
|
||||
continue
|
||||
previous_signature = signature
|
||||
results.append(
|
||||
{
|
||||
"request_id": row[0],
|
||||
@@ -786,8 +736,6 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
|
||||
"payload": json.loads(row[4]),
|
||||
}
|
||||
)
|
||||
if len(results) >= bounded_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
@@ -818,57 +766,8 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
|
||||
return results
|
||||
|
||||
|
||||
def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str, Any]:
|
||||
"""Return the most recent proof that this request reached qBittorrent.
|
||||
|
||||
A current qBittorrent API error is not proof that a download exists. Historical
|
||||
snapshots are used so a torrent that has since been removed can still be
|
||||
described honestly in the UI.
|
||||
"""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at, payload_json
|
||||
FROM snapshots
|
||||
WHERE request_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(request_id, max(1, min(int(limit or 100), 500))),
|
||||
).fetchall()
|
||||
|
||||
for created_at, payload_json in rows:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
timeline = payload.get("timeline") if isinstance(payload, dict) else None
|
||||
if not isinstance(timeline, list):
|
||||
continue
|
||||
for hop in timeline:
|
||||
if not isinstance(hop, dict) or hop.get("service") != "qBittorrent":
|
||||
continue
|
||||
details = hop.get("details") if isinstance(hop.get("details"), dict) else {}
|
||||
torrents = details.get("torrents")
|
||||
if isinstance(torrents, list) and torrents:
|
||||
return {
|
||||
"observed": True,
|
||||
"last_seen_at": created_at,
|
||||
"state": hop.get("status"),
|
||||
"summary": details.get("summary"),
|
||||
"torrents": torrents,
|
||||
}
|
||||
return {
|
||||
"observed": False,
|
||||
"last_seen_at": None,
|
||||
"state": None,
|
||||
"summary": None,
|
||||
"torrents": [],
|
||||
}
|
||||
|
||||
|
||||
def ensure_admin_user() -> None:
|
||||
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||
if not settings.admin_username or not settings.admin_password:
|
||||
return
|
||||
existing = get_user_by_username(settings.admin_username)
|
||||
if existing:
|
||||
@@ -876,14 +775,6 @@ 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,
|
||||
@@ -3519,23 +3410,6 @@ def update_portal_item(
|
||||
return updated
|
||||
|
||||
|
||||
def delete_portal_item(item_id: int) -> bool:
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,))
|
||||
conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,))
|
||||
deleted = conn.execute(
|
||||
"DELETE FROM portal_items WHERE id = ?",
|
||||
(item_id,),
|
||||
).rowcount
|
||||
if deleted:
|
||||
logger.info("portal item deleted id=%s", item_id)
|
||||
return bool(deleted)
|
||||
|
||||
|
||||
def add_portal_comment(
|
||||
item_id: int,
|
||||
*,
|
||||
@@ -3618,91 +3492,6 @@ def list_portal_comments(item_id: int, *, include_internal: bool = True, limit:
|
||||
return [_portal_comment_from_row(row) for row in rows]
|
||||
|
||||
|
||||
def add_portal_item_activity(
|
||||
item_id: int,
|
||||
*,
|
||||
event_type: str,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
message: str,
|
||||
metadata_json: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO portal_item_activity (
|
||||
item_id,
|
||||
event_type,
|
||||
actor_username,
|
||||
actor_role,
|
||||
message,
|
||||
metadata_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
event_type,
|
||||
actor_username,
|
||||
actor_role,
|
||||
message,
|
||||
metadata_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
activity_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||
FROM portal_item_activity
|
||||
WHERE id = ?
|
||||
""",
|
||||
(activity_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("Portal activity could not be loaded after insert.")
|
||||
return {
|
||||
"id": row[0],
|
||||
"item_id": row[1],
|
||||
"event_type": row[2],
|
||||
"actor_username": row[3],
|
||||
"actor_role": row[4],
|
||||
"message": row[5],
|
||||
"metadata_json": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
|
||||
|
||||
def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
|
||||
FROM portal_item_activity
|
||||
WHERE item_id = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(item_id, safe_limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"item_id": row[1],
|
||||
"event_type": row[2],
|
||||
"actor_username": row[3],
|
||||
"actor_role": row[4],
|
||||
"message": row[5],
|
||||
"metadata_json": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_portal_overview() -> Dict[str, Any]:
|
||||
with _connect() as conn:
|
||||
kind_rows = conn.execute(
|
||||
|
||||
+5
-47
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .db import has_admin_user, init_db
|
||||
from .db import init_db
|
||||
from .routers.requests import (
|
||||
router as requests_router,
|
||||
startup_warmup_requests_cache,
|
||||
@@ -25,15 +25,7 @@ from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.portal import router as portal_router
|
||||
from .routers.operations import router as operations_router
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .services.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.operation_progress import (
|
||||
begin_operation,
|
||||
finish_operation,
|
||||
normalize_operation_id,
|
||||
reset_operation,
|
||||
)
|
||||
from .logging_config import (
|
||||
bind_request_id,
|
||||
configure_logging,
|
||||
@@ -67,14 +59,6 @@ app.add_middleware(
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
||||
operation_token = None
|
||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=request.url.path,
|
||||
)
|
||||
request.state.request_id = request_id
|
||||
started_at = time.perf_counter()
|
||||
body = await request.body()
|
||||
@@ -117,9 +101,6 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request.url.path,
|
||||
duration_ms,
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(operation_id, success=False, status_code=500)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
raise
|
||||
|
||||
@@ -149,13 +130,6 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
}
|
||||
),
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(
|
||||
operation_id,
|
||||
success=response.status_code < 400,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
return response
|
||||
|
||||
@@ -191,15 +165,13 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
||||
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
if str(settings.jwt_secret or "").strip() == "change-me":
|
||||
logger.warning(
|
||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
||||
"security configuration warning: JWT_SECRET is still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
if str(settings.admin_password or "") == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
@@ -207,17 +179,6 @@ 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(
|
||||
@@ -231,7 +192,6 @@ 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,
|
||||
@@ -256,7 +216,6 @@ async def startup() -> None:
|
||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
@@ -271,4 +230,3 @@ app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(portal_router)
|
||||
app.include_router(operations_router)
|
||||
|
||||
@@ -35,7 +35,6 @@ class ActionOption(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
risk: str
|
||||
description: Optional[str] = None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
@@ -49,7 +48,6 @@ class Snapshot(BaseModel):
|
||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||
actions: List[ActionOption] = Field(default_factory=list)
|
||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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,7 +20,6 @@ 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,
|
||||
@@ -122,15 +121,6 @@ def _require_recipient_email(value: object) -> str:
|
||||
detail="recipient_email is required and must be a valid email address",
|
||||
)
|
||||
|
||||
|
||||
def _optional_recipient_email(value: object) -> Optional[str]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"magent_ssl_certificate_pem",
|
||||
"magent_ssl_private_key_pem",
|
||||
@@ -144,7 +134,6 @@ SENSITIVE_KEYS = {
|
||||
"jellyfin_api_key",
|
||||
"sonarr_api_key",
|
||||
"radarr_api_key",
|
||||
"bazarr_api_key",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_password",
|
||||
}
|
||||
@@ -160,17 +149,10 @@ URL_SETTING_KEYS = {
|
||||
"jellyfin_public_url",
|
||||
"sonarr_base_url",
|
||||
"radarr_base_url",
|
||||
"bazarr_base_url",
|
||||
"prowlarr_base_url",
|
||||
"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",
|
||||
@@ -227,9 +209,6 @@ SETTING_KEYS: List[str] = [
|
||||
"radarr_quality_profile_id",
|
||||
"radarr_root_folder",
|
||||
"radarr_qbittorrent_category",
|
||||
"bazarr_base_url",
|
||||
"bazarr_api_key",
|
||||
"bazarr_default_language",
|
||||
"prowlarr_base_url",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_base_url",
|
||||
@@ -248,9 +227,6 @@ SETTING_KEYS: List[str] = [
|
||||
"requests_cleanup_time",
|
||||
"requests_cleanup_days",
|
||||
"requests_data_source",
|
||||
"issue_confirmation_contact_attempts",
|
||||
"issue_confirmation_interval_value",
|
||||
"issue_confirmation_interval_unit",
|
||||
"site_banner_enabled",
|
||||
"site_banner_message",
|
||||
"site_banner_tone",
|
||||
@@ -258,7 +234,6 @@ SETTING_KEYS: List[str] = [
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
]
|
||||
|
||||
|
||||
@@ -678,38 +653,12 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
changed_keys.append(key)
|
||||
continue
|
||||
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
||||
if key == "issue_confirmation_contact_attempts":
|
||||
try:
|
||||
attempts = int(value_to_store)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
|
||||
if attempts < 0 or attempts > 10:
|
||||
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
|
||||
value_to_store = str(attempts)
|
||||
if key == "issue_confirmation_interval_value":
|
||||
try:
|
||||
interval_value = int(value_to_store)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
|
||||
if interval_value < 1 or interval_value > 365:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
|
||||
value_to_store = str(interval_value)
|
||||
if key == "issue_confirmation_interval_unit":
|
||||
value_to_store = value_to_store.lower()
|
||||
if value_to_store not in {"days", "weeks", "months"}:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
||||
if key in URL_SETTING_KEYS and value_to_store:
|
||||
try:
|
||||
value_to_store = _normalize_service_url(value_to_store)
|
||||
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)
|
||||
@@ -1358,35 +1307,6 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
|
||||
return {"status": "ok", "username": username, "role": role}
|
||||
|
||||
|
||||
@router.post("/users/{username}/email")
|
||||
async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
|
||||
email = _optional_recipient_email(payload.get("email"))
|
||||
if email:
|
||||
duplicate = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in get_all_users()
|
||||
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
|
||||
|
||||
if not set_user_email(username, email):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
refreshed = get_user_by_username(username)
|
||||
logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
|
||||
return {"status": "ok", "user": refreshed, "email": email}
|
||||
|
||||
|
||||
@router.post("/users/{username}/auto-search")
|
||||
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||
@@ -1733,34 +1653,9 @@ async def get_invites() -> Dict[str, Any]:
|
||||
results = []
|
||||
for invite in invites:
|
||||
profile = profiles.get(invite.get("profile_id"))
|
||||
if not invite.get("enabled"):
|
||||
operational_state = "disabled"
|
||||
state_label = "Disabled"
|
||||
attention_reason = "This invite has been switched off."
|
||||
elif invite.get("is_expired"):
|
||||
operational_state = "expired"
|
||||
state_label = "Expired"
|
||||
attention_reason = "The invite has passed its expiry date."
|
||||
elif invite.get("remaining_uses") == 0:
|
||||
operational_state = "exhausted"
|
||||
state_label = "Fully used"
|
||||
attention_reason = "Every permitted sign-up has been used."
|
||||
elif invite.get("profile_id") is not None and (
|
||||
profile is None or profile.get("is_active") is False
|
||||
):
|
||||
operational_state = "profile_unavailable"
|
||||
state_label = "Profile unavailable"
|
||||
attention_reason = "The assigned profile is missing or disabled."
|
||||
else:
|
||||
operational_state = "ready"
|
||||
state_label = "Ready to use"
|
||||
attention_reason = None
|
||||
results.append(
|
||||
{
|
||||
**invite,
|
||||
"operational_state": operational_state,
|
||||
"state_label": state_label,
|
||||
"attention_reason": attention_reason,
|
||||
"profile": (
|
||||
{
|
||||
"id": profile.get("id"),
|
||||
@@ -1771,16 +1666,7 @@ async def get_invites() -> Dict[str, Any]:
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"invites": results,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
|
||||
"attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
|
||||
"used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
|
||||
"with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
|
||||
},
|
||||
}
|
||||
return {"invites": results}
|
||||
|
||||
|
||||
@router.get("/invites/policy")
|
||||
@@ -1965,10 +1851,8 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
if send_email and not recipient_email:
|
||||
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = create_signup_invite(
|
||||
@@ -2038,10 +1922,8 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
if send_email and not recipient_email:
|
||||
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = update_signup_invite(
|
||||
|
||||
+41
-131
@@ -7,7 +7,7 @@ import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
@@ -17,7 +17,6 @@ from ..db import (
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
get_users_by_username_ci,
|
||||
get_all_users,
|
||||
set_user_password,
|
||||
set_user_jellyseerr_id,
|
||||
set_user_email,
|
||||
@@ -48,15 +47,8 @@ from ..security import (
|
||||
verify_password,
|
||||
)
|
||||
from ..security import create_stream_token
|
||||
from ..auth import (
|
||||
clear_auth_cookies,
|
||||
get_current_user,
|
||||
normalize_user_auth_provider,
|
||||
resolve_user_auth_provider,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
||||
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,
|
||||
@@ -103,27 +95,7 @@ def _require_recipient_email(value: object) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _optional_recipient_email(value: object) -> str | None:
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
return _require_recipient_email(value)
|
||||
|
||||
|
||||
def _optional_account_email(value: object) -> str | None:
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Enter a valid email address.",
|
||||
)
|
||||
|
||||
|
||||
def _auth_client_ip(request: Request) -> str:
|
||||
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()
|
||||
@@ -386,15 +358,6 @@ 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"),
|
||||
@@ -617,11 +580,7 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=local username=%s client=%s",
|
||||
@@ -670,19 +629,15 @@ async def login(
|
||||
user["role"],
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": user["username"], "role": user["role"]},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user["username"], "role": user["role"]},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=jellyfin username=%s client=%s",
|
||||
@@ -713,13 +668,13 @@ async def jellyfin_login(
|
||||
canonical_username,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
try:
|
||||
auth_response = await client.authenticate_by_name(username, password)
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=jellyfin username=%s client=%s",
|
||||
@@ -727,7 +682,7 @@ async def jellyfin_login(
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
if not preferred_match:
|
||||
@@ -769,20 +724,16 @@ async def jellyfin_login(
|
||||
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seerr/login")
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=seerr username=%s client=%s",
|
||||
@@ -794,7 +745,7 @@ async def jellyseerr_login(
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
|
||||
try:
|
||||
auth_response = await client.login_local(form_data.username, form_data.password)
|
||||
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",
|
||||
@@ -802,11 +753,11 @@ async def jellyseerr_login(
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(auth_response, dict):
|
||||
if not isinstance(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(auth_response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(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
|
||||
@@ -840,11 +791,11 @@ async def jellyseerr_login(
|
||||
jellyseerr_user_id,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -852,12 +803,6 @@ 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(
|
||||
@@ -887,7 +832,7 @@ async def invite_details(code: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict, response: Response) -> dict:
|
||||
async def signup(payload: dict) -> 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()
|
||||
@@ -963,14 +908,14 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
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(auth_response, dict) or not auth_response.get("User"):
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
@@ -1042,17 +987,17 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
@@ -1178,37 +1123,6 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.put("/profile/email")
|
||||
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
|
||||
email = _optional_account_email(payload.get("email"))
|
||||
if email:
|
||||
duplicate = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in get_all_users()
|
||||
if str(candidate.get("username") or "").casefold() != username.casefold()
|
||||
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="That email address is already assigned to another account.",
|
||||
)
|
||||
|
||||
if not set_user_email(username, email):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email))
|
||||
return {"status": "ok", "email": email}
|
||||
|
||||
|
||||
@router.get("/profile/invites")
|
||||
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
@@ -1260,10 +1174,8 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
send_email = bool(payload.get("send_email"))
|
||||
recipient_email = _optional_recipient_email(recipient_email)
|
||||
if send_email and not recipient_email:
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
@@ -1352,10 +1264,8 @@ async def update_profile_invite(
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
send_email = bool(payload.get("send_email"))
|
||||
recipient_email = _optional_recipient_email(recipient_email)
|
||||
if send_email and not recipient_email:
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import get_current_user_event_stream
|
||||
from . import requests as requests_router
|
||||
from .status import services_status
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
@@ -84,7 +85,9 @@ async def events_stream(
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_recent_signature: Optional[str] = None
|
||||
last_services_signature: Optional[str] = None
|
||||
next_recent_at = 0.0
|
||||
next_services_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
@@ -126,6 +129,27 @@ async def events_stream(
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if now >= next_services_at:
|
||||
next_services_at = now + 30.0
|
||||
try:
|
||||
status_payload = await services_status()
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status_payload,
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_services_signature:
|
||||
last_services_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
|
||||
@@ -3,7 +3,6 @@ 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)])
|
||||
@@ -18,10 +17,6 @@ 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"}:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..services.operation_progress import get_operation
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/operations",
|
||||
tags=["operations"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{operation_id}")
|
||||
async def operation_status(operation_id: str) -> dict:
|
||||
operation = get_operation(operation_id)
|
||||
if not operation:
|
||||
raise HTTPException(status_code=404, detail="Operation not found")
|
||||
return operation
|
||||
@@ -1,35 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..db import (
|
||||
add_portal_item_activity,
|
||||
add_portal_comment,
|
||||
count_portal_items,
|
||||
create_portal_item,
|
||||
delete_portal_item,
|
||||
get_portal_item,
|
||||
get_portal_overview,
|
||||
list_portal_comments,
|
||||
list_portal_item_activity,
|
||||
list_portal_items,
|
||||
update_portal_item,
|
||||
)
|
||||
from ..services.issue_resolution import (
|
||||
begin_issue_confirmation,
|
||||
issue_resolution_state,
|
||||
respond_to_issue_confirmation,
|
||||
)
|
||||
from ..services.notifications import send_portal_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +33,6 @@ PORTAL_STATUSES = {
|
||||
"done",
|
||||
"declined",
|
||||
"closed",
|
||||
"awaiting_confirmation",
|
||||
# Seerr-style request pipeline statuses
|
||||
"pending",
|
||||
"approved",
|
||||
@@ -68,11 +55,6 @@ PORTAL_MEDIA_STATUSES = {
|
||||
PORTAL_ISSUE_TYPES = {
|
||||
"general",
|
||||
"playback",
|
||||
"transcode",
|
||||
"service_unavailable",
|
||||
"broken_media",
|
||||
"wrong_content",
|
||||
"audio",
|
||||
"subtitle",
|
||||
"quality",
|
||||
"metadata",
|
||||
@@ -80,9 +62,6 @@ PORTAL_ISSUE_TYPES = {
|
||||
"other",
|
||||
}
|
||||
|
||||
_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
|
||||
_MEDIA_STATUS_CACHE_SECONDS = 15.0
|
||||
|
||||
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
|
||||
"pending": {"pending", "approved", "declined"},
|
||||
"approved": {"approved", "declined"},
|
||||
@@ -260,97 +239,6 @@ def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
|
||||
return "Approved"
|
||||
|
||||
|
||||
ISSUE_WORKFLOW_STAGES = (
|
||||
("reported", "Reported"),
|
||||
("review", "Under review"),
|
||||
("planned", "Fix planned"),
|
||||
("repair", "Fix underway"),
|
||||
("confirmation", "Confirm fix"),
|
||||
("resolved", "Resolved"),
|
||||
)
|
||||
|
||||
ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = {
|
||||
"new": (
|
||||
0,
|
||||
"Issue received",
|
||||
"Your report has been logged and is waiting for the support team to review it.",
|
||||
"active",
|
||||
),
|
||||
"triaging": (
|
||||
1,
|
||||
"Being investigated",
|
||||
"The support team is checking the report and identifying the right fix.",
|
||||
"active",
|
||||
),
|
||||
"planned": (
|
||||
2,
|
||||
"Fix ready to begin",
|
||||
"The problem has been reviewed and the next action has been selected.",
|
||||
"active",
|
||||
),
|
||||
"in_progress": (
|
||||
3,
|
||||
"Fix in progress",
|
||||
"Work is underway on the affected content or service.",
|
||||
"active",
|
||||
),
|
||||
"blocked": (
|
||||
3,
|
||||
"Fix needs attention",
|
||||
"Work has paused because the support team needs another service, resource, or decision before continuing.",
|
||||
"attention",
|
||||
),
|
||||
"awaiting_confirmation": (
|
||||
4,
|
||||
"Waiting for confirmation",
|
||||
"A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.",
|
||||
"active",
|
||||
),
|
||||
"done": (
|
||||
5,
|
||||
"Issue resolved",
|
||||
"The reported problem has been fixed and the issue is complete.",
|
||||
"complete",
|
||||
),
|
||||
"closed": (
|
||||
5,
|
||||
"Issue resolved",
|
||||
"The reported problem has been fixed and the issue is closed.",
|
||||
"complete",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _issue_workflow_payload(status: Any) -> Dict[str, Any]:
|
||||
normalized_status = str(status or "new").strip().lower()
|
||||
stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get(
|
||||
normalized_status,
|
||||
ISSUE_STATUS_TO_STAGE["new"],
|
||||
)
|
||||
steps = []
|
||||
for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES):
|
||||
step_state = (
|
||||
"complete"
|
||||
if index < stage_index or (index == stage_index and state == "complete")
|
||||
else "active"
|
||||
if index == stage_index
|
||||
else "waiting"
|
||||
)
|
||||
if index == stage_index and state == "attention":
|
||||
step_state = "attention"
|
||||
steps.append({"key": key, "label": label, "state": step_state})
|
||||
return {
|
||||
"current_step": stage_index + 1,
|
||||
"total_steps": len(ISSUE_WORKFLOW_STAGES),
|
||||
"stage": ISSUE_WORKFLOW_STAGES[stage_index][0],
|
||||
"stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1],
|
||||
"headline": headline,
|
||||
"message": message,
|
||||
"state": state,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_request_pipeline(
|
||||
request_status: Optional[str],
|
||||
media_status: Optional[str],
|
||||
@@ -451,36 +339,6 @@ def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
|
||||
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
|
||||
|
||||
|
||||
def _public_media_status_payload(
|
||||
*,
|
||||
status: str,
|
||||
headline: str,
|
||||
message: str,
|
||||
latency_ms: Optional[int] = None,
|
||||
version: Optional[str] = None,
|
||||
restart_pending: Optional[bool] = None,
|
||||
active_streams: Optional[int] = None,
|
||||
transcoding_streams: Optional[int] = None,
|
||||
session_check_available: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"headline": headline,
|
||||
"message": message,
|
||||
"latency_ms": latency_ms,
|
||||
"server": {
|
||||
"version": version,
|
||||
"restart_pending": restart_pending,
|
||||
},
|
||||
"activity": {
|
||||
"active_streams": active_streams,
|
||||
"transcoding_streams": transcoding_streams,
|
||||
"available": session_check_available,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
is_admin = _is_admin(user)
|
||||
is_owner = _is_owner(user, item)
|
||||
@@ -489,13 +347,7 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
||||
"can_edit": is_admin or is_owner,
|
||||
"can_comment": True,
|
||||
"can_moderate": is_admin,
|
||||
"can_delete": is_admin and str(item.get("kind") or "").lower() == "issue",
|
||||
"can_raise_issue": str(item.get("kind") or "") == "request",
|
||||
"can_confirm_resolution": (
|
||||
str(item.get("kind") or "").lower() == "issue"
|
||||
and str(item.get("status") or "").lower() == "awaiting_confirmation"
|
||||
and (is_admin or is_owner)
|
||||
),
|
||||
}
|
||||
kind = str(item.get("kind") or "").strip().lower()
|
||||
if kind == "request":
|
||||
@@ -507,85 +359,15 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
||||
"is_terminal": media_status in {"available", "failed"} or request_status == "declined",
|
||||
}
|
||||
elif kind == "issue":
|
||||
resolution = issue_resolution_state(item)
|
||||
serialized["issue"] = {
|
||||
"issue_type": _clean_text(item.get("issue_type")) or "general",
|
||||
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
||||
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
||||
"resolved_at": _clean_text(item.get("issue_resolved_at")),
|
||||
"workflow": _issue_workflow_payload(item.get("status")),
|
||||
"confirmation": {
|
||||
"status": resolution.get("status"),
|
||||
"attempts_sent": int(resolution.get("attemptsSent") or 0),
|
||||
"maximum_attempts": int(resolution.get("maximumAttempts") or 0),
|
||||
"last_contact_at": resolution.get("lastContactAt"),
|
||||
"next_contact_at": resolution.get("nextContactAt"),
|
||||
"interval_value": resolution.get("intervalValue"),
|
||||
"interval_unit": resolution.get("intervalUnit"),
|
||||
"last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
|
||||
},
|
||||
}
|
||||
return serialized
|
||||
|
||||
|
||||
def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]:
|
||||
activity = [
|
||||
entry
|
||||
for entry in list_portal_item_activity(int(item["id"]), limit=300)
|
||||
if include_internal or entry.get("event_type") != "internal_note_added"
|
||||
]
|
||||
if not any(entry.get("event_type") == "item_created" for entry in activity):
|
||||
activity.insert(
|
||||
0,
|
||||
{
|
||||
"id": f"created-{item['id']}",
|
||||
"item_id": item["id"],
|
||||
"event_type": "item_created",
|
||||
"actor_username": item.get("created_by_username") or "unknown",
|
||||
"actor_role": "user",
|
||||
"message": (
|
||||
"Issue raised and added to the support queue."
|
||||
if str(item.get("kind") or "").lower() == "issue"
|
||||
else "Portal item created."
|
||||
),
|
||||
"metadata_json": None,
|
||||
"created_at": item.get("created_at"),
|
||||
},
|
||||
)
|
||||
if include_internal:
|
||||
return activity
|
||||
public_activity: list[Dict[str, Any]] = []
|
||||
for entry in activity:
|
||||
public_entry = {key: value for key, value in entry.items() if key != "metadata_json"}
|
||||
actor_role = str(entry.get("actor_role") or "user").lower()
|
||||
public_entry["actor_username"] = (
|
||||
"Magent"
|
||||
if actor_role == "system"
|
||||
else "Support team"
|
||||
if actor_role == "admin"
|
||||
else "Reporter"
|
||||
)
|
||||
public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
|
||||
public_activity.append(public_entry)
|
||||
return public_activity
|
||||
|
||||
|
||||
def _record_activity(
|
||||
item_id: int,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str,
|
||||
user: Dict[str, Any],
|
||||
) -> None:
|
||||
add_portal_item_activity(
|
||||
item_id,
|
||||
event_type=event_type,
|
||||
actor_username=str(user.get("username") or "unknown"),
|
||||
actor_role=str(user.get("role") or "user"),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
async def _notify(
|
||||
*,
|
||||
event_type: str,
|
||||
@@ -624,122 +406,6 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
|
||||
}
|
||||
|
||||
|
||||
@router.get("/issues/media-status")
|
||||
async def portal_media_status() -> Dict[str, Any]:
|
||||
"""Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
|
||||
now = time.monotonic()
|
||||
cached_payload = _MEDIA_STATUS_CACHE.get("payload")
|
||||
if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
|
||||
return cached_payload
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not jellyfin.configured():
|
||||
payload = _public_media_status_payload(
|
||||
status="not_configured",
|
||||
headline="Media server status is unavailable",
|
||||
message="Magent cannot run a playback check right now. Your report can still be submitted.",
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
system_info = await jellyfin.get_system_info()
|
||||
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
payload = _public_media_status_payload(
|
||||
status="down",
|
||||
headline="The media server is not responding",
|
||||
message=(
|
||||
"This looks broader than one title. The report will include the failed server check "
|
||||
"so an administrator can investigate the service first."
|
||||
),
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
except Exception:
|
||||
logger.exception("guided issue Jellyfin system check failed")
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
payload = _public_media_status_payload(
|
||||
status="down",
|
||||
headline="The media server check failed",
|
||||
message="Your report can still be submitted and will include this failed service check.",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||
info = system_info if isinstance(system_info, dict) else {}
|
||||
version_value = info.get("Version")
|
||||
version = str(version_value).strip() if version_value is not None else None
|
||||
restart_pending = bool(info.get("HasPendingRestart"))
|
||||
|
||||
session_check_available = False
|
||||
active_streams: Optional[int] = None
|
||||
transcoding_streams: Optional[int] = None
|
||||
try:
|
||||
sessions = await jellyfin.get_sessions()
|
||||
if isinstance(sessions, list):
|
||||
session_check_available = True
|
||||
active_streams = sum(
|
||||
1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
|
||||
)
|
||||
transcoding_streams = sum(
|
||||
1
|
||||
for session in sessions
|
||||
if isinstance(session, dict)
|
||||
and session.get("NowPlayingItem")
|
||||
and session.get("TranscodingInfo")
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
|
||||
|
||||
if restart_pending:
|
||||
status = "degraded"
|
||||
headline = "Media server is online but needs attention"
|
||||
message = "Jellyfin is responding, but it reports that a restart is pending."
|
||||
elif session_check_available and active_streams:
|
||||
status = "up"
|
||||
headline = "Media server is online and actively streaming"
|
||||
message = (
|
||||
"Other playback is currently working, so this is more likely specific to the title, "
|
||||
"audio track, subtitle, client, or transcode path."
|
||||
)
|
||||
else:
|
||||
status = "up"
|
||||
headline = "Media server is online"
|
||||
message = "Jellyfin responded normally. Continue with the report if playback is still failing."
|
||||
|
||||
payload = _public_media_status_payload(
|
||||
status=status,
|
||||
headline=headline,
|
||||
message=message,
|
||||
latency_ms=latency_ms,
|
||||
version=version,
|
||||
restart_pending=restart_pending,
|
||||
active_streams=active_streams,
|
||||
transcoding_streams=transcoding_streams,
|
||||
session_check_available=session_check_available,
|
||||
)
|
||||
_MEDIA_STATUS_CACHE.update(
|
||||
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/items")
|
||||
async def portal_list_items(
|
||||
kind: Optional[str] = None,
|
||||
@@ -955,16 +621,6 @@ async def portal_create_item(
|
||||
priority=priority or "normal",
|
||||
assignee_username=assignee_username,
|
||||
)
|
||||
_record_activity(
|
||||
int(created["id"]),
|
||||
event_type="item_created",
|
||||
message=(
|
||||
"Issue raised and added to the support queue."
|
||||
if created.get("kind") == "issue"
|
||||
else f"{str(created.get('kind') or 'Portal item').capitalize()} created."
|
||||
),
|
||||
user=current_user,
|
||||
)
|
||||
initial_comment = _clean_text(payload.get("comment"))
|
||||
if initial_comment:
|
||||
add_portal_comment(
|
||||
@@ -984,7 +640,6 @@ async def portal_create_item(
|
||||
return {
|
||||
"item": _serialize_item(created, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@@ -1037,12 +692,6 @@ async def portal_create_issue_for_request(
|
||||
priority=priority or "normal",
|
||||
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
|
||||
)
|
||||
_record_activity(
|
||||
int(created["id"]),
|
||||
event_type="item_created",
|
||||
message=f"Issue raised and linked to collection request #{item_id}.",
|
||||
user=current_user,
|
||||
)
|
||||
initial_comment = _clean_text(payload.get("comment"))
|
||||
if initial_comment:
|
||||
add_portal_comment(
|
||||
@@ -1062,7 +711,6 @@ async def portal_create_issue_for_request(
|
||||
return {
|
||||
"item": _serialize_item(created, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
|
||||
"linked_request_id": item_id,
|
||||
}
|
||||
|
||||
@@ -1175,33 +823,9 @@ async def portal_get_item(
|
||||
return {
|
||||
"item": _serialize_item(item, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(item, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}")
|
||||
async def portal_delete_item(
|
||||
item_id: int,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
if not _is_admin(current_user):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
item = get_portal_item(item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
if str(item.get("kind") or "").lower() != "issue":
|
||||
raise HTTPException(status_code=400, detail="Only issues can be deleted here")
|
||||
if not delete_portal_item(item_id):
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
logger.info(
|
||||
"portal issue deleted id=%s title=%s actor=%s",
|
||||
item_id,
|
||||
item.get("title"),
|
||||
current_user.get("username"),
|
||||
)
|
||||
return {"status": "deleted", "item_id": item_id}
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}")
|
||||
async def portal_update_item(
|
||||
item_id: int,
|
||||
@@ -1213,7 +837,6 @@ async def portal_update_item(
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
is_admin = _is_admin(current_user)
|
||||
is_owner = _is_owner(current_user, item)
|
||||
item_kind = str(item.get("kind") or "").lower()
|
||||
if not (is_admin or is_owner):
|
||||
raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
|
||||
|
||||
@@ -1263,7 +886,7 @@ async def portal_update_item(
|
||||
if "external_ref" in payload:
|
||||
updates["external_ref"] = _clean_text(payload.get("external_ref"))
|
||||
if is_admin:
|
||||
kind = item_kind
|
||||
kind = str(item.get("kind") or "").lower()
|
||||
if "priority" in payload:
|
||||
updates["priority"] = _normalize_choice(
|
||||
payload.get("priority"),
|
||||
@@ -1353,9 +976,9 @@ async def portal_update_item(
|
||||
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
|
||||
if "status" in payload:
|
||||
next_status = str(updates.get("status") or item.get("status") or "").lower()
|
||||
if next_status == "closed":
|
||||
if next_status in {"done", "closed"}:
|
||||
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
|
||||
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
|
||||
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
|
||||
updates.setdefault("issue_resolved_at", None)
|
||||
|
||||
if not updates:
|
||||
@@ -1363,47 +986,14 @@ async def portal_update_item(
|
||||
return {
|
||||
"item": _serialize_item(item, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(item, include_internal=is_admin),
|
||||
}
|
||||
|
||||
updated = update_portal_item(item_id, **updates)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Portal item not found")
|
||||
|
||||
requested_issue_status = str(updates.get("status") or "").lower()
|
||||
should_start_confirmation = item_kind == "issue" and (
|
||||
(requested_issue_status == "done" and str(item.get("status") or "").lower() != "done")
|
||||
or (
|
||||
requested_issue_status == "awaiting_confirmation"
|
||||
and str(item.get("status") or "").lower() != "awaiting_confirmation"
|
||||
)
|
||||
)
|
||||
if should_start_confirmation:
|
||||
try:
|
||||
updated = await begin_issue_confirmation(
|
||||
item_id,
|
||||
actor_username=str(current_user.get("username") or "unknown"),
|
||||
actor_role=str(current_user.get("role") or "admin"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
|
||||
if changed_fields:
|
||||
if item_kind == "issue" and not should_start_confirmation:
|
||||
old_status = str(item.get("status") or "unknown").replace("_", " ")
|
||||
new_status = str(updated.get("status") or "unknown").replace("_", " ")
|
||||
activity_message = (
|
||||
f"Status changed from {old_status} to {new_status}."
|
||||
if item.get("status") != updated.get("status")
|
||||
else f"Issue details updated: {', '.join(sorted(changed_fields))}."
|
||||
)
|
||||
_record_activity(
|
||||
item_id,
|
||||
event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated",
|
||||
message=activity_message,
|
||||
user=current_user,
|
||||
)
|
||||
await _notify(
|
||||
event_type="portal_item_updated",
|
||||
item=updated,
|
||||
@@ -1414,42 +1004,6 @@ async def portal_update_item(
|
||||
return {
|
||||
"item": _serialize_item(updated, current_user),
|
||||
"comments": comments,
|
||||
"activity": _activity_payload(updated, include_internal=is_admin),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/issues/{item_id}/resolution-response")
|
||||
async def portal_issue_resolution_response(
|
||||
item_id: int,
|
||||
payload: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
if not (_is_admin(current_user) or _is_owner(current_user, item)):
|
||||
raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution")
|
||||
if not isinstance(payload.get("resolved"), bool):
|
||||
raise HTTPException(status_code=400, detail="resolved must be true or false")
|
||||
try:
|
||||
updated = respond_to_issue_confirmation(
|
||||
item_id,
|
||||
resolved=payload["resolved"],
|
||||
actor_username=str(current_user.get("username") or "unknown"),
|
||||
actor_role=str(current_user.get("role") or "user"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await _notify(
|
||||
event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected",
|
||||
item=updated,
|
||||
user=current_user,
|
||||
note="resolved=true" if payload["resolved"] else "resolved=false",
|
||||
)
|
||||
return {
|
||||
"item": _serialize_item(updated, current_user),
|
||||
"comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)),
|
||||
"activity": _activity_payload(updated, include_internal=_is_admin(current_user)),
|
||||
}
|
||||
|
||||
|
||||
@@ -1491,17 +1045,6 @@ async def portal_create_comment(
|
||||
message=message,
|
||||
is_internal=is_internal,
|
||||
)
|
||||
if str(item.get("kind") or "").lower() == "issue":
|
||||
_record_activity(
|
||||
item_id,
|
||||
event_type="internal_note_added" if is_internal else "comment_added",
|
||||
message=(
|
||||
f"Internal troubleshooting note: {message[:240]}"
|
||||
if is_internal
|
||||
else f"Support update: {message[:240]}"
|
||||
),
|
||||
user=current_user,
|
||||
)
|
||||
updated_item = get_portal_item(item_id)
|
||||
if updated_item:
|
||||
await _notify(
|
||||
|
||||
+164
-1519
File diff suppressed because it is too large
Load Diff
@@ -30,9 +30,6 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||
},
|
||||
"navigation": {
|
||||
"showRequests": bool(runtime.site_nav_show_requests),
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (CHANGELOG or "").strip()
|
||||
|
||||
@@ -2,17 +2,16 @@ from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..auth import get_current_user
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.bazarr import BazarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
@@ -27,42 +26,12 @@ 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()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -91,13 +60,6 @@ async def services_status() -> Dict[str, Any]:
|
||||
radarr.get_system_status,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.get_system_status,
|
||||
)
|
||||
)
|
||||
prowlarr_status = await _check(
|
||||
"Prowlarr",
|
||||
prowlarr.configured(),
|
||||
@@ -109,7 +71,13 @@ 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))
|
||||
services.append(
|
||||
await _check(
|
||||
"qBittorrent",
|
||||
qbittorrent.configured(),
|
||||
qbittorrent.get_app_version,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
@@ -133,7 +101,6 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
@@ -154,18 +121,11 @@ 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),
|
||||
"bazarr": (
|
||||
"Bazarr",
|
||||
bazarr.configured() and bool(runtime.bazarr_api_key),
|
||||
bazarr.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")
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ _INT_FIELDS = {
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_cleanup_days",
|
||||
"issue_confirmation_contact_attempts",
|
||||
"issue_confirmation_interval_value",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_BOOL_FIELDS = {
|
||||
@@ -41,7 +39,6 @@ _BOOL_FIELDS = {
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
}
|
||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@ 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")
|
||||
@@ -57,8 +55,6 @@ 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,7 +17,6 @@ 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
|
||||
|
||||
@@ -98,12 +97,7 @@ 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."
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
|
||||
return True, "ok"
|
||||
return False, "Discord webhook URL is required."
|
||||
|
||||
@@ -119,12 +113,7 @@ 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."
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_webhook_url):
|
||||
return True, "ok"
|
||||
return False, "Generic webhook URL is required."
|
||||
|
||||
@@ -134,21 +123,11 @@ 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":
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
|
||||
return True, "ok"
|
||||
return False, "ntfy requires a base URL and topic."
|
||||
if provider == "gotify":
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
|
||||
return True, "ok"
|
||||
return False, "Gotify requires a base URL and app token."
|
||||
if provider == "pushover":
|
||||
@@ -156,12 +135,7 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
return True, "ok"
|
||||
return False, "Pushover requires an application token and user key."
|
||||
if provider == "webhook":
|
||||
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)
|
||||
if _clean_text(runtime.magent_notify_push_base_url):
|
||||
return True, "ok"
|
||||
return False, "Webhook relay requires a target URL."
|
||||
if provider == "telegram":
|
||||
@@ -216,7 +190,6 @@ 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()
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html import escape
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..config import settings as env_settings
|
||||
from ..db import (
|
||||
add_portal_item_activity,
|
||||
get_portal_item,
|
||||
get_user_by_username,
|
||||
list_portal_item_activity,
|
||||
list_portal_items,
|
||||
update_portal_item,
|
||||
)
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import resolve_user_delivery_email, send_generic_email
|
||||
from .snapshot import build_snapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SYSTEM_USER = "Magent"
|
||||
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = item.get("metadata_json")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = _metadata(item).get("resolutionConfirmation")
|
||||
return dict(state) if isinstance(state, dict) else {}
|
||||
|
||||
|
||||
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
|
||||
metadata = _metadata(item)
|
||||
metadata["resolutionConfirmation"] = state
|
||||
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _interval_delta(value: int, unit: str) -> timedelta:
|
||||
safe_value = max(1, min(int(value), 365))
|
||||
normalized_unit = str(unit or "days").strip().lower()
|
||||
if normalized_unit == "weeks":
|
||||
return timedelta(weeks=safe_value)
|
||||
if normalized_unit == "months":
|
||||
return timedelta(days=30 * safe_value)
|
||||
return timedelta(days=safe_value)
|
||||
|
||||
|
||||
def _workflow_settings() -> tuple[int, int, str]:
|
||||
runtime = get_runtime_settings()
|
||||
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
|
||||
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
|
||||
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
|
||||
if interval_unit not in {"days", "weeks", "months"}:
|
||||
interval_unit = "days"
|
||||
return attempts, interval_value, interval_unit
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
runtime = get_runtime_settings()
|
||||
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate.rstrip("/")
|
||||
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
|
||||
|
||||
|
||||
def _issue_url(item_id: int) -> str:
|
||||
return f"{_app_url()}/portal/issues?item={item_id}"
|
||||
|
||||
|
||||
def _activity(
|
||||
item_id: int,
|
||||
event_type: str,
|
||||
message: str,
|
||||
*,
|
||||
actor_username: str = _SYSTEM_USER,
|
||||
actor_role: str = "system",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
add_portal_item_activity(
|
||||
item_id,
|
||||
event_type=event_type,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
message=message,
|
||||
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
|
||||
)
|
||||
|
||||
|
||||
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = entry.get("metadata_json")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
|
||||
activity = list_portal_item_activity(item_id, limit=500)
|
||||
for entry in reversed(activity):
|
||||
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
|
||||
continue
|
||||
tracking = _activity_metadata(entry).get("repairTracking")
|
||||
if isinstance(tracking, dict):
|
||||
return dict(tracking), activity
|
||||
return {}, activity
|
||||
|
||||
|
||||
def _positive_ints(value: Any) -> list[int]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
int(item)
|
||||
for item in value
|
||||
if isinstance(item, int) and not isinstance(item, bool) and item > 0
|
||||
]
|
||||
|
||||
|
||||
def _media_signature(item: Any) -> Dict[str, str]:
|
||||
if not isinstance(item, dict):
|
||||
return {}
|
||||
result: Dict[str, str] = {}
|
||||
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, (dict, list)):
|
||||
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
elif value is not None and str(value).strip():
|
||||
result[key] = str(value).strip()
|
||||
return result
|
||||
|
||||
|
||||
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
|
||||
previous = _media_signature(baseline)
|
||||
if not previous:
|
||||
return True
|
||||
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
|
||||
|
||||
|
||||
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
|
||||
request_id = str(tracking.get("requestId") or "").strip()
|
||||
action_id = str(tracking.get("actionId") or "").strip()
|
||||
media_type = str(tracking.get("mediaType") or "").strip().lower()
|
||||
collector_id = tracking.get("collectorId")
|
||||
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
|
||||
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
|
||||
|
||||
snapshot = await build_snapshot(request_id)
|
||||
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
|
||||
arr = raw.get("arr") if isinstance(raw.get("arr"), dict) else {}
|
||||
arr_item = arr.get("item") if isinstance(arr, dict) else None
|
||||
jellyfin = raw.get("jellyfin") if isinstance(raw.get("jellyfin"), dict) else {}
|
||||
jellyfin_item = jellyfin.get("item") if isinstance(jellyfin, dict) else None
|
||||
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
|
||||
baselines = tracking.get("jellyfinBaseline")
|
||||
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
|
||||
found_at_start = tracking.get("jellyfinFoundAtStart") is True
|
||||
|
||||
if media_type == "movie":
|
||||
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
|
||||
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
|
||||
imported = isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
|
||||
if not imported:
|
||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
|
||||
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
|
||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
|
||||
current_signature = _media_signature(jellyfin_item)
|
||||
if action_id == "replace_media" and found_at_start:
|
||||
if not baselines or not _signature_changed(current_signature, baselines[0]):
|
||||
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
|
||||
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
|
||||
|
||||
target_rows = tracking.get("episodes")
|
||||
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
|
||||
target_ids = {
|
||||
int(item["id"])
|
||||
for item in targets
|
||||
if isinstance(item.get("id"), int) and int(item["id"]) > 0
|
||||
}
|
||||
target_pairs = {
|
||||
(int(item["seasonNumber"]), int(item["episodeNumber"]))
|
||||
for item in targets
|
||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
||||
}
|
||||
if not target_ids or not target_pairs:
|
||||
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
episodes = await sonarr.get_episodes(collector_id)
|
||||
episode_map = {
|
||||
int(item["id"]): item
|
||||
for item in episodes
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||
} if isinstance(episodes, list) else {}
|
||||
imported = all(
|
||||
episode_id in episode_map
|
||||
and (
|
||||
episode_map[episode_id].get("hasFile") is True
|
||||
or (
|
||||
isinstance(episode_map[episode_id].get("episodeFileId"), int)
|
||||
and episode_map[episode_id]["episodeFileId"] > 0
|
||||
)
|
||||
)
|
||||
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
|
||||
for episode_id in target_ids
|
||||
)
|
||||
if not imported:
|
||||
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
|
||||
|
||||
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
|
||||
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
|
||||
current_by_pair = {
|
||||
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
|
||||
for item in jellyfin_episodes
|
||||
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
|
||||
}
|
||||
if not all(pair in current_by_pair for pair in target_pairs):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
|
||||
if action_id == "replace_media" and found_at_start:
|
||||
baseline_by_pair = {
|
||||
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
|
||||
for item in baselines
|
||||
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
|
||||
}
|
||||
if any(pair not in baseline_by_pair for pair in target_pairs):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
|
||||
if not all(
|
||||
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
|
||||
for pair in target_pairs
|
||||
):
|
||||
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
|
||||
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
|
||||
|
||||
|
||||
def _close_issue(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
reason: str,
|
||||
confirmed: bool,
|
||||
actor_username: str = _SYSTEM_USER,
|
||||
actor_role: str = "system",
|
||||
) -> Dict[str, Any]:
|
||||
now = _now().isoformat()
|
||||
state = issue_resolution_state(item)
|
||||
state.update(
|
||||
{
|
||||
"status": "confirmed" if confirmed else "auto_closed",
|
||||
"confirmedAt": now if confirmed else state.get("confirmedAt"),
|
||||
"closedAt": now,
|
||||
"nextContactAt": None,
|
||||
"closedReason": reason,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
int(item["id"]),
|
||||
status="closed",
|
||||
issue_resolved_at=now,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue could not be closed")
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"resolution_confirmed" if confirmed else "issue_auto_closed",
|
||||
reason,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
maximum, interval_value, interval_unit = _workflow_settings()
|
||||
state = issue_resolution_state(item)
|
||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
||||
if maximum <= 0:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason="Issue closed automatically because reporter confirmation emails are disabled.",
|
||||
confirmed=False,
|
||||
)
|
||||
if attempts >= maximum:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
|
||||
confirmed=False,
|
||||
)
|
||||
|
||||
attempt_number = attempts + 1
|
||||
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
|
||||
recipient = resolve_user_delivery_email(reporter)
|
||||
issue_url = _issue_url(int(item["id"]))
|
||||
sent = False
|
||||
delivery_error: Optional[str] = None
|
||||
if recipient:
|
||||
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
|
||||
body_text = (
|
||||
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
|
||||
f"Issue: {item.get('title') or 'Untitled issue'}\n"
|
||||
f"Confirmation request: {attempt_number} of {maximum}\n\n"
|
||||
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
|
||||
"If you do not respond, Magent will close the issue automatically after the configured confirmation period."
|
||||
)
|
||||
body_html = (
|
||||
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
|
||||
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
|
||||
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
|
||||
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
|
||||
f'Confirmation request {attempt_number} of {maximum}</p>'
|
||||
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
|
||||
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
|
||||
'</div>'
|
||||
)
|
||||
try:
|
||||
await send_generic_email(
|
||||
recipient_email=recipient,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
sent = True
|
||||
except Exception as exc:
|
||||
delivery_error = str(exc)
|
||||
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
|
||||
else:
|
||||
delivery_error = "No email address is stored for the reporter."
|
||||
|
||||
now = _now()
|
||||
state.update(
|
||||
{
|
||||
"status": "awaiting_confirmation",
|
||||
"attemptsSent": attempt_number,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": now.isoformat(),
|
||||
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"lastDeliverySucceeded": sent,
|
||||
"lastDeliveryError": delivery_error,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
int(item["id"]),
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation schedule could not be saved")
|
||||
if sent:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
|
||||
else:
|
||||
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"confirmation_email_sent" if sent else "confirmation_email_failed",
|
||||
message,
|
||||
metadata={
|
||||
"attempt": attempt_number,
|
||||
"maximum": maximum,
|
||||
"nextContactAt": state["nextContactAt"],
|
||||
"deliveryError": delivery_error,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def begin_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
now = _now().isoformat()
|
||||
maximum, interval_value, interval_unit = _workflow_settings()
|
||||
state = {
|
||||
"status": "awaiting_confirmation",
|
||||
"startedAt": now,
|
||||
"attemptsSent": 0,
|
||||
"maximumAttempts": maximum,
|
||||
"lastContactAt": None,
|
||||
"nextContactAt": now,
|
||||
"intervalValue": interval_value,
|
||||
"intervalUnit": interval_unit,
|
||||
"confirmedAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="awaiting_confirmation",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue confirmation workflow could not be started")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_proposed",
|
||||
"The issue was marked fixed and sent to the reporter for confirmation.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
|
||||
)
|
||||
return await _contact_reporter(updated)
|
||||
|
||||
|
||||
def respond_to_issue_confirmation(
|
||||
item_id: int,
|
||||
*,
|
||||
resolved: bool,
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
) -> Dict[str, Any]:
|
||||
item = get_portal_item(item_id)
|
||||
if not item or str(item.get("kind") or "").lower() != "issue":
|
||||
raise ValueError("Issue not found")
|
||||
if str(item.get("status") or "").lower() != "awaiting_confirmation":
|
||||
raise ValueError("This issue is not waiting for resolution confirmation")
|
||||
if resolved:
|
||||
return _close_issue(
|
||||
item,
|
||||
reason="The reporter confirmed that the issue is fixed.",
|
||||
confirmed=True,
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
|
||||
now = _now().isoformat()
|
||||
state = issue_resolution_state(item)
|
||||
state.update(
|
||||
{
|
||||
"status": "reported_still_broken",
|
||||
"reporterResponseAt": now,
|
||||
"nextContactAt": None,
|
||||
"closedAt": None,
|
||||
}
|
||||
)
|
||||
updated = update_portal_item(
|
||||
item_id,
|
||||
status="in_progress",
|
||||
issue_resolved_at=None,
|
||||
metadata_json=_metadata_with_resolution(item, state),
|
||||
)
|
||||
if not updated:
|
||||
raise RuntimeError("Issue could not be reopened")
|
||||
_activity(
|
||||
item_id,
|
||||
"resolution_rejected",
|
||||
"The reporter said the issue is still happening. The issue was returned to In progress.",
|
||||
actor_username=actor_username,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def process_active_media_repairs() -> Dict[str, int]:
|
||||
items = list_portal_items(kind="issue", status="in_progress", limit=500)
|
||||
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
|
||||
for item in items:
|
||||
tracking, activity = _repair_tracking(int(item["id"]))
|
||||
if not tracking:
|
||||
continue
|
||||
result["checked"] += 1
|
||||
try:
|
||||
evidence = await _media_repair_evidence(tracking)
|
||||
if evidence.get("complete"):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_verified",
|
||||
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
await begin_issue_confirmation(
|
||||
int(item["id"]),
|
||||
actor_username=_SYSTEM_USER,
|
||||
actor_role="system",
|
||||
)
|
||||
result["completed"] += 1
|
||||
continue
|
||||
|
||||
result["waiting"] += 1
|
||||
if evidence.get("phase") == "indexing" and not any(
|
||||
str(entry.get("event_type") or "") == "repair_imported"
|
||||
for entry in activity
|
||||
):
|
||||
_activity(
|
||||
int(item["id"]),
|
||||
"repair_imported",
|
||||
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
|
||||
metadata={
|
||||
"requestId": tracking.get("requestId"),
|
||||
"actionId": tracking.get("actionId"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
current = (now or _now()).astimezone(timezone.utc)
|
||||
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
|
||||
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
|
||||
maximum, _, _ = _workflow_settings()
|
||||
for item in items:
|
||||
state = issue_resolution_state(item)
|
||||
due_at = _parse_datetime(state.get("nextContactAt"))
|
||||
if due_at and due_at > current:
|
||||
continue
|
||||
try:
|
||||
attempts = max(0, int(state.get("attemptsSent") or 0))
|
||||
if maximum <= 0 or attempts >= maximum:
|
||||
_close_issue(
|
||||
item,
|
||||
reason=(
|
||||
"Issue closed automatically because reporter confirmation emails are disabled."
|
||||
if maximum <= 0
|
||||
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
|
||||
),
|
||||
confirmed=False,
|
||||
)
|
||||
result["closed"] += 1
|
||||
else:
|
||||
await _contact_reporter(item)
|
||||
result["contacted"] += 1
|
||||
except Exception:
|
||||
result["failed"] += 1
|
||||
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def run_issue_confirmation_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
repair_result = await process_active_media_repairs()
|
||||
if repair_result["completed"] or repair_result["failed"]:
|
||||
logger.info("automatic media repair sweep complete result=%s", repair_result)
|
||||
result = await process_due_issue_confirmations()
|
||||
if result["contacted"] or result["closed"] or result["failed"]:
|
||||
logger.info("issue confirmation sweep complete result=%s", result)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("issue confirmation sweep failed")
|
||||
await asyncio.sleep(60)
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
@@ -50,7 +49,6 @@ 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()
|
||||
@@ -117,7 +115,6 @@ 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:
|
||||
@@ -127,7 +124,6 @@ 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)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
||||
_OPERATION_TTL_SECONDS = 15 * 60
|
||||
_MAX_OPERATIONS = 500
|
||||
_MAX_EVENTS = 60
|
||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
||||
"magent_operation_id", default=None
|
||||
)
|
||||
_operations: Dict[str, Dict[str, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
||||
|
||||
|
||||
def _prune_locked(now_monotonic: float) -> None:
|
||||
expired = [
|
||||
operation_id
|
||||
for operation_id, operation in _operations.items()
|
||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
||||
]
|
||||
for operation_id in expired:
|
||||
_operations.pop(operation_id, None)
|
||||
if len(_operations) <= _MAX_OPERATIONS:
|
||||
return
|
||||
oldest = sorted(
|
||||
_operations,
|
||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
||||
)
|
||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
||||
_operations.pop(operation_id, None)
|
||||
|
||||
|
||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
||||
now_monotonic = time.monotonic()
|
||||
now_iso = _now_iso()
|
||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
||||
with _lock:
|
||||
_prune_locked(now_monotonic)
|
||||
_operations[operation_id] = {
|
||||
"id": operation_id,
|
||||
"label": normalized_label,
|
||||
"path": path,
|
||||
"status": "running",
|
||||
"started_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"updated_monotonic": now_monotonic,
|
||||
"duration_ms": None,
|
||||
"events": [
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete",
|
||||
"message": "Magent received the action.",
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
return _current_operation_id.set(operation_id)
|
||||
|
||||
|
||||
def reset_operation(token: Token) -> None:
|
||||
_current_operation_id.reset(token)
|
||||
|
||||
|
||||
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id:
|
||||
return None
|
||||
event_id = uuid.uuid4().hex
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return None
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": event_id,
|
||||
"service": service,
|
||||
"state": "active",
|
||||
"message": message or f"Contacting {service}…",
|
||||
"started_at": now_iso,
|
||||
"finished_at": None,
|
||||
"duration_ms": None,
|
||||
"status_code": None,
|
||||
"started_monotonic": now_monotonic,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
return event_id
|
||||
|
||||
|
||||
def finish_remote_call(
|
||||
event_id: Optional[str],
|
||||
*,
|
||||
success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> None:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id or not event_id:
|
||||
return
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
event = next(
|
||||
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
||||
None,
|
||||
)
|
||||
if not event:
|
||||
return
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "complete" if success else "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["status_code"] = status_code
|
||||
event["message"] = message or (
|
||||
f"{event['service']} responded successfully."
|
||||
if success
|
||||
else f"{event['service']} returned an error."
|
||||
)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
|
||||
|
||||
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
for event in operation["events"]:
|
||||
if event.get("state") == "active":
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
||||
started = datetime.fromisoformat(str(operation["started_at"]))
|
||||
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
||||
operation["status"] = "complete" if success else "error"
|
||||
operation["status_code"] = status_code
|
||||
operation["duration_ms"] = round(duration_ms, 1)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete" if success else "error",
|
||||
"message": (
|
||||
"Magent finished processing the action."
|
||||
if success
|
||||
else "Magent could not complete the action."
|
||||
),
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": status_code,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
|
||||
|
||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = normalize_operation_id(operation_id)
|
||||
if not normalized:
|
||||
return None
|
||||
with _lock:
|
||||
operation = _operations.get(normalized)
|
||||
if not operation:
|
||||
return None
|
||||
result = deepcopy(operation)
|
||||
result.pop("updated_monotonic", None)
|
||||
for event in result.get("events", []):
|
||||
event.pop("started_monotonic", None)
|
||||
return result
|
||||
@@ -15,10 +15,8 @@ from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..db import (
|
||||
save_snapshot,
|
||||
get_recent_actions,
|
||||
get_request_cache_payload,
|
||||
get_request_cache_by_id,
|
||||
get_request_download_evidence,
|
||||
get_recent_snapshots,
|
||||
get_setting,
|
||||
set_setting,
|
||||
@@ -32,8 +30,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
||||
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
||||
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
|
||||
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
|
||||
|
||||
|
||||
STATUS_LABELS = {
|
||||
@@ -62,22 +58,6 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
|
||||
"""Use the collector's authoritative identity when cached Seerr metadata is sparse."""
|
||||
if not isinstance(arr_item, dict):
|
||||
return
|
||||
if snapshot.title in {None, "", "Unknown"}:
|
||||
title = arr_item.get("title") or arr_item.get("seriesTitle")
|
||||
if isinstance(title, str) and title.strip():
|
||||
snapshot.title = title.strip()
|
||||
if not snapshot.year:
|
||||
year = arr_item.get("year")
|
||||
try:
|
||||
snapshot.year = int(year) if year else snapshot.year
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_media_title(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
@@ -226,20 +206,7 @@ async def _get_seerr_media_details(
|
||||
|
||||
|
||||
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
||||
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
|
||||
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
|
||||
collector_has_file = bool(
|
||||
isinstance(collector_item, dict)
|
||||
and (
|
||||
collector_item.get("hasFile")
|
||||
or snapshot.request_type == RequestType.tv
|
||||
and isinstance(collector_stats, dict)
|
||||
and collector_stats.get("episodeFileCount")
|
||||
)
|
||||
)
|
||||
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
|
||||
snapshot.state == NormalizedState.importing and collector_has_file
|
||||
):
|
||||
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
|
||||
return
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
@@ -255,9 +222,8 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
||||
pass
|
||||
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
|
||||
if previous:
|
||||
previous_payload = previous[0].get("payload") or {}
|
||||
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
|
||||
if previous_jellyfin.get("found"):
|
||||
prev_state = previous[0].get("state")
|
||||
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
|
||||
return
|
||||
try:
|
||||
await client.refresh_library()
|
||||
@@ -334,43 +300,6 @@ def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
|
||||
return grouped
|
||||
|
||||
|
||||
def _episode_availability(episodes: Any) -> Dict[str, Any]:
|
||||
if not isinstance(episodes, list):
|
||||
return {"available": 0, "missing": 0, "total": 0, "seasons": []}
|
||||
now = datetime.now(timezone.utc)
|
||||
season_rows: Dict[int, Dict[str, Any]] = {}
|
||||
for episode in episodes:
|
||||
if not isinstance(episode, dict) or not episode.get("monitored", True):
|
||||
continue
|
||||
air_date = episode.get("airDateUtc")
|
||||
if isinstance(air_date, str):
|
||||
try:
|
||||
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
aired_at = None
|
||||
if aired_at and aired_at > now:
|
||||
continue
|
||||
season_number = episode.get("seasonNumber")
|
||||
if not isinstance(season_number, int):
|
||||
continue
|
||||
row = season_rows.setdefault(
|
||||
season_number,
|
||||
{"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
|
||||
)
|
||||
row["total"] += 1
|
||||
if episode.get("hasFile"):
|
||||
row["available"] += 1
|
||||
else:
|
||||
row["missing"] += 1
|
||||
seasons = [season_rows[key] for key in sorted(season_rows)]
|
||||
return {
|
||||
"available": sum(int(row["available"]) for row in seasons),
|
||||
"missing": sum(int(row["missing"]) for row in seasons),
|
||||
"total": sum(int(row["total"]) for row in seasons),
|
||||
"seasons": seasons,
|
||||
}
|
||||
|
||||
|
||||
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not torrents:
|
||||
return {"state": "idle", "message": "0 active downloads."}
|
||||
@@ -415,546 +344,6 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st
|
||||
return f"https://image.tmdb.org/t/p/{size}{path}"
|
||||
|
||||
|
||||
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
|
||||
progress = torrent.get("progress")
|
||||
try:
|
||||
numeric = float(progress)
|
||||
except (TypeError, ValueError):
|
||||
numeric = -1
|
||||
if 0 <= numeric <= 1:
|
||||
return round(numeric * 100, 1)
|
||||
try:
|
||||
size = float(torrent.get("size"))
|
||||
amount_left = float(torrent.get("amount_left"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if size <= 0:
|
||||
return None
|
||||
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
|
||||
|
||||
|
||||
def _parse_action_time(value: Any) -> Optional[datetime]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
|
||||
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
for action in get_recent_actions(request_id, 25):
|
||||
if action.get("action_id") not in REPAIR_ACTION_IDS:
|
||||
continue
|
||||
created_at = _parse_action_time(action.get("created_at"))
|
||||
if created_at is None:
|
||||
continue
|
||||
age_seconds = (current_time - created_at).total_seconds()
|
||||
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
|
||||
return action
|
||||
return None
|
||||
|
||||
|
||||
def _build_repair_activity(
|
||||
snapshot: Snapshot,
|
||||
*,
|
||||
action: Optional[Dict[str, Any]],
|
||||
arr_state: str,
|
||||
arr_details: Dict[str, Any],
|
||||
download: Dict[str, Any],
|
||||
jellyfin_found: bool,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not action:
|
||||
return None
|
||||
|
||||
action_id = str(action.get("action_id") or "")
|
||||
collector = (
|
||||
"Bazarr"
|
||||
if action_id == "repair_subtitles"
|
||||
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
|
||||
)
|
||||
action_ok = str(action.get("status") or "").lower() == "ok"
|
||||
action_message = str(action.get("message") or "The repair action was recorded.")
|
||||
download_state = str(download.get("state") or "not_started")
|
||||
download_visible = bool(download.get("visible"))
|
||||
availability = arr_details.get("availability")
|
||||
if not isinstance(availability, dict):
|
||||
availability = {}
|
||||
missing = int(availability.get("missing") or 0)
|
||||
total = int(availability.get("total") or 0)
|
||||
collection_complete = arr_state == "available" and (
|
||||
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
|
||||
)
|
||||
|
||||
submitted_step = {
|
||||
"id": "submitted",
|
||||
"label": "Repair requested",
|
||||
"state": "complete",
|
||||
"detail": "Magent recorded the issue and started the selected repair.",
|
||||
}
|
||||
|
||||
if not action_ok:
|
||||
return {
|
||||
"visible": True,
|
||||
"actionId": action_id,
|
||||
"state": "attention",
|
||||
"headline": "Repair needs attention",
|
||||
"message": action_message,
|
||||
"service": collector,
|
||||
"updatedAt": action.get("created_at"),
|
||||
"steps": [
|
||||
submitted_step,
|
||||
{
|
||||
"id": "collector",
|
||||
"label": f"{collector} hand-off",
|
||||
"state": "attention",
|
||||
"detail": action_message,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if action_id == "repair_subtitles":
|
||||
return {
|
||||
"visible": True,
|
||||
"actionId": action_id,
|
||||
"state": "searching",
|
||||
"headline": "Subtitle repair is running",
|
||||
"message": (
|
||||
f"{action_message} Bazarr is checking the configured subtitle providers; "
|
||||
"the issue can be confirmed once the replacement track is available."
|
||||
),
|
||||
"service": collector,
|
||||
"updatedAt": action.get("created_at"),
|
||||
"steps": [
|
||||
submitted_step,
|
||||
{
|
||||
"id": "collector",
|
||||
"label": "Bazarr accepted the search",
|
||||
"state": "complete",
|
||||
"detail": action_message,
|
||||
},
|
||||
{
|
||||
"id": "result",
|
||||
"label": "Subtitle result",
|
||||
"state": "active",
|
||||
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if collection_complete:
|
||||
headline = "Repair collected"
|
||||
message = (
|
||||
f"{collector} now reports the replacement file as collected. "
|
||||
+ (
|
||||
"It is also available in Grizzlyflix."
|
||||
if jellyfin_found
|
||||
else "Grizzlyflix is indexing the updated file now."
|
||||
)
|
||||
)
|
||||
state = "complete" if jellyfin_found else "indexing"
|
||||
download_step_state = "complete"
|
||||
download_step_detail = f"{collector} reports the replacement file as collected and imported."
|
||||
available_step_state = "complete" if jellyfin_found else "active"
|
||||
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
|
||||
state = {
|
||||
"downloading": "downloading",
|
||||
"completed": "importing",
|
||||
"paused": "attention",
|
||||
"error": "attention",
|
||||
"missing": "attention",
|
||||
}[download_state]
|
||||
headline = {
|
||||
"downloading": "Replacement download in progress",
|
||||
"completed": "Replacement downloaded — waiting for import",
|
||||
"paused": "Replacement download paused",
|
||||
"error": "Replacement download cannot be checked",
|
||||
"missing": "Replacement hand-off needs checking",
|
||||
}[download_state]
|
||||
message = {
|
||||
"downloading": "The replacement is downloading now.",
|
||||
"paused": "The replacement download is paused and needs attention.",
|
||||
"completed": f"The download has finished and is waiting for {collector} to import it.",
|
||||
"error": "Magent cannot currently read the replacement download from qBittorrent.",
|
||||
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
|
||||
}[download_state]
|
||||
download_step_state = "active" if download_state == "downloading" else (
|
||||
"complete" if download_state == "completed" else "attention"
|
||||
)
|
||||
download_step_detail = str(
|
||||
download.get("summary") or "Magent found the replacement download in qBittorrent."
|
||||
)
|
||||
available_step_state = "waiting"
|
||||
else:
|
||||
state = "searching"
|
||||
headline = "Replacement search in progress"
|
||||
message = (
|
||||
f"{action_message} {collector} has accepted the search, but no replacement download "
|
||||
"has been selected yet. Magent will keep checking."
|
||||
)
|
||||
download_step_state = "waiting"
|
||||
download_step_detail = "Waiting for a suitable release to be selected."
|
||||
available_step_state = "waiting"
|
||||
|
||||
return {
|
||||
"visible": True,
|
||||
"actionId": action_id,
|
||||
"state": state,
|
||||
"headline": headline,
|
||||
"message": message,
|
||||
"service": collector,
|
||||
"updatedAt": action.get("created_at"),
|
||||
"steps": [
|
||||
submitted_step,
|
||||
{
|
||||
"id": "collector",
|
||||
"label": f"{collector} accepted the search",
|
||||
"state": "complete",
|
||||
"detail": action_message,
|
||||
},
|
||||
{
|
||||
"id": "download",
|
||||
"label": "Replacement download",
|
||||
"state": download_step_state,
|
||||
"detail": download_step_detail,
|
||||
},
|
||||
{
|
||||
"id": "available",
|
||||
"label": "Updated media available",
|
||||
"state": available_step_state,
|
||||
"detail": (
|
||||
"The repaired title is available in Grizzlyflix."
|
||||
if jellyfin_found and collection_complete
|
||||
else (
|
||||
"The media server is indexing the replacement."
|
||||
if collection_complete
|
||||
else "Waiting for download and import to finish."
|
||||
)
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_presentation(
|
||||
snapshot: Snapshot,
|
||||
*,
|
||||
approved: bool,
|
||||
arr_state: str,
|
||||
arr_details: Dict[str, Any],
|
||||
prowlarr_state: str,
|
||||
download: Dict[str, Any],
|
||||
jellyfin_found: bool,
|
||||
jellyfin_link: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
|
||||
noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
|
||||
availability = arr_details.get("availability")
|
||||
if not isinstance(availability, dict):
|
||||
availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
|
||||
available = int(availability.get("available") or 0)
|
||||
missing = int(availability.get("missing") or 0)
|
||||
total = int(availability.get("total") or 0)
|
||||
partial = available > 0 and missing > 0
|
||||
jellyfin_partial = bool(
|
||||
jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0
|
||||
)
|
||||
fully_available = bool(jellyfin_found and not jellyfin_partial)
|
||||
download_visible = bool(download.get("visible"))
|
||||
download_state = str(download.get("state") or "not_started")
|
||||
|
||||
if snapshot.state == NormalizedState.requested:
|
||||
status_label = "Waiting for approval"
|
||||
meaning = "This request has been received, but it must be approved before collection can begin."
|
||||
elif snapshot.state == NormalizedState.needs_add:
|
||||
status_label = "Approved, but not yet in the library queue"
|
||||
meaning = (
|
||||
f"The request was approved, but it has not reached the {collector} collector yet. "
|
||||
"Adding it to the library queue is the next step."
|
||||
)
|
||||
elif jellyfin_partial:
|
||||
status_label = f"Partially available — {available} of {total} episodes collected"
|
||||
meaning = (
|
||||
f"Some of this request is ready to watch. {collector} is still looking for "
|
||||
f"{missing} missing episode{'s' if missing != 1 else ''}."
|
||||
)
|
||||
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||
status_label = "Available to watch"
|
||||
meaning = "Collection is complete and the title is available on the media server."
|
||||
elif download_visible and download_state == "paused":
|
||||
status_label = "Download paused"
|
||||
meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
|
||||
elif download_visible and download_state == "missing":
|
||||
status_label = "Download attempt is no longer visible"
|
||||
meaning = (
|
||||
"A download was previously queued for this request, but qBittorrent no longer reports it. "
|
||||
"A fresh release search may be required."
|
||||
)
|
||||
elif download_visible and download_state == "error":
|
||||
status_label = "Unable to read the current download"
|
||||
meaning = (
|
||||
"A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
|
||||
)
|
||||
elif snapshot.state == NormalizedState.downloading:
|
||||
status_label = "Download in progress"
|
||||
meaning = "A release has been collected and is currently downloading."
|
||||
elif snapshot.state == NormalizedState.importing:
|
||||
if arr_state == "available" and not jellyfin_found:
|
||||
status_label = "Collected — waiting for the media server"
|
||||
meaning = (
|
||||
f"{collector} has collected and imported this title, but it is not visible on "
|
||||
"the media server yet."
|
||||
)
|
||||
else:
|
||||
status_label = "Downloaded — waiting for library import"
|
||||
meaning = f"The download has finished and {collector} is preparing it for the media server."
|
||||
elif arr_state == "error":
|
||||
status_label = "Unable to read the library queue"
|
||||
meaning = (
|
||||
f"The request is approved, but Magent could not read its current state from {collector}. "
|
||||
"The service may be temporarily unavailable."
|
||||
)
|
||||
elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
|
||||
if partial:
|
||||
status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
|
||||
meaning = (
|
||||
f"The request was approved and sent to {collector}. {available} of {total} aired "
|
||||
f"episodes have been collected; {missing} still need a matching release."
|
||||
)
|
||||
elif missing:
|
||||
status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
|
||||
meaning = (
|
||||
f"The request was approved and sent to the {collector} collector, but none of the "
|
||||
f"{total} aired episodes have been collected yet."
|
||||
)
|
||||
else:
|
||||
status_label = "Added to library queue"
|
||||
meaning = f"The request was approved and sent to the {collector} collector."
|
||||
elif arr_state in {"added", "searching"}:
|
||||
status_label = "Added to library queue — waiting for a matching release"
|
||||
meaning = (
|
||||
f"The request was approved and sent to the {collector} collector, but a usable release "
|
||||
"has not been collected yet."
|
||||
)
|
||||
elif snapshot.state == NormalizedState.failed:
|
||||
status_label = "This request needs attention"
|
||||
meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
|
||||
else:
|
||||
status_label = "Approved — preparing collection" if approved else "Request received"
|
||||
meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process."
|
||||
|
||||
action_ids = [action.id for action in snapshot.actions]
|
||||
if fully_available:
|
||||
next_title = "Ready to watch"
|
||||
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
||||
recommended = []
|
||||
elif "resume_torrent" in action_ids:
|
||||
next_title = "Resume the interrupted download"
|
||||
next_description = "The download exists but is not currently progressing. Resume it to continue collection."
|
||||
recommended = ["resume_torrent"]
|
||||
elif "readd_to_arr" in action_ids:
|
||||
next_title = "Add this request to the library queue"
|
||||
next_description = f"Send the approved request to {collector} so collection can begin."
|
||||
recommended = ["readd_to_arr"]
|
||||
elif "search_auto" in action_ids or "search_releases" in action_ids:
|
||||
if snapshot.request_type == RequestType.tv and missing:
|
||||
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
|
||||
else:
|
||||
target = f"a matching {noun} release"
|
||||
next_title = f"Search for {target}"
|
||||
next_description = (
|
||||
"Run an automatic search, or review the available releases and choose one manually."
|
||||
)
|
||||
recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
|
||||
elif download_state == "downloading":
|
||||
next_title = "Let the current download finish"
|
||||
next_description = "Magent is tracking the active download; no action is needed right now."
|
||||
recommended = []
|
||||
elif snapshot.state == NormalizedState.importing and arr_state == "available":
|
||||
next_title = "Wait for the media server to index this title"
|
||||
next_description = (
|
||||
f"{collector} has completed its work. Use Recheck request to see whether the title "
|
||||
"has appeared on the media server."
|
||||
)
|
||||
recommended = []
|
||||
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||
next_title = "Ready to watch"
|
||||
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
||||
recommended = []
|
||||
elif snapshot.state == NormalizedState.requested:
|
||||
next_title = "Wait for approval"
|
||||
next_description = "An administrator must approve this request before collection can start."
|
||||
recommended = []
|
||||
else:
|
||||
next_title = "Magent is checking the next step"
|
||||
next_description = "No safe action is available until the current service state is known."
|
||||
recommended = []
|
||||
|
||||
requested_stage = {
|
||||
"id": "requested",
|
||||
"label": "Requested",
|
||||
"state": "complete",
|
||||
"summary": "Request received",
|
||||
}
|
||||
approved_stage = {
|
||||
"id": "approved",
|
||||
"label": "Approved",
|
||||
"state": "complete" if approved else "active",
|
||||
"summary": "Approved for collection" if approved else "Waiting for approval",
|
||||
}
|
||||
if arr_state == "missing":
|
||||
library_state, library_summary = "attention", "Not yet added to the collector"
|
||||
elif arr_state == "error":
|
||||
library_state, library_summary = "attention", f"Unable to read {collector}"
|
||||
elif partial:
|
||||
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
|
||||
elif arr_state == "available":
|
||||
library_state, library_summary = "complete", "Collection complete"
|
||||
elif arr_state in {"added", "searching"}:
|
||||
library_state = "active" if missing or not available else "complete"
|
||||
library_summary = (
|
||||
f"{missing} episode{'s' if missing != 1 else ''} still missing"
|
||||
if snapshot.request_type == RequestType.tv and missing
|
||||
else "In the library queue"
|
||||
)
|
||||
else:
|
||||
library_state, library_summary = "waiting", "Waiting for collector information"
|
||||
|
||||
if fully_available:
|
||||
search_state, search_summary = "complete", "No further search needed"
|
||||
elif arr_state == "available":
|
||||
search_state, search_summary = "complete", "A release was collected"
|
||||
elif download_visible:
|
||||
search_state = "complete"
|
||||
search_summary = "A release was found"
|
||||
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
|
||||
search_state = "active" if prowlarr_state == "ok" else "attention"
|
||||
search_summary = (
|
||||
f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
|
||||
if snapshot.request_type == RequestType.tv and missing
|
||||
else "Ready to search for a release"
|
||||
)
|
||||
else:
|
||||
search_state, search_summary = "waiting", "Search has not started"
|
||||
|
||||
completed_download_summary = (
|
||||
"The requested content has been collected and is available to watch. "
|
||||
"No further action is needed."
|
||||
)
|
||||
if fully_available:
|
||||
download_stage_state, download_summary = "complete", completed_download_summary
|
||||
pipeline_download_visible = False
|
||||
pipeline_torrents: List[Dict[str, Any]] = []
|
||||
elif arr_state == "available":
|
||||
download_stage_state = "complete"
|
||||
download_summary = f"{collector} has imported the collected file"
|
||||
pipeline_download_visible = False
|
||||
pipeline_torrents = []
|
||||
elif download_visible:
|
||||
download_stage_state = {
|
||||
"downloading": "active",
|
||||
"paused": "attention",
|
||||
"completed": "complete",
|
||||
"missing": "attention",
|
||||
"error": "attention",
|
||||
}.get(download_state, "waiting")
|
||||
download_summary = str(download.get("summary") or "A prior download attempt was found")
|
||||
pipeline_download_visible = True
|
||||
pipeline_torrents = download.get("torrents") or []
|
||||
else:
|
||||
download_stage_state, download_summary = "waiting", "No download attempt yet"
|
||||
pipeline_download_visible = False
|
||||
pipeline_torrents = []
|
||||
|
||||
if jellyfin_partial:
|
||||
available_label = "Partially available"
|
||||
available_state = "partial"
|
||||
available_state_label = "Partly ready"
|
||||
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
|
||||
elif jellyfin_found:
|
||||
available_label = "Available to watch"
|
||||
available_state = "complete"
|
||||
available_state_label = "Ready"
|
||||
available_summary = "This title is ready to watch in Grizzlyflix."
|
||||
elif arr_state == "available":
|
||||
available_label = "Adding to Grizzlyflix"
|
||||
available_state = "active"
|
||||
available_state_label = "Indexing"
|
||||
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
|
||||
else:
|
||||
available_label = "Media server"
|
||||
available_state = "waiting"
|
||||
available_state_label = "Waiting"
|
||||
available_summary = "This title has not reached Grizzlyflix yet."
|
||||
|
||||
display_download = dict(download)
|
||||
if fully_available:
|
||||
display_download.update(
|
||||
{
|
||||
"visible": False,
|
||||
"state": "completed",
|
||||
"summary": completed_download_summary,
|
||||
"torrents": [],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": {"label": status_label, "meaning": meaning},
|
||||
"download": display_download,
|
||||
"nextStep": {
|
||||
"title": next_title,
|
||||
"description": next_description,
|
||||
"actionIds": recommended,
|
||||
},
|
||||
"pipeline": [
|
||||
requested_stage,
|
||||
approved_stage,
|
||||
{
|
||||
"id": "library",
|
||||
"label": "Library collection",
|
||||
"state": library_state,
|
||||
"summary": library_summary,
|
||||
"available": available,
|
||||
"missing": missing,
|
||||
"total": total,
|
||||
"seasons": availability.get("seasons") or [],
|
||||
"missingEpisodes": arr_details.get("missingEpisodes") or {},
|
||||
},
|
||||
{
|
||||
"id": "search",
|
||||
"label": "Release search",
|
||||
"state": search_state,
|
||||
"summary": search_summary,
|
||||
"actionIds": [] if fully_available else [
|
||||
action_id
|
||||
for action_id in ("search_auto", "search_releases")
|
||||
if action_id in action_ids
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "download",
|
||||
"label": "Download complete" if fully_available else "Download",
|
||||
"state": download_stage_state,
|
||||
"summary": download_summary,
|
||||
"visible": pipeline_download_visible,
|
||||
"torrents": pipeline_torrents,
|
||||
},
|
||||
{
|
||||
"id": "available",
|
||||
"label": available_label,
|
||||
"state": available_state,
|
||||
"stateLabel": available_state_label,
|
||||
"summary": available_summary,
|
||||
"link": jellyfin_link,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def build_snapshot(request_id: str) -> Snapshot:
|
||||
timeline = []
|
||||
runtime = get_runtime_settings()
|
||||
@@ -1060,7 +449,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
poster_path = media.get("posterPath") or media.get("poster_path")
|
||||
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
|
||||
|
||||
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
|
||||
if snapshot.title in {None, "", "Unknown"} and allow_remote:
|
||||
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||
if tmdb_id:
|
||||
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
|
||||
@@ -1144,7 +533,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
|
||||
arr_details["queue"] = arr_queue
|
||||
episodes = await sonarr.get_episodes(series_id)
|
||||
arr_details["availability"] = _episode_availability(episodes)
|
||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||
if missing_by_season:
|
||||
arr_details["missingEpisodes"] = missing_by_season
|
||||
@@ -1193,12 +581,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_state = "added"
|
||||
else:
|
||||
arr_state = "missing"
|
||||
arr_details["availability"] = {
|
||||
"available": 1 if arr_item and arr_item.get("hasFile") else 0,
|
||||
"missing": 0 if arr_item and arr_item.get("hasFile") else 1,
|
||||
"total": 1,
|
||||
"seasons": [],
|
||||
}
|
||||
if arr_item and isinstance(arr_item.get("id"), int):
|
||||
arr_queue = await radarr.get_queue(int(arr_item["id"]))
|
||||
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
||||
@@ -1210,20 +592,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
if arr_state is None:
|
||||
arr_state = "unknown"
|
||||
|
||||
_apply_arr_identity(snapshot, arr_item)
|
||||
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
||||
|
||||
prowlarr_state = "unknown"
|
||||
try:
|
||||
prowlarr_health = await prowlarr.get_health()
|
||||
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
|
||||
prowlarr_state = "issues"
|
||||
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
|
||||
else:
|
||||
prowlarr_state = "ok"
|
||||
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
|
||||
except Exception as exc:
|
||||
prowlarr_state = "error"
|
||||
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
|
||||
|
||||
jellyfin_available = False
|
||||
@@ -1291,66 +668,34 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
qbit_state = "not_started"
|
||||
qbit_message = "No download attempt has been observed."
|
||||
download_ids = _download_ids(_queue_records(arr_queue))
|
||||
download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
|
||||
torrent_list: List[Dict[str, Any]] = []
|
||||
download_visible = bool(download_ids) or bool(download_history.get("observed"))
|
||||
qbit_error = None
|
||||
qbit_state = None
|
||||
qbit_message = None
|
||||
try:
|
||||
download_ids = _download_ids(_queue_records(arr_queue))
|
||||
torrent_list: List[Dict[str, Any]] = []
|
||||
if qbittorrent.configured():
|
||||
if download_ids:
|
||||
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
|
||||
torrent_list = torrents if isinstance(torrents, list) else []
|
||||
else:
|
||||
request_tag = f"magent-{request_id}"
|
||||
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
|
||||
category = f"magent-{request_id}"
|
||||
torrents = await qbittorrent.get_torrents_by_category(category)
|
||||
torrent_list = torrents if isinstance(torrents, list) else []
|
||||
for torrent in torrent_list:
|
||||
if isinstance(torrent, dict):
|
||||
torrent["progressPercent"] = _torrent_progress(torrent)
|
||||
if torrent_list:
|
||||
download_visible = True
|
||||
summary = _summarize_qbit(torrent_list)
|
||||
qbit_state = str(summary.get("state") or "idle")
|
||||
qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
|
||||
elif download_ids:
|
||||
qbit_state = "missing"
|
||||
qbit_message = (
|
||||
"The collector queued a download, but it is no longer visible in qBittorrent."
|
||||
)
|
||||
elif download_history.get("observed"):
|
||||
qbit_state = "missing"
|
||||
qbit_message = (
|
||||
"A previous download was observed, but it is not currently visible in qBittorrent."
|
||||
)
|
||||
except Exception as exc:
|
||||
qbit_error = str(exc)
|
||||
if download_visible:
|
||||
qbit_state = "error"
|
||||
qbit_message = (
|
||||
"A download attempt exists, but Magent cannot currently read its state from qBittorrent."
|
||||
)
|
||||
|
||||
download_presentation = {
|
||||
"visible": download_visible,
|
||||
"observed": download_visible,
|
||||
"state": qbit_state,
|
||||
"summary": qbit_message,
|
||||
"torrents": torrent_list,
|
||||
"lastSeenAt": download_history.get("last_seen_at"),
|
||||
}
|
||||
qbit_state = summary.get("state")
|
||||
qbit_message = summary.get("message")
|
||||
timeline.append(
|
||||
TimelineHop(
|
||||
service="qBittorrent",
|
||||
status=qbit_state,
|
||||
status=summary["state"],
|
||||
details={
|
||||
**download_presentation,
|
||||
"error": qbit_error,
|
||||
"summary": summary["message"],
|
||||
"torrents": torrent_list,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)}))
|
||||
|
||||
status_code = None
|
||||
try:
|
||||
@@ -1375,8 +720,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
snapshot.state_reason = qbit_message
|
||||
elif qbit_state == "completed":
|
||||
if arr_state == "available":
|
||||
snapshot.state = NormalizedState.importing
|
||||
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
|
||||
snapshot.state = NormalizedState.completed
|
||||
snapshot.state_reason = "In your library and ready to watch."
|
||||
else:
|
||||
snapshot.state = NormalizedState.importing
|
||||
snapshot.state_reason = "Download finished. Waiting for library import."
|
||||
@@ -1392,8 +737,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
snapshot.state = NormalizedState.searching
|
||||
snapshot.state_reason = "Searching for a matching release."
|
||||
elif arr_state == "available":
|
||||
snapshot.state = NormalizedState.importing
|
||||
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
|
||||
snapshot.state = NormalizedState.completed
|
||||
snapshot.state_reason = "In your library and ready to watch."
|
||||
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
|
||||
snapshot.state = NormalizedState.added_to_arr
|
||||
snapshot.state_reason = "Item is present in Sonarr/Radarr"
|
||||
@@ -1421,32 +766,23 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
actions.append(
|
||||
ActionOption(
|
||||
id="readd_to_arr",
|
||||
label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
|
||||
label="Push to Sonarr/Radarr",
|
||||
risk="medium",
|
||||
description="Send this approved request to the library collector.",
|
||||
)
|
||||
)
|
||||
elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
|
||||
missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
|
||||
automatic_label = (
|
||||
f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
|
||||
if snapshot.request_type == RequestType.tv and missing_count
|
||||
else "Search automatically for a release"
|
||||
)
|
||||
elif arr_item and arr_state != "available":
|
||||
actions.append(
|
||||
ActionOption(
|
||||
id="search_auto",
|
||||
label=automatic_label,
|
||||
label="Search and auto-download",
|
||||
risk="low",
|
||||
description="Ask the library collector to find and download the best permitted match.",
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
ActionOption(
|
||||
id="search_releases",
|
||||
label="Review available releases",
|
||||
label="Search and choose a download",
|
||||
risk="low",
|
||||
description="Search the configured indexers and choose a release yourself.",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1457,28 +793,18 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
id="resume_torrent",
|
||||
label="Resume the download",
|
||||
risk="low",
|
||||
description="Resume the existing qBittorrent job if it is paused or stalled.",
|
||||
)
|
||||
)
|
||||
|
||||
snapshot.actions = actions
|
||||
jellyfin_link = None
|
||||
if runtime.jellyfin_public_url and jellyfin_available:
|
||||
if runtime.jellyfin_public_url and snapshot.state in {
|
||||
NormalizedState.available,
|
||||
NormalizedState.completed,
|
||||
}:
|
||||
base_url = runtime.jellyfin_public_url.rstrip("/")
|
||||
jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
|
||||
if jellyfin_item_id:
|
||||
jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
|
||||
else:
|
||||
query = quote(snapshot.title or "")
|
||||
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
|
||||
availability = arr_details.get("availability") or {}
|
||||
is_partial = bool(
|
||||
jellyfin_available
|
||||
and snapshot.request_type == RequestType.tv
|
||||
and int(availability.get("missing") or 0) > 0
|
||||
)
|
||||
if jellyfin_available and not is_partial:
|
||||
snapshot.actions = []
|
||||
snapshot.raw = {
|
||||
"jellyseerr": jelly_request,
|
||||
"arr": {
|
||||
@@ -1487,47 +813,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
},
|
||||
"jellyfin": {
|
||||
"publicUrl": runtime.jellyfin_public_url,
|
||||
"found": jellyfin_available,
|
||||
"available": jellyfin_available and snapshot.state in {
|
||||
"available": snapshot.state in {
|
||||
NormalizedState.available,
|
||||
NormalizedState.completed,
|
||||
},
|
||||
"partial": is_partial,
|
||||
"link": jellyfin_link,
|
||||
"item": jellyfin_item,
|
||||
},
|
||||
"qbittorrent": {
|
||||
**download_presentation,
|
||||
"downloadIds": download_ids,
|
||||
"error": qbit_error,
|
||||
},
|
||||
}
|
||||
|
||||
snapshot.presentation = _build_presentation(
|
||||
snapshot,
|
||||
approved=derived_approved,
|
||||
arr_state=arr_state,
|
||||
arr_details=arr_details,
|
||||
prowlarr_state=prowlarr_state,
|
||||
download=download_presentation,
|
||||
jellyfin_found=jellyfin_available,
|
||||
jellyfin_link=jellyfin_link,
|
||||
)
|
||||
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
|
||||
repair_activity = _build_repair_activity(
|
||||
snapshot,
|
||||
action=repair_action,
|
||||
arr_state=arr_state,
|
||||
arr_details=arr_details,
|
||||
download=download_presentation,
|
||||
jellyfin_found=jellyfin_available,
|
||||
)
|
||||
if repair_activity:
|
||||
snapshot.presentation["repairActivity"] = repair_activity
|
||||
status_presentation = snapshot.presentation.get("status")
|
||||
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
|
||||
snapshot.state_reason = str(status_presentation["meaning"])
|
||||
|
||||
await _maybe_refresh_jellyfin(snapshot)
|
||||
await asyncio.to_thread(save_snapshot, snapshot)
|
||||
return snapshot
|
||||
|
||||
@@ -2,8 +2,8 @@ fastapi==0.134.0
|
||||
uvicorn==0.41.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.14.2
|
||||
PyJWT==2.13.0
|
||||
pydantic-settings==2.13.1
|
||||
PyJWT==2.11.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.31
|
||||
Pillow==12.3.0
|
||||
python-multipart==0.0.22
|
||||
Pillow==12.1.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
name: magent-beta
|
||||
|
||||
services:
|
||||
magent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
APP_NAME: Magent Beta
|
||||
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
|
||||
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
|
||||
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
|
||||
AUTH_COOKIE_NAME: magent_beta_auth
|
||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
||||
SQLITE_PATH: /app/data/magent.db
|
||||
LOG_FILE: /app/data/magent.log
|
||||
SITE_BANNER_ENABLED: "true"
|
||||
SITE_BANNER_MESSAGE: "Beta environment"
|
||||
SITE_BANNER_TONE: warning
|
||||
ports:
|
||||
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
|
||||
- "127.0.0.1:8100:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -1,110 +0,0 @@
|
||||
/* Top-navigation workspace and streamlined account screens. */
|
||||
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
|
||||
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
|
||||
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
|
||||
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
|
||||
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
|
||||
.settings-top-navigation label { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
|
||||
.settings-top-navigation label span { color: var(--ops-faint); font: 12px Inter, sans-serif; text-transform: none; }
|
||||
.settings-top-navigation select { width: 260px; min-height: 42px; font: 13px Inter, sans-serif; padding: 10px 12px; border-radius: 8px; }
|
||||
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
|
||||
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
|
||||
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
|
||||
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
|
||||
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
|
||||
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
||||
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
|
||||
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
|
||||
.account-identity > div > span { font-size: 12px; color: var(--ops-faint); }
|
||||
.account-avatar { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; border: 1px solid #45434f; border-radius: 14px; background: #25242c; color: #dedaff; font: 600 20px "DM Sans", sans-serif; }
|
||||
.account-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--ops-line-soft); margin-bottom: 24px; }
|
||||
.account-tabs button { min-height: 46px; padding: 0 2px; border: 0; border-radius: 0 !important; border-bottom: 2px solid transparent; border-color: transparent !important; background: transparent !important; color: var(--ops-muted); font: 500 14px "DM Sans", sans-serif; box-shadow: none; text-transform: none; }
|
||||
.account-tabs button[aria-selected=true] { color: #dedaff; border-bottom-color: #bcb3ff !important; }
|
||||
.account-page [hidden] { display: none !important; }
|
||||
.account-panel { border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); padding: 32px; animation: account-appear .18s ease-out; }
|
||||
.account-section-intro { margin-bottom: 26px; }
|
||||
.account-section-intro h2 { margin: 0 0 8px; font-size: 21px; }
|
||||
.account-section-intro p { margin: 0; font-size: 13px; color: var(--ops-muted); line-height: 1.6; }
|
||||
.account-form { display: grid; gap: 10px; max-width: 500px; }
|
||||
.account-form fieldset { display: grid; gap: 10px; min-width: 0; padding: 0; margin: 0; border: 0; }
|
||||
.account-form label { display: block; padding: 0; margin: 0; border: 0; background: none; color: var(--ops-text); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||
.account-form input { display: block; width: 100%; min-width: 0; min-height: 46px; padding: 11px 13px; margin: 0; border: 1px solid var(--ops-line); border-radius: 8px; font: 14px Inter, sans-serif; }
|
||||
.account-form fieldset label:not(:first-child) { margin-top: 10px; }
|
||||
.account-form .account-hint { margin: 0; color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
|
||||
.account-form-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||
button.account-primary { min-height: 44px; padding: 11px 20px; border: 1px solid #c7bdff !important; border-radius: 8px; background: #c7bdff !important; color: #1c172c !important; font: 700 13px "DM Sans", sans-serif; text-transform: none; transition: background .15s, opacity .15s; }
|
||||
button.account-primary:hover:not(:disabled) { background: #d8d1ff !important; }
|
||||
button.account-primary:disabled { opacity: .4; cursor: not-allowed; }
|
||||
button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px solid var(--ops-line); background: transparent !important; color: var(--ops-muted); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
|
||||
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
|
||||
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
|
||||
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
|
||||
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.account-request-summary > div { display: grid; gap: 5px; }
|
||||
.account-request-summary strong { font: 600 25px "DM Sans", sans-serif; }
|
||||
.account-request-summary span { color: var(--ops-muted); font-size: 12px; }
|
||||
.account-request-summary a { margin-left: auto; font-size: 13px; color: #d0c8ff; text-decoration: none; }
|
||||
.account-list-heading { font-size: 14px; margin: 0 0 8px; }
|
||||
.account-access-list { list-style: none; padding: 0; margin: 0; }
|
||||
.account-access-list > li { padding: 18px 0; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.account-access-list > li:last-child { border: 0; }
|
||||
.account-access-summary { display: flex; justify-content: space-between; gap: 16px; }
|
||||
.account-access-summary strong { font-size: 13px; font-weight: 500; }
|
||||
.account-access-summary time { font-size: 12px; color: var(--ops-muted); text-align: right; }
|
||||
.account-access-list details { margin-top: 8px; font-size: 12px; color: var(--ops-faint); }
|
||||
.account-access-list summary { cursor: pointer; }
|
||||
.account-access-list dl { display: grid; gap: 8px; margin-bottom: 0; }
|
||||
.account-access-list dl > div { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.account-access-list dd { margin: 0; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||
.account-empty { color: var(--ops-muted); font-size: 14px; line-height: 1.6; padding: 16px 0; }
|
||||
.account-empty button { margin-top: 8px; }
|
||||
.page:has(> .login-page) { padding: 0; background: #121214; }
|
||||
.page > main.login-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; width: 100%; max-width: none; margin: 0; padding: 40px 20px; background: radial-gradient(ellipse at 50% 15%, #24202e 0, transparent 60%); }
|
||||
.login-card { width: 100%; max-width: 430px; border: 1px solid #3a3841; border-radius: 20px; padding: 32px; background: #1c1b1f; box-shadow: 0 22px 90px #0003; }
|
||||
.login-brand { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 36px; }
|
||||
.login-brand > a { display: flex; align-items: center; gap: 10px; color: #eeeaf5; text-decoration: none; font: 600 23px "DM Sans", sans-serif; }
|
||||
.magent-mark { width: 38px; height: 38px; }
|
||||
.login-beta { padding: 4px 8px; border: 1px solid #44404d; border-radius: 6px; font: 10px "JetBrains Mono", monospace; color: #bdb5cf; text-transform: uppercase; }
|
||||
.login-card header { margin-bottom: 24px; }
|
||||
.login-card h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.2; color: #f1edf6; }
|
||||
.login-card header p { margin: 0; color: #aba5b7; font-size: 13px; }
|
||||
.login-methods { display: flex; gap: 4px; padding: 4px; margin: 0 0 16px; background: #151417; border: 1px solid #3b3842; border-radius: 9px; }
|
||||
.login-methods button { flex: 1; min-height: 36px; padding: 8px; border: 0; border-radius: 6px !important; background: transparent !important; color: #b3adbf; font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||
.login-methods button[aria-pressed=true] { background: #36313f !important; color: #eee7ff; }
|
||||
.login-method-help { font-size: 12px; line-height: 1.5; margin: 0 0 10px; color: #aba5b7; }
|
||||
.login-form input { background: #151417 !important; border-color: #46414e !important; }
|
||||
.login-password-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
|
||||
.login-password-label a { color: #c4b9e5; font-size: 12px; text-decoration: none; }
|
||||
.login-password-field { position: relative; }
|
||||
.login-password-field input { padding-right: 48px; }
|
||||
.login-password-field .password-visibility { position: absolute; top: 1px; right: 1px; width: 44px; height: calc(100% - 2px); min-height: 42px; padding: 12px; border: 0; background: transparent !important; color: #aba5b7; box-shadow: none; }
|
||||
.password-visibility svg { width: 19px; height: 19px; display: block; }
|
||||
.login-form .login-submit { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; min-height: 46px; }
|
||||
.login-card footer { border-top: 1px solid #36323c; margin-top: 28px; padding-top: 22px; text-align: center; color: #aaa3b5; font-size: 12px; }
|
||||
.login-card footer a { margin-left: 4px; color: #d4c7ff; text-decoration: none; font-weight: 500; }
|
||||
.login-credit { color: #87818f; font-size: 11px; margin: 24px 0 0; }
|
||||
.account-page :focus-visible, .login-page :focus-visible, .settings-top-navigation :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
|
||||
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
|
||||
@media (max-width: 680px) {
|
||||
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
|
||||
.page > main.account-page { margin-top: 28px; }
|
||||
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
|
||||
.account-identity strong { max-width: 130px; }
|
||||
.account-avatar { display: none; }
|
||||
.account-panel { padding: 22px 20px; }
|
||||
.account-request-summary { gap: 24px; flex-wrap: wrap; }
|
||||
.account-request-summary a { width: 100%; margin: 0; }
|
||||
.account-access-summary { flex-direction: column; gap: 6px; }
|
||||
.account-access-summary time { text-align: left; }
|
||||
.settings-top-navigation { gap: 14px; flex-wrap: wrap; }
|
||||
.settings-top-navigation label { flex: 1; min-width: 220px; }
|
||||
.settings-top-navigation select { flex: 1; width: 100%; }
|
||||
.login-card { padding: 26px 24px; }
|
||||
.page > main.login-page { padding: 24px 16px; }
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
||||
type Option = { value: string; label: string }
|
||||
type Props = {
|
||||
setting: AdminSetting
|
||||
label: string
|
||||
value: string
|
||||
help?: string
|
||||
placeholder?: string
|
||||
boolean?: boolean
|
||||
numeric?: boolean
|
||||
multiline?: boolean
|
||||
options?: Option[]
|
||||
optionsUnavailable?: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SELECTS: Record<string, Option[]> = {
|
||||
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
|
||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
|
||||
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
|
||||
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
|
||||
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
|
||||
}
|
||||
|
||||
export default function SettingField(props: Props) {
|
||||
const { setting, label, value, help, placeholder, onChange } = props
|
||||
const id = `setting-${setting.key}`
|
||||
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
|
||||
const selectedOptions = options && value && !options.some((option) => option.value === value)
|
||||
? [{ value, label: `Current selection (${value})` }, ...options] : options
|
||||
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
|
||||
const zeroAllowed = setting.key === 'log_file_backup_count'
|
||||
const minimum = zeroAllowed ? 0 : 1
|
||||
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
|
||||
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
|
||||
|
||||
if (props.boolean) {
|
||||
return (
|
||||
<div className="setting-field setting-switch">
|
||||
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
|
||||
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
|
||||
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
|
||||
{props.optionsUnavailable ? (
|
||||
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
|
||||
) : selectedOptions ? (
|
||||
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{!value && <option value="">Choose an option</option>}
|
||||
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : props.multiline ? (
|
||||
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
|
||||
) : (
|
||||
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
|
||||
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
|
||||
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
|
||||
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
)}
|
||||
{help && <p id={`${id}-help`}>{help}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+846
-467
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(!collapsed)
|
||||
return (
|
||||
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
|
||||
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '−' : '+'}</b></span></button>}
|
||||
<div id={`${id}-content`} hidden={!open}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,9 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'artwork',
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'bazarr',
|
||||
'prowlarr',
|
||||
'qbittorrent',
|
||||
'requests',
|
||||
'issue-workflow',
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
|
||||
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
|
||||
.config-directory-region { display: grid; gap: 16px; }
|
||||
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
|
||||
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
|
||||
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
|
||||
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
|
||||
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
||||
.config-link-copy strong { font-size: 14px; }
|
||||
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
|
||||
.config-link-arrow { color: var(--ops-faint); }
|
||||
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
|
||||
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
|
||||
.config-connection-badge.is-up { color: var(--ops-green); }
|
||||
.config-connection-badge.is-down { color: var(--ops-red); }
|
||||
.config-connection-badge.is-degraded { color: var(--ops-warn); }
|
||||
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
|
||||
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
|
||||
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
|
||||
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
|
||||
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
|
||||
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
|
||||
.config-desktop-navigation { display: grid; gap: 18px; }
|
||||
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
|
||||
.admin-sidebar .admin-nav-title { font-size: 10px; }
|
||||
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
|
||||
.config-mobile-picker { display: none; }
|
||||
.admin-card { min-width: 0; }
|
||||
.admin-shell { grid-template-areas: "nav main"; }
|
||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
|
||||
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
|
||||
.admin-card .admin-header { margin-bottom: 24px; }
|
||||
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
|
||||
.admin-card .admin-header .section-kicker { font-size: 10px; }
|
||||
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
|
||||
.config-service-status button { margin-left: auto; }
|
||||
.admin-form.admin-zone-stack { gap: 16px; }
|
||||
.admin-form .config-subsection { padding: 22px !important; }
|
||||
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
|
||||
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
|
||||
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
|
||||
.config-subsection .section-header h2::after { display: none; }
|
||||
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
|
||||
.config-subsection .admin-grid { gap: 20px 24px; }
|
||||
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
|
||||
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
|
||||
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
||||
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
||||
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
||||
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
||||
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
||||
.setting-switch > div { display: grid; gap: 6px; }
|
||||
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
|
||||
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
|
||||
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
|
||||
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
|
||||
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
|
||||
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
|
||||
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
|
||||
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
|
||||
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
|
||||
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
|
||||
.config-region-toggle strong { font-size: 15px; }
|
||||
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
|
||||
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
|
||||
.config-subsection [hidden] { display: none !important; }
|
||||
.config-region-toggle + div .config-subsection-heading { display: none; }
|
||||
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
|
||||
.config-inline-controls select { width: auto; }
|
||||
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
|
||||
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
|
||||
.admin-card .cache-row { min-width: 650px; }
|
||||
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.config-directory-links { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
|
||||
.config-desktop-navigation { display: none; }
|
||||
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
|
||||
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
|
||||
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
||||
.admin-form .config-subsection { padding: 16px !important; }
|
||||
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
||||
.config-link-copy { flex-basis: calc(100% - 62px); }
|
||||
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
||||
.config-link-arrow { display: none; }
|
||||
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
|
||||
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
|
||||
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.config-subsection .section-subtitle { margin-top: 0; }
|
||||
.config-subsection .settings-section-actions > button { flex-grow: 1; }
|
||||
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
|
||||
.config-service-status { gap: 10px; }
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
|
||||
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
|
||||
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
|
||||
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
|
||||
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
|
||||
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' },
|
||||
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
|
||||
]},
|
||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options', symbol: '01' },
|
||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates', symbol: '02' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
|
||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
|
||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
|
||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
|
||||
]},
|
||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
||||
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
|
||||
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' },
|
||||
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' },
|
||||
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' },
|
||||
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' },
|
||||
]},
|
||||
]
|
||||
|
||||
export const serviceStatusLabel = (status?: string) => ({
|
||||
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up',
|
||||
}[status ?? ''] ?? 'Not checked')
|
||||
@@ -7,7 +7,19 @@ export default function AdminDiagnosticsPage() {
|
||||
return (
|
||||
<AdminShell
|
||||
title="Diagnostics"
|
||||
subtitle="Check connections and investigate service problems."
|
||||
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
|
||||
rail={
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Diagnostics</span>
|
||||
<h2>Shared console</h2>
|
||||
<p>
|
||||
This page and Maintenance now use the same diagnostics panel, so every test target and
|
||||
notification ping stays in one source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminDiagnosticsPanel />
|
||||
</AdminShell>
|
||||
|
||||
+198
-479
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import AdminShell from '../../ui/AdminShell'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
|
||||
type AdminUserLite = {
|
||||
id: number
|
||||
@@ -46,21 +46,10 @@ type Invite = {
|
||||
recipient_email?: string | null
|
||||
is_expired?: boolean
|
||||
is_usable?: boolean
|
||||
operational_state?: 'ready' | 'disabled' | 'expired' | 'exhausted' | 'profile_unavailable'
|
||||
state_label?: string
|
||||
attention_reason?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
}
|
||||
|
||||
type InviteSummary = {
|
||||
total: number
|
||||
ready: number
|
||||
attention: number
|
||||
used_signups: number
|
||||
with_recipient: number
|
||||
}
|
||||
|
||||
type InviteForm = {
|
||||
code: string
|
||||
label: string
|
||||
@@ -71,11 +60,10 @@ type InviteForm = {
|
||||
enabled: boolean
|
||||
expires_at: string
|
||||
recipient_email: string
|
||||
send_email: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
type InviteDeliveryMethod = '' | 'manual' | 'email'
|
||||
|
||||
type ProfileForm = {
|
||||
name: string
|
||||
description: string
|
||||
@@ -87,7 +75,6 @@ type ProfileForm = {
|
||||
|
||||
type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
|
||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
|
||||
type InviteView = 'all' | 'ready' | 'attention' | 'used'
|
||||
type InviteTraceScope = 'all' | 'invited' | 'direct'
|
||||
type InviteTraceView = 'list' | 'graph'
|
||||
|
||||
@@ -130,22 +117,17 @@ type InvitePolicy = {
|
||||
invite_access_enabled_users?: number
|
||||
}
|
||||
|
||||
const futureInviteExpiry = (days: number) => {
|
||||
const expires = new Date()
|
||||
expires.setDate(expires.getDate() + days)
|
||||
return expires.toISOString()
|
||||
}
|
||||
|
||||
const defaultInviteForm = (profileId = '', safeDefaults = false): InviteForm => ({
|
||||
const defaultInviteForm = (): InviteForm => ({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
profile_id: profileId,
|
||||
profile_id: '',
|
||||
role: '',
|
||||
max_uses: safeDefaults ? '1' : '',
|
||||
max_uses: '',
|
||||
enabled: true,
|
||||
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
|
||||
expires_at: '',
|
||||
recipient_email: '',
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
|
||||
@@ -179,9 +161,6 @@ const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.
|
||||
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
|
||||
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
|
||||
|
||||
const isInviteOperationallyReady = (invite: Invite) =>
|
||||
invite.operational_state ? invite.operational_state === 'ready' : invite.is_usable !== false
|
||||
|
||||
export default function AdminInviteManagementPage() {
|
||||
const router = useRouter()
|
||||
const [invites, setInvites] = useState<Invite[]>([])
|
||||
@@ -191,7 +170,6 @@ export default function AdminInviteManagementPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [inviteSaving, setInviteSaving] = useState(false)
|
||||
const [sendingInviteId, setSendingInviteId] = useState<number | null>(null)
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
|
||||
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
|
||||
@@ -206,11 +184,6 @@ export default function AdminInviteManagementPage() {
|
||||
|
||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
|
||||
const [inviteFlowStep, setInviteFlowStep] = useState(1)
|
||||
const [useCustomInviteCode, setUseCustomInviteCode] = useState(false)
|
||||
const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState<InviteDeliveryMethod>('')
|
||||
const [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null)
|
||||
const [inviteView, setInviteView] = useState<InviteView>('all')
|
||||
|
||||
const [profileEditingId, setProfileEditingId] = useState<number | null>(null)
|
||||
const [profileForm, setProfileForm] = useState<ProfileForm>(defaultProfileForm())
|
||||
@@ -219,7 +192,7 @@ export default function AdminInviteManagementPage() {
|
||||
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
|
||||
const [masterInviteSelection, setMasterInviteSelection] = useState('')
|
||||
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<InviteManagementTab>('invites')
|
||||
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
|
||||
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
|
||||
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
|
||||
@@ -313,20 +286,7 @@ export default function AdminInviteManagementPage() {
|
||||
])
|
||||
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
|
||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||
setInviteSummary((inviteData?.summary ?? null) as InviteSummary | null)
|
||||
const nextProfiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []
|
||||
setProfiles(nextProfiles)
|
||||
setInviteForm((current) => {
|
||||
const isPristine =
|
||||
!current.code &&
|
||||
!current.label &&
|
||||
!current.description &&
|
||||
!current.profile_id &&
|
||||
!current.recipient_email
|
||||
if (!isPristine) return current
|
||||
const defaultProfile = nextProfiles.find((profile: Profile) => profile.is_active !== false)
|
||||
return defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true)
|
||||
})
|
||||
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
|
||||
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
|
||||
setInvitePolicy(nextPolicy)
|
||||
setMasterInviteSelection(
|
||||
@@ -364,11 +324,7 @@ export default function AdminInviteManagementPage() {
|
||||
|
||||
const resetInviteEditor = () => {
|
||||
setInviteEditingId(null)
|
||||
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
|
||||
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
|
||||
setInviteFlowStep(1)
|
||||
setUseCustomInviteCode(false)
|
||||
setInviteDeliveryMethod('')
|
||||
setInviteForm(defaultInviteForm())
|
||||
}
|
||||
|
||||
const editInvite = (invite: Invite) => {
|
||||
@@ -386,30 +342,22 @@ export default function AdminInviteManagementPage() {
|
||||
enabled: invite.enabled !== false,
|
||||
expires_at: invite.expires_at ?? '',
|
||||
recipient_email: invite.recipient_email ?? '',
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
setInviteFlowStep(4)
|
||||
setUseCustomInviteCode(true)
|
||||
setInviteDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
||||
setStatus(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const saveInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const inviteName = inviteForm.label.trim()
|
||||
if (!inviteName) {
|
||||
setError('Give this invite a name so you can recognise it later.')
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
const recipientEmail = inviteForm.recipient_email.trim()
|
||||
if (!inviteDeliveryMethod) {
|
||||
setError('Choose whether to copy the invite link yourself or send it by email.')
|
||||
if (!recipientEmail) {
|
||||
setError('Recipient email is required.')
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
if (inviteDeliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
|
||||
if (!isValidEmail(recipientEmail)) {
|
||||
setError('Recipient email must be valid.')
|
||||
setStatus(null)
|
||||
return
|
||||
@@ -420,16 +368,16 @@ export default function AdminInviteManagementPage() {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const payload = {
|
||||
code: useCustomInviteCode ? inviteForm.code || null : null,
|
||||
label: inviteName,
|
||||
code: inviteForm.code || null,
|
||||
label: inviteForm.label || null,
|
||||
description: inviteForm.description || null,
|
||||
profile_id: inviteForm.profile_id || null,
|
||||
role: inviteForm.role || null,
|
||||
max_uses: inviteForm.max_uses || null,
|
||||
enabled: inviteForm.enabled,
|
||||
expires_at: inviteForm.expires_at || null,
|
||||
recipient_email: inviteDeliveryMethod === 'email' ? recipientEmail : null,
|
||||
send_email: inviteEditingId == null && inviteDeliveryMethod === 'email',
|
||||
recipient_email: recipientEmail,
|
||||
send_email: inviteForm.send_email,
|
||||
message: inviteForm.message || null,
|
||||
}
|
||||
const url =
|
||||
@@ -446,8 +394,8 @@ export default function AdminInviteManagementPage() {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Save failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
resetInviteEditor()
|
||||
const data = await response.json()
|
||||
if (data?.email?.status === 'ok') {
|
||||
setStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||
@@ -457,13 +405,7 @@ export default function AdminInviteManagementPage() {
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||
)
|
||||
} else {
|
||||
setStatus(
|
||||
inviteEditingId == null
|
||||
? inviteDeliveryMethod === 'manual'
|
||||
? 'Invite created. Copy the link below when you are ready to share it.'
|
||||
: 'Invite created.'
|
||||
: 'Invite updated.'
|
||||
)
|
||||
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
}
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
@@ -530,44 +472,6 @@ export default function AdminInviteManagementPage() {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const sendSavedInvite = async (invite: Invite) => {
|
||||
if (!invite.recipient_email) {
|
||||
editInvite(invite)
|
||||
setError('Add a recipient email before sending this invite.')
|
||||
return
|
||||
}
|
||||
if (!emailConfigured?.configured) {
|
||||
setActiveTab('emails')
|
||||
setError(emailConfigured?.detail ?? 'Configure SMTP before sending invite emails.')
|
||||
return
|
||||
}
|
||||
setSendingInviteId(invite.id)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/invites/email/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_key: 'invited',
|
||||
invite_id: invite.id,
|
||||
recipient_email: invite.recipient_email,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.detail || `Email delivery failed (${response.status})`)
|
||||
}
|
||||
setStatus(`Invite ${invite.code} was sent to ${invite.recipient_email}.`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not send the invite email.')
|
||||
} finally {
|
||||
setSendingInviteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
|
||||
setSelectedTemplateKey(templateKey)
|
||||
loadTemplateEditor(templateKey, emailTemplates)
|
||||
@@ -899,22 +803,12 @@ export default function AdminInviteManagementPage() {
|
||||
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
|
||||
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
|
||||
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
|
||||
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
|
||||
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
|
||||
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
|
||||
const masterInvite = invitePolicy?.master_invite ?? null
|
||||
const selectedTemplate =
|
||||
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
|
||||
const inviteCodeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
|
||||
const inviteIdentityReady = Boolean(
|
||||
inviteForm.label.trim() && (!useCustomInviteCode || inviteCodeCharacters.length >= 6)
|
||||
)
|
||||
const inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length
|
||||
const filteredInvites = useMemo(() => {
|
||||
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
|
||||
if (inviteView === 'attention') return invites.filter((invite) => !isInviteOperationallyReady(invite))
|
||||
if (inviteView === 'used') return invites.filter((invite) => invite.use_count > 0)
|
||||
return invites
|
||||
}, [inviteView, invites])
|
||||
|
||||
const inviteTraceRows = useMemo(() => {
|
||||
const inviteByCode = new Map<string, Invite>()
|
||||
@@ -958,9 +852,9 @@ export default function AdminInviteManagementPage() {
|
||||
childrenByInviter.set(key, bucket)
|
||||
})
|
||||
|
||||
childrenByInviter.forEach((bucket) => {
|
||||
childrenByInviter.forEach((bucket) =>
|
||||
bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' }))
|
||||
})
|
||||
)
|
||||
|
||||
const rows: Array<{
|
||||
username: string
|
||||
@@ -1015,14 +909,10 @@ export default function AdminInviteManagementPage() {
|
||||
visited.add(userKey)
|
||||
const nextPath = new Set(path)
|
||||
nextPath.add(userKey)
|
||||
;(childrenByInviter.get(userKey) ?? []).forEach((child) => {
|
||||
walk(child, level + 1, nextPath)
|
||||
})
|
||||
;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath))
|
||||
}
|
||||
|
||||
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => {
|
||||
walk(rootUser, 0, new Set())
|
||||
})
|
||||
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set()))
|
||||
users.forEach((user) => {
|
||||
const key = String(user.username || '').toLowerCase()
|
||||
if (key && !visited.has(key)) {
|
||||
@@ -1098,7 +988,7 @@ export default function AdminInviteManagementPage() {
|
||||
<span className="label">Invites</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{invites.length}</strong>
|
||||
<span>{inviteSummary?.ready ?? usableInvites} ready • {inviteAttentionCount} need attention</span>
|
||||
<span>{usableInvites} usable • {disabledInvites} disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
@@ -1158,77 +1048,29 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-rail-card invite-automation-card">
|
||||
<div>
|
||||
<span className="admin-rail-eyebrow">Automatic setup</span>
|
||||
<h2>Readiness checks</h2>
|
||||
<p>Magent validates each link and applies safer defaults to new invitations.</p>
|
||||
</div>
|
||||
<div className="invite-readiness-list">
|
||||
<button type="button" onClick={() => setActiveTab('invites')}>
|
||||
<span>New invite defaults</span>
|
||||
<strong>1 use · 7 days</strong>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveTab('profiles')}>
|
||||
<span>Active access profiles</span>
|
||||
<strong>{activeProfiles > 0 ? `${activeProfiles} ready` : 'Needs setup'}</strong>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveTab('bulk')}>
|
||||
<span>Self-service policy</span>
|
||||
<strong>{masterInvite ? 'Configured' : 'Not configured'}</strong>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveTab('emails')}>
|
||||
<span>Email delivery</span>
|
||||
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="Invites"
|
||||
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
|
||||
title="Invite management"
|
||||
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
|
||||
rail={inviteManagementRail}
|
||||
>
|
||||
<section className="admin-section">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
<section className="invite-operations-strip" aria-label="Invite operations overview">
|
||||
<button type="button" onClick={() => { setInviteView('ready'); setActiveTab('invites') }}>
|
||||
<span>Ready</span>
|
||||
<strong>{inviteSummary?.ready ?? usableInvites}</strong>
|
||||
<small>can be used now</small>
|
||||
</button>
|
||||
<button type="button" onClick={() => { setInviteView('attention'); setActiveTab('invites') }}>
|
||||
<span>Needs attention</span>
|
||||
<strong>{inviteAttentionCount}</strong>
|
||||
<small>expired, used, disabled, or misconfigured</small>
|
||||
</button>
|
||||
<button type="button" onClick={() => { setInviteView('used'); setActiveTab('invites') }}>
|
||||
<span>Successful sign-ups</span>
|
||||
<strong>{inviteSummary?.used_signups ?? invites.reduce((total, invite) => total + invite.use_count, 0)}</strong>
|
||||
<small>recorded from invite links</small>
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveTab('emails')}>
|
||||
<span>Email delivery</span>
|
||||
<strong>{emailConfigured?.configured ? 'Ready' : 'Setup'}</strong>
|
||||
<small>{emailConfigured?.configured ? 'one-click sending available' : 'SMTP needs attention'}</small>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="invite-admin-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
>
|
||||
Invite links
|
||||
Blanket controls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1242,20 +1084,11 @@ export default function AdminInviteManagementPage() {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
Automation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'emails'}
|
||||
className={activeTab === 'emails' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('emails')}
|
||||
>
|
||||
Delivery
|
||||
Invites
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1264,7 +1097,16 @@ export default function AdminInviteManagementPage() {
|
||||
className={activeTab === 'trace' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('trace')}
|
||||
>
|
||||
Lineage
|
||||
Trace map
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'emails'}
|
||||
className={activeTab === 'emails' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('emails')}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||
@@ -1274,9 +1116,12 @@ export default function AdminInviteManagementPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => router.push('/profile/invites')}
|
||||
onClick={() => {
|
||||
resetInviteEditor()
|
||||
setActiveTab('invites')
|
||||
}}
|
||||
>
|
||||
Create invite
|
||||
New invite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1595,48 +1440,25 @@ export default function AdminInviteManagementPage() {
|
||||
{activeTab === 'invites' && (
|
||||
<div className="invite-admin-stack">
|
||||
<div className="admin-panel invite-admin-list-panel">
|
||||
<div className="invite-list-heading">
|
||||
<div>
|
||||
<h2>Invite links</h2>
|
||||
<p className="lede">Magent checks availability, usage, expiry, and assigned profiles automatically.</p>
|
||||
</div>
|
||||
<fieldset className="invite-view-filter">
|
||||
<legend>Filter invite links</legend>
|
||||
{([
|
||||
['all', `All ${invites.length}`],
|
||||
['ready', `Ready ${inviteSummary?.ready ?? usableInvites}`],
|
||||
['attention', `Attention ${inviteAttentionCount}`],
|
||||
['used', `Used ${invites.filter((invite) => invite.use_count > 0).length}`],
|
||||
] as Array<[InviteView, string]>).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={inviteView === value ? 'is-active' : ''}
|
||||
onClick={() => setInviteView(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<p className="lede">Copy and share invite links. Profiles can be applied per invite.</p>
|
||||
{loading ? (
|
||||
<div className="status-banner">Loading invites…</div>
|
||||
) : filteredInvites.length === 0 ? (
|
||||
<div className="status-banner">No invite links match this view.</div>
|
||||
) : invites.length === 0 ? (
|
||||
<div className="status-banner">No invites created yet.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{filteredInvites.map((invite) => (
|
||||
<div key={invite.id} className={`admin-list-item invite-list-item is-${invite.operational_state ?? 'ready'}`}>
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="admin-list-item">
|
||||
<div className="admin-list-item-main">
|
||||
<div className="admin-list-item-title-row">
|
||||
<strong>{invite.label || 'Unnamed invite'}</strong>
|
||||
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? 'ready'}`}>
|
||||
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
|
||||
<code className="invite-code">{invite.code}</code>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||
</span>
|
||||
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
|
||||
</div>
|
||||
{invite.attention_reason ? <p className="invite-attention-reason">{invite.attention_reason}</p> : null}
|
||||
<p className="admin-list-item-text"><code className="invite-code">{invite.code}</code></p>
|
||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||
{invite.description && (
|
||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||
{invite.description}
|
||||
@@ -1657,16 +1479,8 @@ export default function AdminInviteManagementPage() {
|
||||
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={sendingInviteId === invite.id || !isInviteOperationallyReady(invite)}
|
||||
onClick={() => void sendSavedInvite(invite)}
|
||||
>
|
||||
{sendingInviteId === invite.id ? 'Sending…' : 'Send email'}
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
|
||||
Delivery options
|
||||
Email invite
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
|
||||
Edit
|
||||
@@ -1681,150 +1495,72 @@ export default function AdminInviteManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-panel invite-admin-form-panel">
|
||||
<div className="invite-flow-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Invite operations</span>
|
||||
<h2>{inviteEditingId == null ? 'Client invite workspace' : `Edit ${inviteForm.label || 'invite'}`}</h2>
|
||||
<p className="lede">Creation is client-facing. Admins can still maintain existing invites here.</p>
|
||||
<h2>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h2>
|
||||
<p className="lede">
|
||||
Link an invite to a profile to apply account defaults at sign-up.
|
||||
</p>
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Identity</span>
|
||||
<small>Code and label used to identify the invite link.</small>
|
||||
</div>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>Cancel edit</button>
|
||||
)}
|
||||
</div>
|
||||
{inviteEditingId == null ? (
|
||||
<div className="invite-created-card">
|
||||
<span className="eyebrow">Option 04 · Invites</span>
|
||||
<h3>Create invites from the client workspace</h3>
|
||||
<p>The guided invite flow now lives in the main navigation where users can create and manage their own invitations.</p>
|
||||
<button type="button" onClick={() => router.push('/profile/invites')}>Open client invite workspace</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={saveInvite} className="invite-flow-form">
|
||||
<ol className="invite-flow-route" aria-label="Invite creation progress">
|
||||
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
|
||||
const step = index + 1
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
className={step === inviteFlowStep ? 'is-active' : step < inviteFlowStep ? 'is-complete' : ''}
|
||||
>
|
||||
<span>{String(step).padStart(2, '0')}</span>
|
||||
<strong>{label}</strong>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<section className={`invite-flow-step ${inviteFlowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
||||
<header>
|
||||
<span className="invite-flow-number">01</span>
|
||||
<div>
|
||||
<span className="eyebrow">Identity</span>
|
||||
<h3>Who is this invite for?</h3>
|
||||
<p>Give it a name that will make sense when you return later.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="invite-flow-fields">
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Invite name</span>
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
|
||||
placeholder="Family, that guy from work, the neighbour"
|
||||
/>
|
||||
</label>
|
||||
<label className="invite-flow-choice-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCustomInviteCode}
|
||||
onChange={(event) => {
|
||||
setUseCustomInviteCode(event.target.checked)
|
||||
if (!event.target.checked) {
|
||||
setInviteForm((current) => ({ ...current, code: '' }))
|
||||
}
|
||||
}}
|
||||
disabled={inviteEditingId != null}
|
||||
/>
|
||||
<span>
|
||||
<strong>Choose a custom invite code</strong>
|
||||
<small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small>
|
||||
</span>
|
||||
</label>
|
||||
{useCustomInviteCode && (
|
||||
<label>
|
||||
<span>Custom code</span>
|
||||
<span>Code (optional)</span>
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
|
||||
placeholder="At least 6 letters or numbers"
|
||||
disabled={inviteEditingId != null}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, code: e.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
<small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small>
|
||||
</label>
|
||||
)}
|
||||
{inviteFlowStep === 1 && (
|
||||
<div className="invite-flow-actions">
|
||||
<button type="button" disabled={!inviteIdentityReady} onClick={() => setInviteFlowStep(2)}>
|
||||
Continue to description
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{inviteFlowStep >= 2 && (
|
||||
<section className={`invite-flow-step ${inviteFlowStep > 2 ? 'is-complete' : 'is-active'}`}>
|
||||
<header>
|
||||
<span className="invite-flow-number">02</span>
|
||||
<div>
|
||||
<span className="eyebrow">Description</span>
|
||||
<h3>Add a welcome note</h3>
|
||||
<p>This optional message is shown to the person on the sign-up page.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="invite-flow-fields">
|
||||
<label>
|
||||
<span>Welcome note (optional)</span>
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, label: e.target.value }))
|
||||
}
|
||||
placeholder="Staff invite batch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Description</span>
|
||||
<small>Optional note shown on the signup page.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))}
|
||||
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</label>
|
||||
{inviteFlowStep === 2 && (
|
||||
<div className="invite-flow-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(1)}>Back</button>
|
||||
<button type="button" className="ghost-button" onClick={() => {
|
||||
setInviteForm((current) => ({ ...current, description: '' }))
|
||||
setInviteFlowStep(3)
|
||||
}}>Skip</button>
|
||||
<button type="button" onClick={() => setInviteFlowStep(3)}>Continue</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{inviteFlowStep >= 3 && (
|
||||
<section className={`invite-flow-step ${inviteFlowStep > 3 ? 'is-complete' : 'is-active'}`}>
|
||||
<header>
|
||||
<span className="invite-flow-number">03</span>
|
||||
<div>
|
||||
<span className="eyebrow">Access</span>
|
||||
<h3>Choose their account access</h3>
|
||||
<p>The selected profile controls their defaults when they sign up.</p>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Defaults</span>
|
||||
<small>Choose a profile and optional role override for sign-up.</small>
|
||||
</div>
|
||||
</header>
|
||||
<div className="invite-flow-fields">
|
||||
<div className="invite-flow-field-grid">
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Profile</span>
|
||||
<select
|
||||
value={inviteForm.profile_id}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, profile_id: event.target.value }))}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">Use system defaults</option>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
|
||||
@@ -1833,141 +1569,125 @@ export default function AdminInviteManagementPage() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Permission</span>
|
||||
<span>Role override</span>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(event) => setInviteForm((current) => ({
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
role: event.target.value as '' | 'user' | 'admin',
|
||||
}))}
|
||||
role: e.target.value as '' | 'user' | 'admin',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Use profile default</option>
|
||||
<option value="">Use profile/default</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="invite-policy-note">
|
||||
<strong>Invite limits are automatic</strong>
|
||||
<span>Magent applies the configured usage and expiry rules. Those controls will live in Config.</span>
|
||||
</div>
|
||||
{inviteFlowStep === 3 && (
|
||||
<div className="invite-flow-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(2)}>Back</button>
|
||||
<button type="button" onClick={() => setInviteFlowStep(4)}>Continue to delivery</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{inviteFlowStep >= 4 && (
|
||||
<section className="invite-flow-step is-active">
|
||||
<header>
|
||||
<span className="invite-flow-number">04</span>
|
||||
<div>
|
||||
<span className="eyebrow">Delivery</span>
|
||||
<h3>How will they receive it?</h3>
|
||||
<p>Copy the link yourself, or let Magent email it directly.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="invite-flow-fields">
|
||||
<div className="invite-delivery-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={inviteDeliveryMethod === 'manual' ? 'is-selected' : ''}
|
||||
onClick={() => {
|
||||
setInviteDeliveryMethod('manual')
|
||||
setInviteForm((current) => ({ ...current, recipient_email: '', message: '' }))
|
||||
}}
|
||||
>
|
||||
<span className="eyebrow">Manual</span>
|
||||
<strong>Give me a link</strong>
|
||||
<small>Magent creates the URL. You copy and share it yourself.</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={inviteDeliveryMethod === 'email' ? 'is-selected' : ''}
|
||||
onClick={() => setInviteDeliveryMethod('email')}
|
||||
>
|
||||
<span className="eyebrow">Email</span>
|
||||
<strong>Send it for me</strong>
|
||||
<small>Magent emails the invitation and still gives you a copyable URL.</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{inviteDeliveryMethod === 'manual' && (
|
||||
<div className="invite-delivery-summary">
|
||||
<strong>Your link will appear as soon as the invite is created.</strong>
|
||||
<span>No email address is required and Magent will not send a message.</span>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Limits</span>
|
||||
<small>Usage cap and optional expiry date/time for the invite.</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteDeliveryMethod === 'email' && (
|
||||
<div className="invite-flow-field-grid">
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Recipient email</span>
|
||||
<span>Max uses</span>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.recipient_email}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))}
|
||||
placeholder="person@example.com"
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Email note (optional)</span>
|
||||
<span>Invite expiry (ISO datetime)</span>
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Delivery</span>
|
||||
<small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label>
|
||||
<span>Recipient email (required)</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={inviteForm.recipient_email}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
|
||||
}
|
||||
placeholder="Required recipient email"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Delivery note</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.message}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))}
|
||||
placeholder="A short personal message"
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, message: e.target.value }))
|
||||
}
|
||||
placeholder="Optional message appended to the invite email"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.send_email}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Send “You have been invited” email after saving
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteEditingId != null && (
|
||||
<label className="invite-status-control">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Status</span>
|
||||
<small>Enable or disable the invite before sharing.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
<span>
|
||||
<strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong>
|
||||
<small>Disable this existing invite to stop its link from accepting sign-ups.</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{inviteDeliveryMethod === 'email' && emailConfigured?.configured === false && (
|
||||
<div className="status-banner">{emailConfigured.detail || 'Configure SMTP before sending an invite by email.'}</div>
|
||||
)}
|
||||
|
||||
<div className="invite-flow-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(3)}>Back</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
inviteSaving ||
|
||||
!inviteDeliveryMethod ||
|
||||
(inviteDeliveryMethod === 'email' && (!isValidEmail(inviteForm.recipient_email) || emailConfigured?.configured === false))
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
||||
}
|
||||
>
|
||||
{inviteSaving
|
||||
? 'Creating invite…'
|
||||
: inviteEditingId != null
|
||||
? 'Save invite'
|
||||
: inviteDeliveryMethod === 'email'
|
||||
? 'Create and email invite'
|
||||
: 'Create invite link'}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2250,8 +1970,7 @@ export default function AdminInviteManagementPage() {
|
||||
<option value="direct">Direct / root only</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="invite-trace-view-toggle">
|
||||
<legend>Trace view mode</legend>
|
||||
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
|
||||
<button
|
||||
type="button"
|
||||
className={traceView === 'graph' ? 'is-active' : ''}
|
||||
@@ -2266,7 +1985,7 @@ export default function AdminInviteManagementPage() {
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-trace-summary">
|
||||
<span>{scopedInviteTraceRows.length} rows shown</span>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import PortalClient from '../../portal/PortalClient'
|
||||
|
||||
export default function AdminIssuesPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
}
|
||||
+12
-63
@@ -1,77 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
|
||||
|
||||
type ServiceState = { name: string; status: string }
|
||||
|
||||
export default function AdminLandingPage() {
|
||||
const router = useRouter()
|
||||
const [services, setServices] = useState<ServiceState[]>([])
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = async () => {
|
||||
if (!getToken()) { router.replace('/login'); return }
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
||||
if (!response.ok) { router.replace('/login'); return }
|
||||
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
|
||||
if (!active) return
|
||||
setReady(true)
|
||||
const status = await authFetch(`${getApiBase()}/status/services`)
|
||||
if (!status.ok) throw new Error('Status unavailable')
|
||||
const data = await status.json()
|
||||
if (active) setServices(Array.isArray(data.services) ? data.services : [])
|
||||
} catch {
|
||||
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { active = false }
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
|
||||
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings…</p> : (
|
||||
<div className="config-directory">
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
|
||||
<section className="config-directory-region" key={group.title}>
|
||||
<header><h2>{group.title}</h2><p>{group.description}</p></header>
|
||||
<div className="config-directory-links">
|
||||
{group.items.map((item) => {
|
||||
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
|
||||
return (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
<AdminShell
|
||||
title="Settings"
|
||||
subtitle="Choose what you want to manage."
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/')}>
|
||||
Back to requests
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="status-banner">
|
||||
Pick a section from the left. Each page explains what it does and how it helps.
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<details className="config-advanced-directory">
|
||||
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
|
||||
<div className="config-directory-links">
|
||||
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
|
||||
<div className="system-guide-grid">
|
||||
<article className="system-guide-card">
|
||||
<h3>Landing page</h3>
|
||||
<p>Recent request activity refreshes live for signed-in users.</p>
|
||||
<p>Recent requests and service summaries refresh live for signed-in users.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Request pages</h3>
|
||||
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Admin views</h3>
|
||||
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
|
||||
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type Profile = {
|
||||
username?: string
|
||||
@@ -24,17 +24,15 @@ export default function FeedbackPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not load profile.')
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setProfile({ username: data?.username })
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +49,7 @@ export default function FeedbackPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
||||
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -60,16 +58,17 @@ 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 {
|
||||
|
||||
+4
-703
@@ -181,107 +181,6 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signed-in-context {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-view-toggle {
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
color: #bfeaff;
|
||||
box-shadow: none;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-view-toggle.is-active {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.user-view-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin: 14px 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.38);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(245, 158, 11, 0.13), rgba(14, 165, 233, 0.06));
|
||||
color: #f8e7b2;
|
||||
}
|
||||
|
||||
.user-view-banner > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-view-banner strong {
|
||||
color: #fde68a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.user-view-banner span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.user-view-banner button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-color: rgba(251, 191, 36, 0.32);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #fde68a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.signed-in-header span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #fde68a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.signed-in-context {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.user-view-toggle {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.user-view-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-view-banner > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-view-banner button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -3666,14 +3565,12 @@ 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 {
|
||||
@@ -4658,51 +4555,6 @@ button:hover:not(:disabled) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-operations-strip > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.055), rgba(255, 255, 255, 0.018));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-operations-strip > button:hover {
|
||||
border-color: rgba(126, 215, 255, 0.42);
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(255, 255, 255, 0.026));
|
||||
}
|
||||
|
||||
.invite-operations-strip span,
|
||||
.invite-operations-strip small {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-operations-strip span {
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invite-operations-strip strong {
|
||||
color: #eef7ff;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.invite-operations-strip small {
|
||||
align-self: end;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.invite-admin-summary-tile {
|
||||
min-height: 96px;
|
||||
}
|
||||
@@ -4831,128 +4683,6 @@ button:hover:not(:disabled) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-automation-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-automation-card > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.invite-readiness-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-readiness-list > button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-readiness-list span {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-readiness-list strong {
|
||||
color: #dceafa;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-list-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-list-heading h2,
|
||||
.invite-list-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.invite-view-filter button {
|
||||
padding: 7px 10px;
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #aeb8c7;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-view-filter button.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.38);
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #eaf8ff;
|
||||
}
|
||||
|
||||
.invite-list-item {
|
||||
border-left: 3px solid rgba(126, 215, 255, 0.4);
|
||||
}
|
||||
|
||||
.invite-list-item.is-ready {
|
||||
border-left-color: #48e0b2;
|
||||
}
|
||||
|
||||
.invite-list-item.is-expired,
|
||||
.invite-list-item.is-exhausted,
|
||||
.invite-list-item.is-profile_unavailable {
|
||||
border-left-color: #ffc56d;
|
||||
}
|
||||
|
||||
.invite-list-item.is-disabled {
|
||||
border-left-color: #7e8999;
|
||||
}
|
||||
|
||||
.invite-state-pill.is-ready {
|
||||
border-color: rgba(72, 224, 178, 0.32);
|
||||
color: #69edc5;
|
||||
}
|
||||
|
||||
.invite-state-pill.is-expired,
|
||||
.invite-state-pill.is-exhausted,
|
||||
.invite-state-pill.is-profile_unavailable {
|
||||
border-color: rgba(255, 197, 109, 0.34);
|
||||
color: #ffd38a;
|
||||
}
|
||||
|
||||
.invite-attention-reason {
|
||||
margin: 5px 0 0;
|
||||
color: #ffc56d;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.invite-admin-bulk-panel .user-bulk-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -5040,328 +4770,6 @@ button:hover:not(:disabled) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets > span {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.invite-expiry-presets > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-expiry-presets button {
|
||||
padding: 7px 9px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-expiry-presets small {
|
||||
color: #aeb8c7;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.invite-flow-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invite-flow-heading h2,
|
||||
.invite-created-card h3,
|
||||
.invite-flow-step h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-flow-heading .lede {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.invite-flow-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-flow-route {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.invite-flow-route li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(138, 155, 185, 0.18);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
color: #77849a;
|
||||
}
|
||||
|
||||
.invite-flow-route li span {
|
||||
color: #718095;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.invite-flow-route li strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.5);
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.14), rgba(35, 74, 119, 0.11));
|
||||
color: #edf8ff;
|
||||
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.75);
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-active span,
|
||||
.invite-flow-route li.is-complete span {
|
||||
color: #7ed7ff;
|
||||
}
|
||||
|
||||
.invite-flow-route li.is-complete {
|
||||
border-color: rgba(72, 224, 178, 0.28);
|
||||
color: #c9d7e5;
|
||||
}
|
||||
|
||||
.invite-flow-step {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(138, 155, 185, 0.18);
|
||||
background: linear-gradient(145deg, rgba(31, 43, 65, 0.82), rgba(20, 29, 45, 0.82));
|
||||
}
|
||||
|
||||
.invite-flow-step.is-active {
|
||||
border-color: rgba(126, 215, 255, 0.38);
|
||||
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.7);
|
||||
}
|
||||
|
||||
.invite-flow-step.is-complete {
|
||||
border-color: rgba(72, 224, 178, 0.24);
|
||||
}
|
||||
|
||||
.invite-flow-step > header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(138, 155, 185, 0.14);
|
||||
}
|
||||
|
||||
.invite-flow-step > header > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.invite-flow-step > header p {
|
||||
margin: 0;
|
||||
color: #aeb8c7;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.invite-flow-number {
|
||||
display: grid;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(126, 215, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
color: #7ed7ff;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.invite-flow-fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.invite-flow-fields > label,
|
||||
.invite-flow-field-grid > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-flow-fields label > span:first-child,
|
||||
.invite-flow-field-grid label > span:first-child {
|
||||
color: #cbd5e3;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invite-flow-fields input:not([type='checkbox']),
|
||||
.invite-flow-fields select,
|
||||
.invite-flow-fields textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-flow-fields label > small {
|
||||
color: #8f9caf;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-flow-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line,
|
||||
.invite-status-control {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px !important;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(138, 155, 185, 0.2);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line > input,
|
||||
.invite-status-control > input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line > span,
|
||||
.invite-status-control > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line strong,
|
||||
.invite-status-control strong {
|
||||
color: #e8edf5;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.invite-flow-choice-line small,
|
||||
.invite-status-control small {
|
||||
color: #9ba8ba;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-flow-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.invite-policy-note,
|
||||
.invite-delivery-summary {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(72, 224, 178, 0.2);
|
||||
background: rgba(72, 224, 178, 0.055);
|
||||
}
|
||||
|
||||
.invite-policy-note strong,
|
||||
.invite-delivery-summary strong {
|
||||
color: #dffaf1;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.invite-policy-note span,
|
||||
.invite-delivery-summary span {
|
||||
color: #9fb4b5;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.invite-delivery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border-color: rgba(138, 155, 185, 0.2);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
color: #cbd5e3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button strong {
|
||||
color: #f2f6fb;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button small {
|
||||
color: #98a5b7;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.invite-delivery-grid > button.is-selected {
|
||||
border-color: rgba(126, 215, 255, 0.58);
|
||||
background: linear-gradient(145deg, rgba(14, 165, 233, 0.16), rgba(35, 74, 119, 0.13));
|
||||
box-shadow: inset 0 2px 0 #7ed7ff;
|
||||
}
|
||||
|
||||
.invite-status-control {
|
||||
border-color: rgba(255, 197, 109, 0.27);
|
||||
background: rgba(255, 197, 109, 0.045);
|
||||
}
|
||||
|
||||
.invite-created-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(72, 224, 178, 0.38);
|
||||
background: linear-gradient(145deg, rgba(72, 224, 178, 0.1), rgba(20, 29, 45, 0.72));
|
||||
box-shadow: inset 0 2px 0 rgba(72, 224, 178, 0.72);
|
||||
}
|
||||
|
||||
.invite-created-card p {
|
||||
margin: 0;
|
||||
color: #b7c5d4;
|
||||
}
|
||||
|
||||
.invite-created-card > .ghost-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.invite-created-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-created-link input {
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono), monospace;
|
||||
}
|
||||
|
||||
.invite-email-template-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -5421,10 +4829,6 @@ button:hover:not(:disabled) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-admin-bulk-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -5436,10 +4840,6 @@ button:hover:not(:disabled) {
|
||||
.invite-form-row-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-flow-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -5478,19 +4878,6 @@ button:hover:not(:disabled) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-operations-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-list-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.invite-view-filter {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
@@ -5510,29 +4897,6 @@ button:hover:not(:disabled) {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.invite-flow-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.invite-flow-route {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-delivery-grid,
|
||||
.invite-created-link {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-flow-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.invite-flow-actions button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enterprise UI tightening pass */
|
||||
.admin-panel,
|
||||
.user-detail-panel,
|
||||
@@ -5643,21 +5007,6 @@ textarea {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.invite-trace-view-toggle legend {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.invite-trace-view-toggle button {
|
||||
@@ -5943,34 +5292,6 @@ textarea {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-contact-card {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-contact-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: min(100%, 440px);
|
||||
}
|
||||
|
||||
.profile-contact-form label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-contact-form label > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-contact-form .status-banner,
|
||||
.profile-contact-form .error-banner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-invites-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -6435,8 +5756,7 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the account menu above header controls. Header positioning is owned
|
||||
by the responsive application shell in ops-redesign.css. */
|
||||
/* Final header account menu stacking override (must be last) */
|
||||
.page,
|
||||
.header,
|
||||
.header-left,
|
||||
@@ -6448,7 +5768,9 @@ textarea {
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative !important;
|
||||
isolation: isolate;
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.header-nav,
|
||||
@@ -7243,27 +6565,6 @@ 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));
|
||||
@@ -7388,7 +6689,7 @@ textarea {
|
||||
|
||||
.portal-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
+24
-5
@@ -1,10 +1,11 @@
|
||||
import './globals.css'
|
||||
import './ops-redesign.css'
|
||||
import './admin/config.css'
|
||||
import './account.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import HeaderActions from './ui/HeaderActions'
|
||||
import HeaderIdentity from './ui/HeaderIdentity'
|
||||
import ThemeToggle from './ui/ThemeToggle'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import ApplicationChrome from './ui/ApplicationChrome'
|
||||
import BrandingLogo from './ui/BrandingLogo'
|
||||
import SiteStatus from './ui/SiteStatus'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Magent',
|
||||
@@ -17,7 +18,25 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<body>
|
||||
<BrandingFavicon />
|
||||
<div className="page">
|
||||
<ApplicationChrome />
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<a className="brand-link" href="/">
|
||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||
<div className="brand-stack">
|
||||
<div className="brand">Magent</div>
|
||||
<div className="tagline">Find and fix media requests fast.</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<ThemeToggle />
|
||||
<HeaderIdentity />
|
||||
</div>
|
||||
<div className="header-nav">
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</header>
|
||||
<SiteStatus />
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+11
-71
@@ -1,53 +1,27 @@
|
||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
||||
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||
|
||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
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
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem('magent_token')
|
||||
}
|
||||
|
||||
export const setToken = (_token: string) => {
|
||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
||||
export const setToken = (token: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem('magent_token', token)
|
||||
}
|
||||
|
||||
export const clearToken = () => {
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
if (typeof window === 'undefined') return
|
||||
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',
|
||||
})
|
||||
window.localStorage.removeItem('magent_token')
|
||||
}
|
||||
|
||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const token = getToken()
|
||||
const headers = new Headers(init?.headers || {})
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return fetch(input, { ...init, headers })
|
||||
}
|
||||
|
||||
export const getEventStreamToken = async () => {
|
||||
@@ -64,37 +38,3 @@ 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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
|
||||
const USER_VIEW_EVENT = 'magent:user-view-change'
|
||||
|
||||
const readUserViewPreview = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
|
||||
}
|
||||
|
||||
const applyDocumentMode = (enabled: boolean) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
|
||||
}
|
||||
|
||||
export const setUserViewPreview = (enabled: boolean) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (enabled) {
|
||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
|
||||
} else {
|
||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
|
||||
}
|
||||
applyDocumentMode(enabled)
|
||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
|
||||
}
|
||||
|
||||
export const useUserViewPreview = () => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const nextValue = readUserViewPreview()
|
||||
applyDocumentMode(nextValue)
|
||||
setEnabled(nextValue)
|
||||
}
|
||||
sync()
|
||||
window.addEventListener(USER_VIEW_EVENT, sync)
|
||||
window.addEventListener('storage', sync)
|
||||
return () => {
|
||||
window.removeEventListener(USER_VIEW_EVENT, sync)
|
||||
window.removeEventListener('storage', sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return enabled
|
||||
}
|
||||
+148
-82
@@ -1,110 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { getApiBase, setToken } from '../lib/auth'
|
||||
import MagentMark from '../ui/MagentMark'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getApiBase, setToken, clearToken } from '../lib/auth'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
|
||||
type LoginMode = 'jellyfin' | 'local'
|
||||
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
||||
const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }
|
||||
const DEFAULT_LOGIN_OPTIONS = {
|
||||
showJellyfinLogin: true,
|
||||
showLocalLogin: true,
|
||||
showForgotPassword: true,
|
||||
showSignupLink: true,
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [mode, setMode] = useState<LoginMode>('jellyfin')
|
||||
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
|
||||
const [optionsReady, setOptionsReady] = useState(false)
|
||||
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
|
||||
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
|
||||
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
|
||||
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
|
||||
? 'jellyfin'
|
||||
: loginOptions.showLocalLogin
|
||||
? 'local'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
|
||||
if (!response.ok) throw new Error('Options unavailable')
|
||||
const data = await response.json()
|
||||
if (controller.signal.aborted) return
|
||||
setOptions({
|
||||
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
|
||||
showLocalLogin: data?.login?.showLocalLogin !== false,
|
||||
showForgotPassword: data?.login?.showForgotPassword !== false,
|
||||
showSignupLink: data?.login?.showSignupLink !== false,
|
||||
})
|
||||
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
|
||||
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
|
||||
}
|
||||
} catch {
|
||||
// Keep the normal sign-in methods available during a settings outage.
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setOptionsReady(true)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
|
||||
event.preventDefault()
|
||||
if (loading || !canSignIn || !optionsReady) return
|
||||
setError('')
|
||||
if (!primaryMode) {
|
||||
setError('Login is currently disabled. Contact an administrator.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
|
||||
clearToken()
|
||||
const baseUrl = getApiBase()
|
||||
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
|
||||
const body = new URLSearchParams({ username, password })
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username: username.trim(), password }),
|
||||
credentials: 'include',
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.'
|
||||
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.'
|
||||
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.'
|
||||
: 'Check your username and password, then try again.')
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/'
|
||||
return
|
||||
}
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
throw new Error('Login failed')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Invalid username or password.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const loadLoginOptions = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await fetch(`${baseUrl}/site/public`)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||
setToken('cookie')
|
||||
window.location.assign('/')
|
||||
} catch {
|
||||
setError('Could not reach Magent. Check your connection and try again.')
|
||||
} finally { setLoading(false) }
|
||||
const login = data?.login ?? {}
|
||||
if (!active) return
|
||||
setLoginOptions({
|
||||
showJellyfinLogin: login.showJellyfinLogin !== false,
|
||||
showLocalLogin: login.showLocalLogin !== false,
|
||||
showForgotPassword: login.showForgotPassword !== false,
|
||||
showSignupLink: login.showSignupLink !== false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
void loadLoginOptions()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loginHelpText = (() => {
|
||||
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
|
||||
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
|
||||
}
|
||||
if (loginOptions.showJellyfinLogin) {
|
||||
return 'Use your Jellyfin account to sign in.'
|
||||
}
|
||||
if (loginOptions.showLocalLogin) {
|
||||
return 'Use your local Magent admin account to sign in.'
|
||||
}
|
||||
return 'No sign-in methods are currently available. Contact an administrator.'
|
||||
})()
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
|
||||
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
|
||||
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
||||
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
|
||||
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
|
||||
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
|
||||
</div>}
|
||||
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in…</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
|
||||
<form className="account-form login-form" onSubmit={submit}>
|
||||
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
|
||||
<label htmlFor="login-username">Username</label>
|
||||
<input id="login-username" name="username" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} />
|
||||
<div className="login-password-label"><label htmlFor="login-password">Password</label>{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}</div>
|
||||
<div className="login-password-field">
|
||||
<input id="login-password" name="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} />
|
||||
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{showPassword && <path d="m3 3 18 18" />}</svg>
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Sign in</h1>
|
||||
<p className="lede">{loginHelpText}</p>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
if (!primaryMode) {
|
||||
event.preventDefault()
|
||||
setError('Login is currently disabled. Contact an administrator.')
|
||||
return
|
||||
}
|
||||
void submit(event, primaryMode)
|
||||
}}
|
||||
className="auth-form"
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="auth-actions">
|
||||
{loginOptions.showJellyfinLogin ? (
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
||||
{loginOptions.showLocalLogin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={loading}
|
||||
onClick={(event) => submit(event, 'local')}
|
||||
>
|
||||
Sign in with Magent account
|
||||
</button>
|
||||
) : null}
|
||||
{loginOptions.showForgotPassword ? (
|
||||
<a className="ghost-button" href="/forgot-password">
|
||||
Forgot password?
|
||||
</a>
|
||||
) : null}
|
||||
{loginOptions.showSignupLink ? (
|
||||
<a className="ghost-button" href="/signup">
|
||||
Have an invite? Create your account (Jellyfin + Magent)
|
||||
</a>
|
||||
) : null}
|
||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></footer>}
|
||||
</section>
|
||||
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type MediaType = 'movie' | 'tv'
|
||||
|
||||
type DiscoveryResult = {
|
||||
title: string
|
||||
year?: number | null
|
||||
type: MediaType
|
||||
tmdbId: number
|
||||
requestId?: number | null
|
||||
statusLabel?: string | null
|
||||
overview?: string | null
|
||||
posterPath?: string | null
|
||||
backdropPath?: string | null
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
media: DiscoveryResult & {
|
||||
seasons: Array<{
|
||||
seasonNumber: number
|
||||
name: string
|
||||
episodeCount: number
|
||||
airDate?: string | null
|
||||
}>
|
||||
existingRequestId?: number | null
|
||||
}
|
||||
destination: {
|
||||
collector: 'Sonarr' | 'Radarr'
|
||||
serverName: string
|
||||
defaultProfileId: number
|
||||
profiles: Array<{ id: number; name: string }>
|
||||
}
|
||||
}
|
||||
|
||||
type OperationEvent = {
|
||||
id: string
|
||||
service: string
|
||||
state: 'active' | 'complete' | 'error'
|
||||
message: string
|
||||
duration_ms?: number | null
|
||||
status_code?: number | null
|
||||
}
|
||||
|
||||
type OperationProgress = {
|
||||
status: 'running' | 'complete' | 'error'
|
||||
duration_ms?: number | null
|
||||
events: OperationEvent[]
|
||||
}
|
||||
|
||||
const mediaChoices: Array<{
|
||||
type: MediaType
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
collector: 'Radarr' | 'Sonarr'
|
||||
icon: string
|
||||
}> = [
|
||||
{
|
||||
type: 'movie',
|
||||
eyebrow: 'Film',
|
||||
title: 'Movie',
|
||||
description: 'Find a film and send it through Seerr to Radarr.',
|
||||
collector: 'Radarr',
|
||||
icon: '/service-icons/radarr.svg',
|
||||
},
|
||||
{
|
||||
type: 'tv',
|
||||
eyebrow: 'Series',
|
||||
title: 'TV show',
|
||||
description: 'Choose a series, the seasons you want, and send it to Sonarr.',
|
||||
collector: 'Sonarr',
|
||||
icon: '/service-icons/sonarr.svg',
|
||||
},
|
||||
]
|
||||
|
||||
const artworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w342') => {
|
||||
if (!path) return null
|
||||
return `https://image.tmdb.org/t/p/${size}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
const apiError = async (response: Response, fallback: string) => {
|
||||
try {
|
||||
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
|
||||
} catch {
|
||||
// The upstream response was not JSON. Use the friendly fallback below.
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const formatDuration = (milliseconds?: number | null) => {
|
||||
if (milliseconds == null) return null
|
||||
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`
|
||||
}
|
||||
|
||||
export default function NewRequestClient() {
|
||||
const router = useRouter()
|
||||
const searchSectionRef = useRef<HTMLElement | null>(null)
|
||||
const resultsSectionRef = useRef<HTMLElement | null>(null)
|
||||
const configureSectionRef = useRef<HTMLElement | null>(null)
|
||||
const [mediaType, setMediaType] = useState<MediaType | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchAttempted, setSearchAttempted] = useState(false)
|
||||
const [results, setResults] = useState<DiscoveryResult[]>([])
|
||||
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
|
||||
const [options, setOptions] = useState<RequestOptions | null>(null)
|
||||
const [loadingOptions, setLoadingOptions] = useState(false)
|
||||
const [profileId, setProfileId] = useState<number | null>(null)
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) router.push('/login')
|
||||
}, [router])
|
||||
|
||||
const resetAfterType = (nextType: MediaType) => {
|
||||
setMediaType(nextType)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSearchAttempted(false)
|
||||
setSelected(null)
|
||||
setOptions(null)
|
||||
setProfileId(null)
|
||||
setSelectedSeasons([])
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
|
||||
}
|
||||
|
||||
const runSearch = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!mediaType) return
|
||||
const term = query.trim()
|
||||
if (!term) {
|
||||
setError('Enter a title to search for.')
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setSearchAttempted(true)
|
||||
setSelected(null)
|
||||
setOptions(null)
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const params = new URLSearchParams({ query: term, media_type: mediaType })
|
||||
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`))
|
||||
const payload = await response.json()
|
||||
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
|
||||
? payload.results
|
||||
.filter((item: any) => item?.type === mediaType && Number(item?.tmdbId) > 0)
|
||||
.map((item: any) => ({
|
||||
title: String(item?.title || 'Untitled'),
|
||||
year: typeof item?.year === 'number' ? item.year : null,
|
||||
type: mediaType,
|
||||
tmdbId: Number(item.tmdbId),
|
||||
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
|
||||
overview: typeof item?.overview === 'string' ? item.overview : null,
|
||||
posterPath: item?.posterPath ?? null,
|
||||
backdropPath: item?.backdropPath ?? null,
|
||||
}))
|
||||
: []
|
||||
setResults(mapped)
|
||||
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
||||
} catch (caught) {
|
||||
setResults([])
|
||||
setError(caught instanceof Error ? caught.message : 'Search is unavailable right now.')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectResult = async (item: DiscoveryResult) => {
|
||||
setSelected(item)
|
||||
setOptions(null)
|
||||
setProfileId(null)
|
||||
setSelectedSeasons([])
|
||||
setOperation(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
if (item.requestId) {
|
||||
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingOptions(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) })
|
||||
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error(await apiError(response, `Could not load request options (${response.status}).`))
|
||||
const payload = (await response.json()) as RequestOptions
|
||||
const refreshedSelection: DiscoveryResult = {
|
||||
...item,
|
||||
title: payload.media.title || item.title,
|
||||
year: payload.media.year ?? item.year,
|
||||
overview: payload.media.overview || item.overview,
|
||||
posterPath: payload.media.posterPath || item.posterPath,
|
||||
backdropPath: payload.media.backdropPath || item.backdropPath,
|
||||
requestId: payload.media.existingRequestId || item.requestId,
|
||||
statusLabel: payload.media.existingRequestId ? 'Already requested' : item.statusLabel,
|
||||
}
|
||||
setSelected(refreshedSelection)
|
||||
if (payload.media.existingRequestId) {
|
||||
setResults((current) => current.map((result) => result.tmdbId === item.tmdbId && result.type === item.type
|
||||
? refreshedSelection
|
||||
: result))
|
||||
return
|
||||
}
|
||||
setOptions(payload)
|
||||
setProfileId(payload.destination.defaultProfileId)
|
||||
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
|
||||
} finally {
|
||||
setLoadingOptions(false)
|
||||
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
|
||||
}
|
||||
}
|
||||
|
||||
const pollOperation = async (operationId: string) => {
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/operations/${operationId}`)
|
||||
if (response.ok) setOperation((await response.json()) as OperationProgress)
|
||||
} catch {
|
||||
// The request response remains authoritative if a progress poll is interrupted.
|
||||
}
|
||||
}
|
||||
|
||||
const submitRequest = async () => {
|
||||
if (!selected || !options || !profileId) return
|
||||
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
||||
setError('Select at least one season.')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`
|
||||
setOperation({ status: 'running', events: [] })
|
||||
const interval = window.setInterval(() => void pollOperation(operationId), 500)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Magent-Operation-ID': operationId,
|
||||
'X-Magent-Operation-Label': `Requesting ${selected.title}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mediaType: selected.type,
|
||||
tmdbId: selected.tmdbId,
|
||||
profileId,
|
||||
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
||||
}),
|
||||
})
|
||||
await pollOperation(operationId)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`))
|
||||
const payload = await response.json()
|
||||
const requestId = typeof payload?.requestId === 'number' ? payload.requestId : null
|
||||
setSelected((current) => current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current)
|
||||
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
|
||||
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
|
||||
: item))
|
||||
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.')
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
|
||||
} finally {
|
||||
window.clearInterval(interval)
|
||||
await pollOperation(operationId)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setEverySeason = (checked: boolean) => {
|
||||
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : [])
|
||||
}
|
||||
|
||||
const selectedPoster = artworkUrl(selected?.posterPath, 'w185')
|
||||
const currentFlowStep = success
|
||||
? 5
|
||||
: selected
|
||||
? 4
|
||||
: searchAttempted
|
||||
? 3
|
||||
: mediaType
|
||||
? 2
|
||||
: 1
|
||||
|
||||
return (
|
||||
<main className="card request-portal-page">
|
||||
<header className="request-portal-hero">
|
||||
<div>
|
||||
<span className="section-kicker">New requests</span>
|
||||
<h1>Find something worth watching.</h1>
|
||||
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
|
||||
</div>
|
||||
<div className="request-portal-route">
|
||||
<span>Seerr</span><i aria-hidden="true" />
|
||||
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
|
||||
<span>Grizzlyflix</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol className="request-master-stepper" aria-label="New request progress">
|
||||
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
|
||||
const step = index + 1
|
||||
return (
|
||||
<li key={label} className={step === currentFlowStep ? 'is-active' : step < currentFlowStep ? 'is-complete' : ''}>
|
||||
<span>{step < currentFlowStep ? '✓' : step}</span>
|
||||
<strong>{label}</strong>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{error && <div className="error-banner request-flow-alert">{error}</div>}
|
||||
{success && <div className="status-banner request-flow-alert">{success}</div>}
|
||||
|
||||
<section className="request-flow-stage is-current">
|
||||
<div className="request-flow-heading">
|
||||
<span className="request-flow-number">01</span>
|
||||
<div><span>Start here</span><h2>What are you looking for?</h2></div>
|
||||
</div>
|
||||
<div className="request-type-grid">
|
||||
{mediaChoices.map((choice) => (
|
||||
<button
|
||||
key={choice.type}
|
||||
type="button"
|
||||
className={`request-type-card ${mediaType === choice.type ? 'is-selected' : ''}`}
|
||||
onClick={() => resetAfterType(choice.type)}
|
||||
aria-pressed={mediaType === choice.type}
|
||||
>
|
||||
<span className="request-type-card-body">
|
||||
<span className="request-service-icon">
|
||||
<img src={choice.icon} alt={`${choice.collector} logo`} />
|
||||
</span>
|
||||
<span className="request-type-card-copy">
|
||||
<span>{choice.eyebrow}</span>
|
||||
<strong>{choice.title}</strong>
|
||||
<span className="request-type-description">{choice.description}</span>
|
||||
<b>{mediaType === choice.type ? 'Selected' : `Choose ${choice.title.toLowerCase()}`}</b>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mediaType && (
|
||||
<section ref={searchSectionRef} className="request-flow-stage is-current">
|
||||
<div className="request-flow-heading">
|
||||
<span className="request-flow-number">02</span>
|
||||
<div><span>{mediaType === 'tv' ? 'TV show selected' : 'Movie selected'}</span><h2>Search for the title</h2></div>
|
||||
</div>
|
||||
<form className="request-flow-search" onSubmit={runSearch}>
|
||||
<label htmlFor="request-title-search">Title</label>
|
||||
<div>
|
||||
<input
|
||||
id="request-title-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={mediaType === 'tv' ? 'Search TV shows' : 'Search movies'}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" disabled={searching}>{searching ? 'Searching…' : 'Search Seerr'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{mediaType && searchAttempted && !searching && (
|
||||
<section ref={resultsSectionRef} className="request-flow-stage is-current">
|
||||
<div className="request-flow-heading">
|
||||
<span className="request-flow-number">03</span>
|
||||
<div><span>Search results</span><h2>{results.length ? 'Select the right title' : 'No matches found'}</h2></div>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="request-flow-empty">
|
||||
<strong>Nothing matched “{query.trim()}”.</strong>
|
||||
<p>Check the spelling or try a shorter title.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="request-result-grid">
|
||||
{results.map((item) => {
|
||||
const poster = artworkUrl(item.posterPath)
|
||||
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
|
||||
return (
|
||||
<button
|
||||
key={`${item.type}:${item.tmdbId}`}
|
||||
type="button"
|
||||
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
|
||||
onClick={() => void selectResult(item)}
|
||||
>
|
||||
<span className="request-result-poster">
|
||||
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
|
||||
</span>
|
||||
<span className="request-result-copy">
|
||||
<small>{item.type === 'tv' ? 'TV show' : 'Movie'}{item.year ? ` · ${item.year}` : ''}</small>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.overview || 'Select this title to view the available request options.'}</p>
|
||||
<b>{item.requestId ? item.statusLabel || 'Already requested' : isSelected ? 'Selected' : 'Select title'}</b>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<section ref={configureSectionRef} className="request-flow-stage is-current request-configure-stage">
|
||||
<div className="request-flow-heading">
|
||||
<span className="request-flow-number">04</span>
|
||||
<div><span>Final step</span><h2>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
|
||||
</div>
|
||||
|
||||
<div className="request-selection-summary">
|
||||
<span className="request-selection-poster">
|
||||
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
|
||||
</span>
|
||||
<div>
|
||||
<small>{selected.type === 'tv' ? 'TV show' : 'Movie'}{selected.year ? ` · ${selected.year}` : ''}</small>
|
||||
<h3>{selected.title}</h3>
|
||||
<p>{selected.overview || 'Ready to configure.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.requestId ? (
|
||||
<div className="request-existing-state">
|
||||
<div><span>Current status</span><strong>{selected.statusLabel || 'Already requested'}</strong><p>Request #{selected.requestId} is already being tracked by Magent.</p></div>
|
||||
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Open request</button>
|
||||
</div>
|
||||
) : loadingOptions ? (
|
||||
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}…</strong><p>Loading valid profiles and request choices.</p></div>
|
||||
) : options ? (
|
||||
<div className="request-options-layout">
|
||||
{selected.type === 'tv' && (
|
||||
<fieldset className="request-season-picker">
|
||||
<legend>Which seasons?</legend>
|
||||
<div className="request-season-actions">
|
||||
<button type="button" onClick={() => setEverySeason(true)}>Select all</button>
|
||||
<button type="button" onClick={() => setEverySeason(false)}>Clear</button>
|
||||
</div>
|
||||
<div className="request-season-grid">
|
||||
{options.media.seasons.map((season) => (
|
||||
<label key={season.seasonNumber} className={selectedSeasons.includes(season.seasonNumber) ? 'is-selected' : ''}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeasons.includes(season.seasonNumber)}
|
||||
onChange={(event) => setSelectedSeasons((current) => event.target.checked
|
||||
? [...current, season.seasonNumber].sort((a, b) => a - b)
|
||||
: current.filter((value) => value !== season.seasonNumber))}
|
||||
/>
|
||||
<span><strong>{season.name}</strong><small>{season.episodeCount} episode{season.episodeCount === 1 ? '' : 's'}</small></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<label className="request-profile-field">
|
||||
<span>Quality profile</span>
|
||||
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
|
||||
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
|
||||
</label>
|
||||
|
||||
<div className="request-submit-bar">
|
||||
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
|
||||
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
||||
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{operation && (
|
||||
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
|
||||
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
|
||||
<div>
|
||||
{operation.events.map((event) => (
|
||||
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
|
||||
))}
|
||||
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request…</span></p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && selected.requestId && (
|
||||
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import NewRequestClient from './NewRequestClient'
|
||||
|
||||
export const metadata = {
|
||||
title: 'New Requests | Magent',
|
||||
}
|
||||
|
||||
export default function NewRequestsPage() {
|
||||
return <NewRequestClient />
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+299
-118
@@ -16,7 +16,6 @@ const normalizeRecentResults = (items: any[]) =>
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: item.year,
|
||||
type: item.type,
|
||||
statusLabel: item.statusLabel,
|
||||
artwork: item.artwork,
|
||||
createdAt: item.createdAt ?? null,
|
||||
@@ -42,9 +41,8 @@ export default function HomePage() {
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
type?: string
|
||||
statusLabel?: string
|
||||
artwork?: { poster_url?: string; backdrop_url?: string }
|
||||
artwork?: { poster_url?: string }
|
||||
createdAt?: string | null
|
||||
}[]
|
||||
>([])
|
||||
@@ -66,6 +64,14 @@ export default function HomePage() {
|
||||
const [recentDays, setRecentDays] = useState(90)
|
||||
const [recentStage, setRecentStage] = useState('all')
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [servicesStatus, setServicesStatus] = useState<
|
||||
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
|
||||
>(null)
|
||||
const [servicesLoading, setServicesLoading] = useState(false)
|
||||
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -78,6 +84,61 @@ export default function HomePage() {
|
||||
void runSearch(trimmed)
|
||||
}
|
||||
|
||||
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
|
||||
const updateServiceStatus = (name: string, status: string, message?: string) => {
|
||||
setServicesStatus((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
services: prev.services.map((service) =>
|
||||
service.name === name ? { ...service, status, message } : service
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const testService = async (name: string) => {
|
||||
const slug = toServiceSlug(name)
|
||||
setServiceTesting((prev) => ({ ...prev, [name]: true }))
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Service test failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data?.status ?? 'unknown'
|
||||
const message =
|
||||
data?.message ||
|
||||
(status === 'up'
|
||||
? 'API OK'
|
||||
: status === 'down'
|
||||
? 'API unreachable'
|
||||
: status === 'degraded'
|
||||
? 'Health warnings'
|
||||
: status === 'not_configured'
|
||||
? 'Not configured'
|
||||
: 'Unknown')
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
|
||||
updateServiceStatus(name, status, data?.message)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
|
||||
} finally {
|
||||
setServiceTesting((prev) => ({ ...prev, [name]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
@@ -137,7 +198,45 @@ export default function HomePage() {
|
||||
if (!authReady) {
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
setServicesLoading(true)
|
||||
setServicesError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/status/services`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Service status failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setServicesStatus(data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setServicesError('Service status is not available right now.')
|
||||
} finally {
|
||||
setServicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
if (liveStreamConnected) {
|
||||
return
|
||||
}
|
||||
const timer = setInterval(load, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [authReady, liveStreamConnected, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
if (!getToken()) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
@@ -158,8 +257,14 @@ export default function HomePage() {
|
||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
||||
source = new EventSource(streamUrl)
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
@@ -176,14 +281,29 @@ export default function HomePage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (payload.type === 'home_services') {
|
||||
if (payload.status && typeof payload.status === 'object') {
|
||||
setServicesStatus(payload.status)
|
||||
setServicesError(null)
|
||||
setServicesLoading(false)
|
||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
setServicesError('Service status is not available right now.')
|
||||
setServicesLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed) return
|
||||
console.error(error)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +311,7 @@ export default function HomePage() {
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source?.close()
|
||||
}
|
||||
}, [authReady, recentDays, recentStage])
|
||||
@@ -241,170 +362,230 @@ export default function HomePage() {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const activeRecentCount = recent.filter((item) => {
|
||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
||||
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
||||
}).length
|
||||
const readyRecentCount = recent.filter((item) => {
|
||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
||||
return label.includes('ready') || label.includes('available')
|
||||
}).length
|
||||
|
||||
const requestCardState = (value?: string) => {
|
||||
const label = String(value ?? '').toLowerCase()
|
||||
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
|
||||
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
|
||||
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
|
||||
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
|
||||
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card home-page">
|
||||
<section className="home-command">
|
||||
<div className="home-command-copy">
|
||||
<span className="section-kicker">Media operations</span>
|
||||
<h1>My requests</h1>
|
||||
<p>Manage and track your media processing queue.</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="home-search">
|
||||
<label htmlFor="request-search">Title, year, or request number</label>
|
||||
<div className="home-search-row">
|
||||
<input
|
||||
id="request-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Dune 2021 or 1289"
|
||||
/>
|
||||
<button type="submit">Find request</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{(searchError || searchResults.length > 0) && (
|
||||
<section className="home-search-results" aria-live="polite">
|
||||
<div className="home-section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Search results</span>
|
||||
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={() => {
|
||||
setSearchResults([])
|
||||
setSearchError(null)
|
||||
}}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{searchError ? (
|
||||
<div className="error-banner">{searchError}</div>
|
||||
) : (
|
||||
<div className="home-result-grid">
|
||||
{searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.title || 'Untitled'}-${index}`}
|
||||
type="button"
|
||||
className="home-result-card"
|
||||
disabled={!item.requestId}
|
||||
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||
<main className="card">
|
||||
<div className="layout-grid">
|
||||
<section className="recent centerpiece">
|
||||
<div className="system-status">
|
||||
<div className="system-header">
|
||||
<h2>System status</h2>
|
||||
<span
|
||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
||||
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
||||
{servicesLoading
|
||||
? 'Checking services...'
|
||||
: servicesError
|
||||
? 'Status not available yet'
|
||||
: servicesStatus?.overall === 'up'
|
||||
? 'Services are up and running'
|
||||
: servicesStatus?.overall === 'down'
|
||||
? 'Something is down'
|
||||
: 'Some services need attention'}
|
||||
</span>
|
||||
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
||||
</div>
|
||||
<div className="system-list">
|
||||
{(() => {
|
||||
const order = [
|
||||
'Seerr',
|
||||
'Sonarr',
|
||||
'Radarr',
|
||||
'Prowlarr',
|
||||
'qBittorrent',
|
||||
'Jellyfin',
|
||||
]
|
||||
const items = servicesStatus?.services ?? []
|
||||
return order.map((name) => {
|
||||
const item = items.find((entry) => entry.name === name)
|
||||
const status = item?.status ?? 'unknown'
|
||||
const testing = serviceTesting[name] ?? false
|
||||
return (
|
||||
<div key={name} className={`system-item system-${status}`}>
|
||||
<span className="system-dot" />
|
||||
<div className="system-meta">
|
||||
<span className="system-name">{name}</span>
|
||||
{serviceTestResults[name] && (
|
||||
<span className="system-test-message">{serviceTestResults[name]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-actions">
|
||||
<span className="system-state">
|
||||
{status === 'up'
|
||||
? 'Up'
|
||||
: status === 'down'
|
||||
? 'Down'
|
||||
: status === 'degraded'
|
||||
? 'Needs attention'
|
||||
: status === 'not_configured'
|
||||
? 'Not configured'
|
||||
: 'Unknown'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="system-test"
|
||||
onClick={() => void testService(name)}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="home-metric-strip" aria-label="Request summary">
|
||||
<div><span>In view</span><strong>{recent.length}</strong></div>
|
||||
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
||||
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
||||
</section>
|
||||
|
||||
<section className="recent home-recent">
|
||||
<div className="recent-header home-section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Request activity</span>
|
||||
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-header">
|
||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||
{authReady && (
|
||||
<div className="recent-filter-group">
|
||||
<label className="recent-filter">
|
||||
<span>Period</span>
|
||||
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
|
||||
<option value={0}>All time</option>
|
||||
<span>Show</span>
|
||||
<select
|
||||
value={recentDays}
|
||||
onChange={(event) => setRecentDays(Number(event.target.value))}
|
||||
>
|
||||
<option value={0}>All</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={60}>60 days</option>
|
||||
<option value={90}>90 days</option>
|
||||
<option value={180}>180 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{authReady && (
|
||||
<div className="request-filter-chips" aria-label="Filter requests by stage">
|
||||
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={recentStage === option.value ? 'is-active' : undefined}
|
||||
onClick={() => setRecentStage(option.value)}
|
||||
<label className="recent-filter">
|
||||
<span>Stage</span>
|
||||
<select
|
||||
value={recentStage}
|
||||
onChange={(event) => setRecentStage(event.target.value)}
|
||||
>
|
||||
{option.value === 'working' ? <i aria-hidden="true" /> : null}
|
||||
{option.value === 'all' ? 'All' : option.label}
|
||||
</button>
|
||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="recent-grid home-recent-grid">
|
||||
</div>
|
||||
<div className="recent-grid">
|
||||
{recentLoading ? (
|
||||
<div className="loading-center">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<span className="loading-text">Loading recent requests...</span>
|
||||
<span className="loading-text">Loading recent requests…</span>
|
||||
</div>
|
||||
) : recentError ? (
|
||||
<div className="error-banner">{recentError}</div>
|
||||
<button type="button" disabled>
|
||||
{recentError}
|
||||
</button>
|
||||
) : recent.length === 0 ? (
|
||||
<div className="home-empty-state">
|
||||
<strong>No requests match these filters</strong>
|
||||
<span>Try a wider period or a different stage.</span>
|
||||
</div>
|
||||
<button type="button" disabled>
|
||||
No recent requests found
|
||||
</button>
|
||||
) : (
|
||||
recent.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/requests/${item.id}`)}
|
||||
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
|
||||
className="recent-card"
|
||||
>
|
||||
{item.artwork?.poster_url ? (
|
||||
{item.artwork?.poster_url && (
|
||||
<img
|
||||
className="recent-poster"
|
||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
||||
)}
|
||||
<span className="recent-info">
|
||||
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
||||
<span className="recent-title">
|
||||
{item.title || 'Untitled'}
|
||||
{item.year ? ` (${item.year})` : ''}
|
||||
</span>
|
||||
<span className="recent-meta">
|
||||
{item.statusLabel || 'Status not available yet'} · Request {item.id}
|
||||
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
||||
{item.id}
|
||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="side-panel">
|
||||
<section className="main-panel find-panel">
|
||||
<div className="find-header">
|
||||
<h1>Search all requests</h1>
|
||||
<p className="lede">
|
||||
Search any request by title + year or request number and see whether it already
|
||||
exists in the system.
|
||||
</p>
|
||||
</div>
|
||||
<div className="find-controls">
|
||||
<form onSubmit={submit} className="search search-row">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="e.g. Dune 2021 or 1289"
|
||||
/>
|
||||
<button type="submit">Check status</button>
|
||||
</form>
|
||||
<div className="filters filters-compact">
|
||||
<div className="filter">
|
||||
<span>Type</span>
|
||||
<div className="pill-group">
|
||||
<button type="button">TV</button>
|
||||
<button type="button">Movie</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter">
|
||||
<span>Status</span>
|
||||
<div className="pill-group">
|
||||
<button type="button">Pending</button>
|
||||
<button type="button">Approved</button>
|
||||
<button type="button">Processing</button>
|
||||
<button type="button">Failed</button>
|
||||
<button type="button">Available</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="recent results-panel">
|
||||
<h2>Search results</h2>
|
||||
<div className="recent-grid">
|
||||
{searchError ? (
|
||||
<button type="button" disabled>
|
||||
{searchError}
|
||||
</button>
|
||||
) : searchResults.length === 0 ? (
|
||||
<button type="button" disabled>
|
||||
No matches yet
|
||||
</button>
|
||||
) : (
|
||||
searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.title || 'Untitled'}-${index}`}
|
||||
type="button"
|
||||
disabled={!item.requestId}
|
||||
onClick={() =>
|
||||
item.requestId && router.push(`/requests/${item.requestId}`)
|
||||
}
|
||||
>
|
||||
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
|
||||
{!item.requestId
|
||||
? '- not requested'
|
||||
: item.statusLabel
|
||||
? `- ${item.statusLabel}`
|
||||
: '- already requested'}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
import PortalClient from '../PortalClient'
|
||||
|
||||
export default function IssuePortalPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
}
|
||||
|
||||
+1139
-3
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RequestPortalPage() {
|
||||
redirect('/new-requests')
|
||||
}
|
||||
@@ -1,75 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
|
||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
|
||||
type OwnedInvite = {
|
||||
id: number; code: string; label?: string | null; description?: string | null
|
||||
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
||||
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
||||
is_usable?: boolean; created_at?: string | null
|
||||
type ProfileInfo = {
|
||||
username: string
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
}
|
||||
|
||||
type ProfileResponse = {
|
||||
user: ProfileInfo
|
||||
}
|
||||
|
||||
type OwnedInvite = {
|
||||
id: number
|
||||
code: string
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
recipient_email?: string | null
|
||||
max_uses?: number | null
|
||||
use_count: number
|
||||
remaining_uses?: number | null
|
||||
enabled: boolean
|
||||
expires_at?: string | null
|
||||
is_expired?: boolean
|
||||
is_usable?: boolean
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
type OwnedInvitesResponse = {
|
||||
invites?: OwnedInvite[]
|
||||
invite_access?: { enabled?: boolean; managed_by_master?: boolean }
|
||||
master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null
|
||||
count?: number
|
||||
invite_access?: {
|
||||
enabled?: boolean
|
||||
managed_by_master?: boolean
|
||||
}
|
||||
master_invite?: {
|
||||
id: number
|
||||
code: string
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
max_uses?: number | null
|
||||
enabled?: boolean
|
||||
expires_at?: string | null
|
||||
is_usable?: boolean
|
||||
} | null
|
||||
}
|
||||
type InviteForm = {
|
||||
code: string; label: string; description: string; recipient_email: string
|
||||
enabled: boolean; message: string
|
||||
}
|
||||
type DeliveryMethod = '' | 'manual' | 'email'
|
||||
|
||||
const defaultInviteForm = (): InviteForm => ({
|
||||
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '',
|
||||
type OwnedInviteForm = {
|
||||
code: string
|
||||
label: string
|
||||
description: string
|
||||
recipient_email: string
|
||||
max_uses: string
|
||||
expires_at: string
|
||||
enabled: boolean
|
||||
send_email: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
recipient_email: '',
|
||||
max_uses: '',
|
||||
expires_at: '',
|
||||
enabled: true,
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return 'Never'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString()
|
||||
if (Number.isNaN(date.valueOf())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||
|
||||
export default function ProfileInvitesPage() {
|
||||
const router = useRouter()
|
||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||
const [inviteSaving, setInviteSaving] = useState(false)
|
||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [flowStep, setFlowStep] = useState(1)
|
||||
const [useCustomCode, setUseCustomCode] = useState(false)
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>('')
|
||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
|
||||
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null)
|
||||
|
||||
const signupBaseUrl = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '/signup'
|
||||
return `${window.location.origin}/signup`
|
||||
}, [])
|
||||
|
||||
const loadInvites = async () => {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
const loadPage = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
const [profileResponse, invitesResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/profile`),
|
||||
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||
])
|
||||
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||
if (profileResponse.status === 401 || invitesResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error('Could not load your invite workspace.')
|
||||
throw new Error('Could not load invite tools.')
|
||||
}
|
||||
const data = (await response.json()) as OwnedInvitesResponse
|
||||
setInvites(Array.isArray(data.invites) ? data.invites : [])
|
||||
setInviteAccessEnabled(Boolean(data.invite_access?.enabled))
|
||||
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master))
|
||||
setMasterInvite(data.master_invite ?? null)
|
||||
const [profileData, inviteData] = (await Promise.all([
|
||||
profileResponse.json(),
|
||||
invitesResponse.json(),
|
||||
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||
const user = profileData?.user ?? {}
|
||||
setProfile({
|
||||
username: user?.username ?? 'Unknown',
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
})
|
||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||||
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||||
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,21 +141,10 @@ export default function ProfileInvitesPage() {
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`)
|
||||
if (!profileResponse.ok) {
|
||||
if (profileResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error('Could not load your profile.')
|
||||
}
|
||||
const profileData = await profileResponse.json()
|
||||
setProfile(profileData?.user ?? null)
|
||||
await loadInvites()
|
||||
await loadPage()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.')
|
||||
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -101,65 +152,80 @@ export default function ProfileInvitesPage() {
|
||||
void load()
|
||||
}, [router])
|
||||
|
||||
const resetFlow = () => {
|
||||
setEditingId(null)
|
||||
setFlowStep(1)
|
||||
setUseCustomCode(false)
|
||||
setDeliveryMethod('')
|
||||
setInviteForm(defaultInviteForm())
|
||||
const resetInviteEditor = () => {
|
||||
setInviteEditingId(null)
|
||||
setInviteForm(defaultOwnedInviteForm())
|
||||
}
|
||||
|
||||
const editInvite = (invite: OwnedInvite) => {
|
||||
setEditingId(invite.id)
|
||||
setCreatedInvite(null)
|
||||
setFlowStep(4)
|
||||
setUseCustomCode(true)
|
||||
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
||||
setInviteEditingId(invite.id)
|
||||
setInviteError(null)
|
||||
setInviteStatus(null)
|
||||
setInviteForm({
|
||||
code: invite.code,
|
||||
code: invite.code ?? '',
|
||||
label: invite.label ?? '',
|
||||
description: invite.description ?? '',
|
||||
recipient_email: invite.recipient_email ?? '',
|
||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||
expires_at: invite.expires_at ?? '',
|
||||
enabled: invite.enabled !== false,
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const reloadInvites = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error(`Invite refresh failed: ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as OwnedInvitesResponse
|
||||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||||
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||||
setMasterInviteTemplate(data?.master_invite ?? null)
|
||||
}
|
||||
|
||||
const saveInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const inviteName = inviteForm.label.trim()
|
||||
const recipientEmail = inviteForm.recipient_email.trim()
|
||||
if (!inviteName) {
|
||||
setError('Give this invite a name so you can recognise it later.')
|
||||
if (!recipientEmail) {
|
||||
setInviteError('Recipient email is required.')
|
||||
setInviteStatus(null)
|
||||
return
|
||||
}
|
||||
if (!deliveryMethod) {
|
||||
setError('Choose how you want to deliver the invite.')
|
||||
if (!isValidEmail(recipientEmail)) {
|
||||
setInviteError('Recipient email must be valid.')
|
||||
setInviteStatus(null)
|
||||
return
|
||||
}
|
||||
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
|
||||
setError('Enter a valid recipient email address.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setInviteSaving(true)
|
||||
setInviteError(null)
|
||||
setInviteStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(
|
||||
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`,
|
||||
inviteEditingId == null
|
||||
? `${baseUrl}/auth/profile/invites`
|
||||
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||||
{
|
||||
method: editingId == null ? 'POST' : 'PUT',
|
||||
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: useCustomCode ? inviteForm.code || null : null,
|
||||
label: inviteName,
|
||||
code: inviteForm.code || null,
|
||||
label: inviteForm.label || null,
|
||||
description: inviteForm.description || null,
|
||||
recipient_email: deliveryMethod === 'email' ? recipientEmail : null,
|
||||
recipient_email: recipientEmail,
|
||||
max_uses: inviteForm.max_uses || null,
|
||||
expires_at: inviteForm.expires_at || null,
|
||||
enabled: inviteForm.enabled,
|
||||
send_email: editingId == null && deliveryMethod === 'email',
|
||||
send_email: inviteForm.send_email,
|
||||
message: inviteForm.message || null,
|
||||
}),
|
||||
}
|
||||
@@ -170,136 +236,400 @@ export default function ProfileInvitesPage() {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error((await response.text()) || 'Could not save the invite.')
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Invite save failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
const savedInvite = data?.invite as OwnedInvite | undefined
|
||||
setStatus(
|
||||
data?.email?.status === 'ok'
|
||||
? `Invite created and emailed to ${data.email.recipient_email}.`
|
||||
: data?.email?.status === 'error'
|
||||
? `Invite created, but the email could not be sent: ${data.email.detail}`
|
||||
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.'
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (data?.email?.status === 'ok') {
|
||||
setInviteStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||
)
|
||||
resetFlow()
|
||||
if (editingId == null && savedInvite) setCreatedInvite(savedInvite)
|
||||
await loadInvites()
|
||||
} else if (data?.email?.status === 'error') {
|
||||
setInviteStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||
)
|
||||
} else {
|
||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
}
|
||||
resetInviteEditor()
|
||||
await reloadInvites()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not save the invite.')
|
||||
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setInviteSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteInvite = async (invite: OwnedInvite) => {
|
||||
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return
|
||||
setError(null)
|
||||
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||||
setInviteError(null)
|
||||
setInviteStatus(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.')
|
||||
if (editingId === invite.id) resetFlow()
|
||||
setStatus(`Deleted ${invite.label || invite.code}.`)
|
||||
await loadInvites()
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Invite delete failed')
|
||||
}
|
||||
if (inviteEditingId === invite.id) {
|
||||
resetInviteEditor()
|
||||
}
|
||||
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||||
await reloadInvites()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not delete the invite.')
|
||||
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
||||
} catch {
|
||||
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||||
} else {
|
||||
window.prompt('Copy invite link', url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
window.prompt('Copy invite link', url)
|
||||
}
|
||||
}
|
||||
|
||||
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
|
||||
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6))
|
||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : ''
|
||||
|
||||
if (loading) return <main className="card">Loading invite workspace…</main>
|
||||
if (loading) {
|
||||
return <main className="card">Loading invite tools...</main>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<div className="user-directory-panel-header profile-page-header">
|
||||
<div>
|
||||
<span className="section-kicker">04 · Invites</span>
|
||||
<h1>Invite someone to Grizzlyflix</h1>
|
||||
<p className="lede">Create a secure invitation one simple decision at a time.</p>
|
||||
<h1>My invites</h1>
|
||||
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
||||
Back to profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
{profile ? (
|
||||
<div className="status-banner">
|
||||
Signed in as <strong>{profile.username}</strong> ({profile.role}).
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="profile-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
onClick={() => router.push('/profile?tab=activity')}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected className="is-active">
|
||||
My invites
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
onClick={() => router.push('/profile?tab=security')}
|
||||
>
|
||||
Security
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||
|
||||
{!canManageInvites ? (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Invites are not enabled for your account</h2>
|
||||
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
|
||||
<h2>Invite access is disabled</h2>
|
||||
<p className="lede">
|
||||
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
|
||||
</p>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => router.push('/profile')}>
|
||||
Return to profile
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||
<div className="invite-flow-heading">
|
||||
<div><span className="eyebrow">Invite flow</span><h2>{editingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2><p className="lede">Set up the invite one decision at a time.</p></div>
|
||||
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>}
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Invite workspace</h2>
|
||||
<p className="lede">
|
||||
{inviteManagedByMaster
|
||||
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
|
||||
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdInvite && editingId == null ? (
|
||||
<div className="invite-created-card" role="status">
|
||||
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3>
|
||||
<p>{createdInvite.recipient_email ? `The invite was emailed to ${createdInvite.recipient_email}.` : 'Copy this link and send it to the person you are inviting.'}</p>
|
||||
<div className="invite-created-link"><input value={createdInviteUrl} readOnly aria-label="Created invite link" /><button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button></div>
|
||||
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button>
|
||||
<div className="profile-invites-layout">
|
||||
<div className="profile-invite-form-card">
|
||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||
<p className="meta profile-invite-form-lede">
|
||||
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
|
||||
</p>
|
||||
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||
<div className="status-banner profile-invite-master-banner">
|
||||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={saveInvite} className="invite-flow-form">
|
||||
<ol className="invite-flow-route" aria-label="Invite creation progress">
|
||||
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
|
||||
const step = index + 1
|
||||
return <li key={label} className={step === flowStep ? 'is-active' : step < flowStep ? 'is-complete' : ''}><span>{String(step).padStart(2, '0')}</span><strong>{label}</strong></li>
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
||||
<header><span className="invite-flow-number">01</span><div><span className="eyebrow">Identity</span><h3>Who is this invite for?</h3><p>Give it a name that will make sense when you return later.</p></div></header>
|
||||
<div className="invite-flow-fields">
|
||||
<label><span>Invite name</span><input value={inviteForm.label} onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))} placeholder="Family, that guy from work, the neighbour" /></label>
|
||||
<label className="invite-flow-choice-line"><input type="checkbox" checked={useCustomCode} disabled={editingId != null} onChange={(event) => { setUseCustomCode(event.target.checked); if (!event.target.checked) setInviteForm((current) => ({ ...current, code: '' })) }} /><span><strong>Choose a custom invite code</strong><small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small></span></label>
|
||||
{useCustomCode && <label><span>Custom code</span><input value={inviteForm.code} disabled={editingId != null} onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))} placeholder="At least 6 letters or numbers" /><small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small></label>}
|
||||
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>}
|
||||
) : null}
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Identity</span>
|
||||
<small>Optional code and label for easier tracking.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{flowStep >= 2 && <section className={`invite-flow-step ${flowStep > 2 ? 'is-complete' : 'is-active'}`}>
|
||||
<header><span className="invite-flow-number">02</span><div><span className="eyebrow">Description</span><h3>Add a welcome note</h3><p>This optional message is shown on the sign-up page.</p></div></header>
|
||||
<div className="invite-flow-fields"><label><span>Welcome note (optional)</span><textarea rows={3} value={inviteForm.description} onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))} placeholder="Welcome to Grizzlyflix. Use this link to create your account." /></label>{flowStep === 2 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(1)}>Back</button><button type="button" className="ghost-button" onClick={() => { setInviteForm((current) => ({ ...current, description: '' })); setFlowStep(3) }}>Skip</button><button type="button" onClick={() => setFlowStep(3)}>Continue</button></div>}</div>
|
||||
</section>}
|
||||
|
||||
{flowStep >= 3 && <section className={`invite-flow-step ${flowStep > 3 ? 'is-complete' : 'is-active'}`}>
|
||||
<header><span className="invite-flow-number">03</span><div><span className="eyebrow">Access</span><h3>Account access is applied automatically</h3><p>Magent uses the safe invite policy configured by an administrator.</p></div></header>
|
||||
<div className="invite-flow-fields"><div className="invite-policy-note"><strong>Standard user access</strong><span>{inviteManagedByMaster && masterInvite ? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.` : 'This invite creates a standard user account using your configured defaults.'}</span></div>{flowStep === 3 && <div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(2)}>Back</button><button type="button" onClick={() => setFlowStep(4)}>Continue to delivery</button></div>}</div>
|
||||
</section>}
|
||||
|
||||
{flowStep >= 4 && <section className="invite-flow-step is-active">
|
||||
<header><span className="invite-flow-number">04</span><div><span className="eyebrow">Delivery</span><h3>How will they receive it?</h3><p>Copy the link yourself, or let Magent email it directly.</p></div></header>
|
||||
<div className="invite-flow-fields">
|
||||
<div className="invite-delivery-grid"><button type="button" className={deliveryMethod === 'manual' ? 'is-selected' : ''} onClick={() => { setDeliveryMethod('manual'); setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }}><span className="eyebrow">Manual</span><strong>Give me a link</strong><small>Magent creates the URL. You copy and share it yourself.</small></button><button type="button" className={deliveryMethod === 'email' ? 'is-selected' : ''} onClick={() => setDeliveryMethod('email')}><span className="eyebrow">Email</span><strong>Send it for me</strong><small>Magent emails the invitation and still gives you a copyable URL.</small></button></div>
|
||||
{deliveryMethod === 'manual' && <div className="invite-delivery-summary"><strong>Your link will appear as soon as the invite is created.</strong><span>No email address is required and Magent will not send a message.</span></div>}
|
||||
{deliveryMethod === 'email' && <div className="invite-flow-field-grid"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
|
||||
{editingId != null && <label className="invite-status-control"><input type="checkbox" checked={inviteForm.enabled} onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))} /><span><strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong><small>Disable this existing invite to stop its link from accepting sign-ups.</small></span></label>}
|
||||
<div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>Back</button><button type="submit" disabled={saving || !deliveryMethod || (deliveryMethod === 'email' && !isValidEmail(inviteForm.recipient_email))}>{saving ? 'Saving…' : editingId != null ? 'Save invite' : deliveryMethod === 'email' ? 'Create and email invite' : 'Create invite link'}</button></div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Code (optional)</span>
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||||
}
|
||||
placeholder="Family invite"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Description</span>
|
||||
<small>Optional note shown on the signup page.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Delivery</span>
|
||||
<small>Recipient email is required. You can also send the invite immediately after saving.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label>
|
||||
<span>Recipient email (required)</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={inviteForm.recipient_email}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
recipient_email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Required recipient email"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Delivery note</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.message}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
message: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Optional note to include in the email"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.send_email}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
send_email: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Send "You have been invited" email after saving
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Limits</span>
|
||||
<small>Usage cap and optional expiry date/time.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Max uses</span>
|
||||
<input
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Invite expiry (ISO datetime)</span>
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Status</span>
|
||||
<small>Enable or disable this invite before sharing.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving
|
||||
? 'Saving…'
|
||||
: inviteEditingId == null
|
||||
? 'Create invite'
|
||||
: 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="meta profile-invite-hint">
|
||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-invites-list">
|
||||
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
|
||||
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
||||
{invites.length === 0 ? (
|
||||
<div className="status-banner">You have not created any invites yet.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="admin-list-item">
|
||||
<div className="admin-list-item-main">
|
||||
<div className="admin-list-item-title-row">
|
||||
<code className="invite-code">{invite.code}</code>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||
</span>
|
||||
<span className="small-pill is-muted">
|
||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||
</span>
|
||||
</div>
|
||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||
{invite.description && (
|
||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||
{invite.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-meta-row">
|
||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||||
<span>
|
||||
Uses: {invite.use_count}
|
||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||
</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Created: {formatDate(invite.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => copyInviteLink(invite)}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => editInvite(invite)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
+399
-171
@@ -1,233 +1,461 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type ProfileInfo = {
|
||||
username: string
|
||||
email?: string | null
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
password_change_supported?: boolean
|
||||
password_provider?: 'local' | 'jellyfin' | null
|
||||
}
|
||||
|
||||
type ProfileStats = {
|
||||
total: number
|
||||
ready: number
|
||||
pending: number
|
||||
in_progress: number
|
||||
declined: number
|
||||
working: number
|
||||
partial: number
|
||||
approved: number
|
||||
last_request_at?: string | null
|
||||
share: number
|
||||
global_total: number
|
||||
most_active_user?: { username: string; total: number } | null
|
||||
}
|
||||
|
||||
type ActivityEntry = {
|
||||
ip: string
|
||||
user_agent: string
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
hit_count: number
|
||||
}
|
||||
|
||||
type ProfileActivity = {
|
||||
last_ip?: string | null
|
||||
last_user_agent?: string | null
|
||||
last_seen_at?: string | null
|
||||
device_count: number
|
||||
recent: ActivityEntry[]
|
||||
}
|
||||
|
||||
type ProfileResponse = {
|
||||
user: ProfileInfo
|
||||
stats?: { total: number; ready: number; in_progress: number }
|
||||
activity?: { recent: ActivityEntry[] }
|
||||
stats: ProfileStats
|
||||
activity: ProfileActivity
|
||||
}
|
||||
type Notice = { tone: 'status' | 'error'; message: string } | null
|
||||
type ProfileTab = 'overview' | 'security' | 'activity'
|
||||
const TABS: { key: ProfileTab; label: string }[] = [
|
||||
{ key: 'overview', label: 'Account' },
|
||||
{ key: 'security', label: 'Security' },
|
||||
{ key: 'activity', label: 'Activity' },
|
||||
]
|
||||
const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview'
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return 'Not recorded'
|
||||
|
||||
type ProfileTab = 'overview' | 'activity' | 'security'
|
||||
|
||||
const normalizeProfileTab = (value?: string | null): ProfileTab => {
|
||||
if (value === 'activity' || value === 'security') {
|
||||
return value
|
||||
}
|
||||
return 'overview'
|
||||
}
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return 'Never'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
if (Number.isNaN(date.valueOf())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
const deviceName = (agent: string) => {
|
||||
const value = (agent || '').toLowerCase()
|
||||
const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox'
|
||||
: value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser'
|
||||
const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android'
|
||||
: value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : ''
|
||||
return device ? `${browser} on ${device}` : browser
|
||||
}
|
||||
const responseMessage = async (response: Response, fallback: string) => {
|
||||
const data = await response.json().catch(() => null)
|
||||
return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback
|
||||
|
||||
const parseBrowser = (agent?: string | null) => {
|
||||
if (!agent) return 'Unknown'
|
||||
const value = agent.toLowerCase()
|
||||
if (value.includes('edg/')) return 'Edge'
|
||||
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
|
||||
if (value.includes('firefox/')) return 'Firefox'
|
||||
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<ProfileResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailSaving, setEmailSaving] = useState(false)
|
||||
const [emailNotice, setEmailNotice] = useState<Notice>(null)
|
||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [passwordNotice, setPasswordNotice] = useState<Notice>(null)
|
||||
const [showAllActivity, setShowAllActivity] = useState(false)
|
||||
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!getToken()) { router.replace('/login'); return }
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile`)
|
||||
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
||||
if (!response.ok) throw new Error('Could not load your profile. Please try again.')
|
||||
const profile = await response.json() as ProfileResponse
|
||||
setData(profile)
|
||||
setEmail(profile.user.email ?? '')
|
||||
} catch {
|
||||
setLoadError('Could not load your profile. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [router])
|
||||
const inviteLink = useMemo(() => '/profile/invites', [])
|
||||
|
||||
useEffect(() => { void loadProfile() }, [loadProfile])
|
||||
useEffect(() => {
|
||||
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get('tab')))
|
||||
syncTab()
|
||||
window.addEventListener('popstate', syncTab)
|
||||
return () => window.removeEventListener('popstate', syncTab)
|
||||
if (typeof window === 'undefined') return
|
||||
const syncTabFromLocation = () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
setActiveTab(normalizeProfileTab(params.get('tab')))
|
||||
}
|
||||
syncTabFromLocation()
|
||||
window.addEventListener('popstate', syncTabFromLocation)
|
||||
return () => window.removeEventListener('popstate', syncTabFromLocation)
|
||||
}, [])
|
||||
|
||||
const selectTab = (tab: ProfileTab) => {
|
||||
setActiveTab(tab)
|
||||
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false })
|
||||
}
|
||||
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let next = index
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % TABS.length
|
||||
else if (event.key === 'ArrowLeft') next = (index + TABS.length - 1) % TABS.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = TABS.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
selectTab(TABS[next].key)
|
||||
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus()
|
||||
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
|
||||
}
|
||||
|
||||
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (emailSaving) return
|
||||
setEmailSaving(true)
|
||||
setEmailNotice(null)
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim() || null }),
|
||||
})
|
||||
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
||||
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.'))
|
||||
const result = await response.json()
|
||||
const saved = typeof result.email === 'string' ? result.email : ''
|
||||
setData((current) => current ? { ...current, user: { ...current.user, email: saved || null } } : current)
|
||||
setEmail(saved)
|
||||
setEmailNotice({ tone: 'status', message: saved ? 'Email saved.' : 'Email removed.' })
|
||||
} catch (error) {
|
||||
setEmailNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not save your email.' })
|
||||
} finally { setEmailSaving(false) }
|
||||
const baseUrl = getApiBase()
|
||||
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
|
||||
if (!profileResponse.ok) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const data = (await profileResponse.json()) as ProfileResponse
|
||||
const user = data?.user ?? {}
|
||||
setProfile({
|
||||
username: user?.username ?? 'Unknown',
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
password_change_supported: Boolean(user?.password_change_supported ?? false),
|
||||
password_provider:
|
||||
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
|
||||
? user.password_provider
|
||||
: null,
|
||||
})
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [router])
|
||||
|
||||
const savePassword = async (event: FormEvent<HTMLFormElement>) => {
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (passwordSaving) return
|
||||
setPasswordNotice(null)
|
||||
if (newPassword.trim().length < 8) {
|
||||
setPasswordNotice({ tone: 'error', message: 'Use at least 8 characters for your new password.' })
|
||||
setStatus(null)
|
||||
if (!currentPassword || !newPassword) {
|
||||
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' })
|
||||
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
|
||||
return
|
||||
}
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/password`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(await responseMessage(response, 'Could not change your password. Please try again.'))
|
||||
const result = await response.json()
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin'
|
||||
? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.'
|
||||
: 'Password updated.' })
|
||||
} catch (error) {
|
||||
setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' })
|
||||
} finally { setPasswordSaving(false) }
|
||||
if (!response.ok) {
|
||||
let detail = 'Update failed'
|
||||
try {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||||
detail = payload.detail
|
||||
}
|
||||
} catch {
|
||||
const text = await response.text().catch(() => '')
|
||||
if (text?.trim()) detail = text
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
const data = await response.json().catch(() => ({}))
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setStatus({
|
||||
tone: 'status',
|
||||
message:
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
|
||||
: 'Password updated.',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error && err.message) {
|
||||
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
||||
} else {
|
||||
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const user = data?.user
|
||||
const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||
const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '')
|
||||
const emailChanged = email.trim() !== (user?.email ?? '')
|
||||
const recent = data?.activity?.recent ?? []
|
||||
const notice = (value: Notice) => value && <p className={`account-notice is-${value.tone}`} role={value.tone === 'error' ? 'alert' : 'status'}>{value.message}</p>
|
||||
const authProvider = profile?.auth_provider ?? 'local'
|
||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
|
||||
const securityHelpText =
|
||||
passwordProvider === 'jellyfin'
|
||||
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
||||
: passwordProvider === 'local'
|
||||
? 'Change your Magent account password.'
|
||||
: 'Password changes are not available for this sign-in provider.'
|
||||
|
||||
if (loading) {
|
||||
return <main className="card">Loading profile...</main>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="account-page">
|
||||
<header className="account-heading">
|
||||
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
|
||||
{user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>}
|
||||
</header>
|
||||
|
||||
{loading ? <p className="account-empty" role="status">Loading your profile…</p> : loadError ? (
|
||||
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
|
||||
) : user && <>
|
||||
<div className="account-tabs" role="tablist" aria-label="Profile sections">
|
||||
{TABS.map((tab, index) => <button key={tab.key} id={`profile-tab-${tab.key}`} type="button" role="tab"
|
||||
aria-selected={activeTab === tab.key} aria-controls={`profile-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1}
|
||||
onKeyDown={(event) => tabKeyDown(event, index)} onClick={() => selectTab(tab.key)}>{tab.label}</button>)}
|
||||
<main className="card">
|
||||
<div className="user-directory-panel-header profile-page-header">
|
||||
<div>
|
||||
<h1>My profile</h1>
|
||||
<p className="lede">Review your account, activity, and security settings.</p>
|
||||
</div>
|
||||
{canManageInvites || canChangePassword ? (
|
||||
<div className="admin-inline-actions">
|
||||
{canManageInvites ? (
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||
Open invite page
|
||||
</button>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="account-panel" id="profile-panel-overview" role="tabpanel" aria-labelledby="profile-tab-overview" hidden={activeTab !== 'overview'}>
|
||||
<div className="account-section-intro"><h2>Contact email</h2><p>For password recovery and updates on your reported issues.</p></div>
|
||||
<form className="account-form" onSubmit={saveEmail}>
|
||||
<label htmlFor="profile-email">Email address</label>
|
||||
<input id="profile-email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={email} disabled={emailSaving}
|
||||
onChange={(event) => { setEmail(event.target.value); setEmailNotice(null) }} />
|
||||
{!user.email && <p className="account-hint">Add an email so we can let you know when a fix is ready.</p>}
|
||||
{user.email && !email.trim() && <p className="account-hint">Saving without an email stops account and issue emails.</p>}
|
||||
{notice(emailNotice)}
|
||||
<div className="account-form-actions">
|
||||
<button type="submit" className="account-primary" disabled={emailSaving || !emailChanged}>{emailSaving ? 'Saving…' : 'Save email'}</button>
|
||||
{emailChanged && <button type="button" className="account-secondary" disabled={emailSaving} onClick={() => { setEmail(user.email ?? ''); setEmailNotice(null) }}>Discard</button>}
|
||||
{profile && (
|
||||
<div className="status-banner">
|
||||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||||
{profile.auth_provider}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'overview'}
|
||||
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||
onClick={() => selectTab('overview')}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'activity'}
|
||||
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||
onClick={() => selectTab('activity')}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
{canManageInvites ? (
|
||||
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
|
||||
My invites
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'security'}
|
||||
className={activeTab === 'security' ? 'is-active' : ''}
|
||||
onClick={() => selectTab('security')}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
{canManageInvites ? (
|
||||
<div className="profile-quick-link-card">
|
||||
<div>
|
||||
<h2>Invite tools</h2>
|
||||
<p className="lede">
|
||||
Create invite links, send them by email, and track who you have invited from a dedicated page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => router.push(inviteLink)}>
|
||||
Go to invites
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<div className="profile-quick-link-card">
|
||||
<div>
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
|
||||
<p className="lede">
|
||||
{passwordProvider === 'jellyfin'
|
||||
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
|
||||
: 'Update your Magent account password.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<h2>Account stats</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Requests submitted</div>
|
||||
<div className="stat-value">{stats?.total ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Ready to watch</div>
|
||||
<div className="stat-value">{stats?.ready ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">In progress</div>
|
||||
<div className="stat-value">{stats?.in_progress ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Pending approval</div>
|
||||
<div className="stat-value">{stats?.pending ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Declined</div>
|
||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Working</div>
|
||||
<div className="stat-value">{stats?.working ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Partial</div>
|
||||
<div className="stat-value">{stats?.partial ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Approved</div>
|
||||
<div className="stat-value">{stats?.approved ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Last request</div>
|
||||
<div className="stat-value stat-value--small">
|
||||
{formatDate(stats?.last_request_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Share of all requests</div>
|
||||
<div className="stat-value">
|
||||
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total requests (global)</div>
|
||||
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
||||
</div>
|
||||
{profile?.role === 'admin' ? (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Most active user</div>
|
||||
<div className="stat-value stat-value--small">
|
||||
{stats?.most_active_user
|
||||
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Connection history</h2>
|
||||
<div className="status-banner">
|
||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||||
</div>
|
||||
<div className="connection-list">
|
||||
{(activity?.recent ?? []).map((entry, index) => (
|
||||
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
|
||||
<div>
|
||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
||||
<div className="meta">IP: {entry.ip}</div>
|
||||
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
||||
</div>
|
||||
<div className="connection-count">{entry.hit_count} visits</div>
|
||||
</div>
|
||||
))}
|
||||
{activity && activity.recent.length === 0 ? (
|
||||
<div className="status-banner">No connection history yet.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
||||
<div className="status-banner">{securityHelpText}</div>
|
||||
{canChangePassword ? (
|
||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||
<label>
|
||||
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status ? (
|
||||
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
||||
{status.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
||||
) : (
|
||||
<div className="status-banner">
|
||||
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
||||
<div className="account-section-intro"><h2>Change password</h2><p>{passwordProvider === 'jellyfin' ? 'One password for Grizzlyflix, Seerr and Magent.' : 'Keep your Magent account secure.'}</p></div>
|
||||
{canChangePassword ? <form className="account-form" onSubmit={savePassword}>
|
||||
<fieldset disabled={passwordSaving}>
|
||||
<label htmlFor="profile-current-password">Current password</label>
|
||||
<input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required />
|
||||
<label htmlFor="profile-new-password">New password</label>
|
||||
<input id="profile-new-password" type="password" autoComplete="new-password" aria-describedby="password-length" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} required />
|
||||
<p id="password-length" className="account-hint">At least 8 characters.</p>
|
||||
<label htmlFor="profile-confirm-password">Confirm new password</label>
|
||||
<input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} minLength={8} required />
|
||||
</fieldset>
|
||||
{notice(passwordNotice)}
|
||||
<div className="account-form-actions"><button type="submit" className="account-primary" disabled={passwordSaving}>{passwordSaving ? 'Updating…' : 'Update password'}</button></div>
|
||||
</form> : <p className="account-empty">Password changes are managed by your sign-in provider. Contact an administrator for help.</p>}
|
||||
</section>
|
||||
|
||||
<section className="account-panel" id="profile-panel-activity" role="tabpanel" aria-labelledby="profile-tab-activity" hidden={activeTab !== 'activity'}>
|
||||
<div className="account-section-intro"><h2>Your activity</h2><p>Your requests and recent account access.</p></div>
|
||||
{data?.stats && <div className="account-request-summary"><div><strong>{data.stats.total}</strong><span>Requests</span></div><div><strong>{data.stats.ready}</strong><span>Ready to watch</span></div><a href="/">View my requests <span aria-hidden="true">↗</span></a></div>}
|
||||
<h3 className="account-list-heading">Recent account access</h3>
|
||||
{recent.length ? <ul className="account-access-list">
|
||||
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => <li key={`${entry.ip}-${entry.last_seen_at}-${index}`}>
|
||||
<div className="account-access-summary"><strong>{deviceName(entry.user_agent)}</strong><time dateTime={entry.last_seen_at}>{formatDate(entry.last_seen_at)}</time></div>
|
||||
<details><summary>Connection details</summary><dl><div><dt>IP address</dt><dd>{entry.ip || 'Not recorded'}</dd></div><div><dt>First seen</dt><dd>{formatDate(entry.first_seen_at)}</dd></div></dl></details>
|
||||
</li>)}
|
||||
</ul> : <p className="account-empty">No recent activity yet.</p>}
|
||||
{recent.length > 5 && <button className="account-secondary" type="button" onClick={() => setShowAllActivity(!showAllActivity)}>{showAllActivity ? 'Show less' : 'Show all activity'}</button>}
|
||||
</section>
|
||||
</>}
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
+572
-891
File diff suppressed because it is too large
Load Diff
@@ -106,7 +106,6 @@ 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(),
|
||||
@@ -118,12 +117,12 @@ function SignupPageContent() {
|
||||
throw new Error(text || 'Sign-up failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
window.location.href = '/'
|
||||
return
|
||||
}
|
||||
throw new Error('Sign-up did not complete')
|
||||
throw new Error('Sign-up did not return a token')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import SettingsNavigation from './SettingsNavigation'
|
||||
import AdminSidebar from './AdminSidebar'
|
||||
|
||||
type AdminShellProps = {
|
||||
title: string
|
||||
@@ -12,21 +12,24 @@ type AdminShellProps = {
|
||||
}
|
||||
|
||||
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
||||
const hasRail = Boolean(rail)
|
||||
|
||||
return (
|
||||
<div className="admin-shell admin-shell--top-nav">
|
||||
<SettingsNavigation />
|
||||
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
|
||||
<aside className="admin-shell-nav">
|
||||
<AdminSidebar />
|
||||
</aside>
|
||||
<main className="card admin-card">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<span className="section-kicker">Configuration</span>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="lede">{subtitle}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
|
||||
</main>
|
||||
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Services',
|
||||
items: [
|
||||
{ href: '/admin/general', label: 'General' },
|
||||
{ href: '/admin/seerr', label: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||
{ href: '/admin/radarr', label: 'Radarr' },
|
||||
{ href: '/admin/prowlarr', label: 'Prowlarr' },
|
||||
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Requests',
|
||||
items: [
|
||||
{ href: '/admin/requests', label: 'Request sync' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
{ href: '/admin/cache', label: 'Cache Control' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
{ href: '/admin/logs', label: 'Activity log' },
|
||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
return (
|
||||
<nav className="admin-sidebar">
|
||||
<div className="admin-sidebar-title">Settings</div>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.title} className="admin-nav-group">
|
||||
<span className="admin-nav-title">{group.title}</span>
|
||||
<div className="admin-nav-links">
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href))
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import BrandingLogo from './BrandingLogo'
|
||||
import HeaderActions from './HeaderActions'
|
||||
import HeaderIdentity from './HeaderIdentity'
|
||||
import SiteStatus from './SiteStatus'
|
||||
import UserViewBanner from './UserViewBanner'
|
||||
import WorkspaceNavigation from './WorkspaceNavigation'
|
||||
|
||||
export default function ApplicationChrome() {
|
||||
const pathname = usePathname()
|
||||
if (pathname === '/login') return null
|
||||
return <>
|
||||
<header className="header">
|
||||
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
||||
<div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div>
|
||||
<div className="header-nav"><HeaderActions /></div>
|
||||
</header>
|
||||
<WorkspaceNavigation />
|
||||
<UserViewBanner />
|
||||
<SiteStatus />
|
||||
</>
|
||||
}
|
||||
@@ -1,44 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type BrandingLogoProps = {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
return (
|
||||
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||
{!failed ? (
|
||||
<img
|
||||
className={loaded ? 'is-loaded' : undefined}
|
||||
className={className}
|
||||
src="/api/branding/logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
alt={alt}
|
||||
/>
|
||||
) : null}
|
||||
{!loaded ? (
|
||||
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#7ed7ff" />
|
||||
<stop offset="100%" stopColor="#c6c1ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0b1328" />
|
||||
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
|
||||
<path
|
||||
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
|
||||
fill="url(#magentLogoGlow)"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
setSignedIn(Boolean(token))
|
||||
if (!token) {
|
||||
setShowRequestsNav(true)
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const [response, siteResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/me`),
|
||||
fetch(`${baseUrl}/site/public`).catch(() => null),
|
||||
])
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setRole(data?.role ?? null)
|
||||
if (siteResponse?.ok) {
|
||||
const siteData = await siteResponse.json()
|
||||
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
|
||||
} else {
|
||||
setShowRequestsNav(true)
|
||||
}
|
||||
await response.json()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setShowRequestsNav(true)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
@@ -50,68 +33,17 @@ export default function HeaderActions() {
|
||||
return null
|
||||
}
|
||||
|
||||
const roleItems =
|
||||
role === null
|
||||
? []
|
||||
: role === 'admin'
|
||||
? [
|
||||
{
|
||||
href: '/profile/invites',
|
||||
label: 'Invites',
|
||||
match: (path: string) => path.startsWith('/profile/invites'),
|
||||
},
|
||||
{
|
||||
href: '/admin',
|
||||
label: 'Config',
|
||||
match: (path: string) => path.startsWith('/admin') || path.startsWith('/users'),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
href: '/profile/invites',
|
||||
label: 'Invites',
|
||||
match: (path: string) => path.startsWith('/profile/invites'),
|
||||
},
|
||||
]
|
||||
|
||||
const commonItems = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'My Requests',
|
||||
match: (path: string) => path === '/' || path.startsWith('/requests/'),
|
||||
},
|
||||
...(showRequestsNav
|
||||
? [
|
||||
{
|
||||
href: '/new-requests',
|
||||
label: 'New Requests',
|
||||
match: (path: string) => path === '/new-requests',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: '/portal/issues',
|
||||
label: 'Issues',
|
||||
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
|
||||
},
|
||||
]
|
||||
|
||||
const items = [
|
||||
...commonItems,
|
||||
...roleItems,
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="header-actions" aria-label="Primary">
|
||||
{items.map((item, index) => {
|
||||
const active = item.match(pathname)
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<div className="header-actions-center">
|
||||
<a href="/how-it-works">How it works</a>
|
||||
</div>
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
<a href="/portal">Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
||||
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderIdentity() {
|
||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
||||
const [buildNumber, setBuildNumber] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const viewAsUser = useUserViewPreview()
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
@@ -29,9 +27,6 @@ export default function HeaderIdentity() {
|
||||
const data = await response.json()
|
||||
if (data?.username) {
|
||||
setIdentity({ username: data.username, role: data.role })
|
||||
if (data.role !== 'admin') {
|
||||
setUserViewPreview(false)
|
||||
}
|
||||
}
|
||||
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
||||
if (siteResponse.ok) {
|
||||
@@ -54,9 +49,7 @@ export default function HeaderIdentity() {
|
||||
|
||||
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
||||
const initial = identity.username.slice(0, 1).toUpperCase()
|
||||
const signOut = async () => {
|
||||
setUserViewPreview(false)
|
||||
await logout().catch(() => undefined)
|
||||
const signOut = () => {
|
||||
clearToken()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
@@ -64,17 +57,6 @@ export default function HeaderIdentity() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signed-in-context">
|
||||
{identity.role === 'admin' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
|
||||
aria-pressed={viewAsUser}
|
||||
onClick={() => setUserViewPreview(!viewAsUser)}
|
||||
>
|
||||
{viewAsUser ? 'Exit user view' : 'View as user'}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="signed-in-menu">
|
||||
<button
|
||||
type="button"
|
||||
@@ -88,10 +70,7 @@ export default function HeaderIdentity() {
|
||||
</button>
|
||||
{open && (
|
||||
<div className="signed-in-dropdown">
|
||||
<div className="signed-in-header">
|
||||
Signed in as {label}
|
||||
{viewAsUser ? <span>Previewing user view</span> : null}
|
||||
</div>
|
||||
<div className="signed-in-header">Signed in as {label}</div>
|
||||
<div className="signed-in-actions">
|
||||
<a href="/profile" onClick={() => setOpen(false)}>
|
||||
My profile
|
||||
@@ -104,7 +83,7 @@ export default function HeaderIdentity() {
|
||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||
Changelog
|
||||
</a>
|
||||
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
|
||||
<button type="button" className="signed-in-signout" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
@@ -112,6 +91,5 @@ export default function HeaderIdentity() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function MagentMark() {
|
||||
return <svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true"><rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" /><path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" /></svg>
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { CONFIG_GROUPS } from '../admin/configNavigation'
|
||||
|
||||
export default function SettingsNavigation() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname)
|
||||
if (pathname === '/admin') return null
|
||||
return <nav className="settings-top-navigation" aria-label="Settings navigation">
|
||||
<a href="/admin">← All settings</a>
|
||||
<label><span>Jump to</span><select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
|
||||
<option value="/admin">Settings overview</option>
|
||||
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
|
||||
</select></label>
|
||||
</nav>
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
||||
|
||||
export default function UserViewBanner() {
|
||||
const enabled = useUserViewPreview()
|
||||
|
||||
if (!enabled) return null
|
||||
|
||||
return (
|
||||
<div className="user-view-banner" role="status">
|
||||
<div>
|
||||
<strong>User view</strong>
|
||||
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setUserViewPreview(false)}>
|
||||
Exit user view
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type NavigationItem = {
|
||||
href: string
|
||||
label: string
|
||||
shortLabel: string
|
||||
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings'
|
||||
adminOnly?: boolean
|
||||
match: (path: string) => boolean
|
||||
}
|
||||
|
||||
const NAVIGATION: NavigationItem[] = [
|
||||
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
|
||||
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
||||
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
||||
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
|
||||
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
|
||||
]
|
||||
|
||||
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
|
||||
|
||||
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
|
||||
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
|
||||
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
|
||||
invites: <><circle cx="9" cy="8" r="3" /><path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
|
||||
}
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
export default function WorkspaceNavigation() {
|
||||
const pathname = usePathname()
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
Promise.all([
|
||||
authFetch(`${getApiBase()}/auth/me`),
|
||||
fetch(`${getApiBase()}/site/public`).catch(() => null),
|
||||
])
|
||||
.then(async ([response, siteResponse]) => {
|
||||
if (response.ok) setRole((await response.json())?.role ?? 'user')
|
||||
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.slice(0, 5).map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -101,10 +101,8 @@ export default function UserDetailPage() {
|
||||
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
||||
const [profileSelection, setProfileSelection] = useState('')
|
||||
const [expiryInput, setExpiryInput] = useState('')
|
||||
const [emailInput, setEmailInput] = useState('')
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
const [savingExpiry, setSavingExpiry] = useState(false)
|
||||
const [savingEmail, setSavingEmail] = useState(false)
|
||||
const [systemActionBusy, setSystemActionBusy] = useState(false)
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null)
|
||||
const [lineage, setLineage] = useState<UserLineage>(null)
|
||||
@@ -167,7 +165,6 @@ export default function UserDetailPage() {
|
||||
: String(nextUser.profile_id)
|
||||
)
|
||||
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
|
||||
setEmailInput(nextUser?.email ?? '')
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -221,47 +218,6 @@ export default function UserDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveUserEmail = async (clear = false) => {
|
||||
if (!user) return
|
||||
const email = clear ? '' : emailInput.trim()
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Enter a valid email address.')
|
||||
setActionStatus(null)
|
||||
return
|
||||
}
|
||||
setSavingEmail(true)
|
||||
setError(null)
|
||||
setActionStatus(null)
|
||||
try {
|
||||
const response = await authFetch(
|
||||
`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email || null }),
|
||||
}
|
||||
)
|
||||
const text = await response.text()
|
||||
let data: any = null
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || text || 'Email update failed')
|
||||
}
|
||||
setEmailInput(data?.user?.email ?? '')
|
||||
await loadUser()
|
||||
setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not update the contact email.')
|
||||
} finally {
|
||||
setSavingEmail(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
||||
if (!user) return
|
||||
try {
|
||||
@@ -590,53 +546,6 @@ export default function UserDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="user-detail-side-column">
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Contact email</h2>
|
||||
<p className="lede">Used by Magent for account recovery and issue updates.</p>
|
||||
</div>
|
||||
<form
|
||||
className="user-detail-actions user-detail-actions--stacked"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void saveUserEmail()
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span className="user-bulk-label">Email address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={(event) => setEmailInput(event.target.value)}
|
||||
placeholder="person@example.com"
|
||||
autoComplete="off"
|
||||
disabled={savingEmail}
|
||||
/>
|
||||
</label>
|
||||
<div className="user-detail-helper">
|
||||
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? '')}
|
||||
>
|
||||
{savingEmail ? 'Saving...' : user.email ? 'Save email' : 'Add email'}
|
||||
</button>
|
||||
{user.email && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void saveUserEmail(true)}
|
||||
disabled={savingEmail}
|
||||
>
|
||||
Remove email
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Access controls</h2>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"app/**/*.{ts,tsx}",
|
||||
"next.config.js",
|
||||
"!node_modules",
|
||||
"!.next"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off",
|
||||
"noDocumentCookie": "off",
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+216
-488
File diff suppressed because it is too large
Load Diff
+9
-11
@@ -1,28 +1,26 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0803262237",
|
||||
"version": "0803262216",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint ."
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.12",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "24.11.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18",
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(70 21.00012)">
|
||||
<path d="M105.302 154.943L112.824 869.492C52.651 877.014 7.52158 846.927 7.52158 786.755L0 192.55C0 4.51106 172.996-40.6184 278.298 34.5974L812.33 342.982C887.546 395.633 902.589 493.413 864.981 561.107 857.46 508.456 834.895 478.37 789.765 448.284L188.039 109.813C142.91 79.7268 105.302 87.2484 105.302 154.943Z" fill="#24292E"/>
|
||||
<path d="M0 376.079C45.1295 391.122 90.259 383.6 127.867 361.036L744.636 0C782.244 52.651 774.723 105.302 729.593 135.388L210.604 436.251C135.388 473.859 37.6079 436.251 0 376.079Z" transform="translate(60.17249 531.0214)" fill="#24292E"/>
|
||||
<path d="M0 413.687L368.557 203.083 7.52157 0 0 413.687Z" transform="translate(240.6902 282.8092)" fill="#FFC230"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 846 B |
@@ -1,9 +0,0 @@
|
||||
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/>
|
||||
<path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/>
|
||||
<g clip-rule="evenodd">
|
||||
<path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/>
|
||||
<path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/>
|
||||
<path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,18 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/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}"
|
||||
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
|
||||
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
|
||||
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 beta 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-beta-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.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}'
|
||||
|
||||
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
|
||||
cp '${prod_path}/.env' '${deploy_path}/.env'
|
||||
fi
|
||||
|
||||
mkdir -p '${deploy_path}/data'
|
||||
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
|
||||
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
|
||||
fi
|
||||
|
||||
cd '${deploy_path}'
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml build
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml up -d
|
||||
"
|
||||
|
||||
echo "Running remote beta smoke checks"
|
||||
ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
import time
|
||||
from urllib import error, request
|
||||
|
||||
checks = [
|
||||
('http://127.0.0.1:8100/health', 200),
|
||||
('http://${beta_frontend_bind}:3100/login', 200),
|
||||
]
|
||||
|
||||
# Compose returns before the app is ready. Allow the new processes to start
|
||||
# instead of reporting a failed deployment on the first connection reset.
|
||||
deadline = time.monotonic() + 90
|
||||
for url, expected in checks:
|
||||
while True:
|
||||
try:
|
||||
with request.urlopen(url, timeout=5) as response:
|
||||
if response.status != expected:
|
||||
raise OSError(f'HTTP {response.status}, expected {expected}')
|
||||
print(url, response.status)
|
||||
break
|
||||
except (error.URLError, OSError, TimeoutError) as exc:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
|
||||
print(f'Waiting for beta to start: {url}', flush=True)
|
||||
time.sleep(2)
|
||||
PY
|
||||
"
|
||||
|
||||
echo "Beta deployment completed successfully"
|
||||
@@ -1,185 +0,0 @@
|
||||
// UI contract checks. Every API request is mocked: no accounts, passwords,
|
||||
// emails or live media are modified. Requires Node and Playwright.
|
||||
const assert = require('node:assert/strict')
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||
const output = process.env.REVIEW_DIR
|
||||
const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } }
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
let options = structuredClone(site)
|
||||
let loginStatus = 401
|
||||
let profileStatus = 200
|
||||
let emailStatus = 200
|
||||
let passwordStatus = 200
|
||||
let provider = 'jellyfin'
|
||||
let supported = true
|
||||
let savedEmail = 'member@example.com'
|
||||
const calls = []
|
||||
const errors = []
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
const method = request.method()
|
||||
const reply = (json, status = 200) => route.fulfill({ status, json })
|
||||
if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
|
||||
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'user' })
|
||||
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
|
||||
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
|
||||
stats: { total: 12, ready: 9, in_progress: 3 },
|
||||
activity: { recent: Array.from({ length: 7 }, (_, i) => ({ ip: '192.0.2.' + (i + 1), user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/140.0', first_seen_at: '2026-09-01T00:00:00Z', last_seen_at: '2026-09-06T01:00:00Z' })) },
|
||||
} : { detail: 'Service unavailable' }, profileStatus)
|
||||
if (method !== 'GET') calls.push({ path, method, body: request.postData() })
|
||||
if (path === '/api/auth/profile/email') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
if (emailStatus !== 200) return reply({ detail: 'This email is already in use.' }, emailStatus)
|
||||
savedEmail = request.postDataJSON().email
|
||||
return reply({ email: savedEmail })
|
||||
}
|
||||
if (path === '/api/auth/password') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
return reply(passwordStatus === 200 ? { provider } : { detail: 'Current password is incorrect' }, passwordStatus)
|
||||
}
|
||||
if (path.endsWith('/login')) {
|
||||
if (loginStatus === 0) return route.abort()
|
||||
return reply({ authenticated: loginStatus === 200 }, loginStatus)
|
||||
}
|
||||
if (path.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
|
||||
return reply({ requests: [], items: [], services: [], results: [] })
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
const screenshot = async (name) => { if (output) await page.screenshot({ path: output + '/' + name + '.png', fullPage: true, animations: 'disabled' }) }
|
||||
const openLogin = async () => { await page.goto(base + '/login'); await page.getByRole('heading', { name: 'Welcome back.' }).waitFor(); await page.waitForTimeout(250) }
|
||||
const login = async () => { await page.getByLabel('Username', { exact: true }).fill('member'); await page.getByLabel('Password', { exact: true }).fill('test-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click() }
|
||||
await openLogin()
|
||||
assert.equal(await page.locator('.header,.workspace-sidebar,.admin-sidebar').count(), 0)
|
||||
await screenshot('account-login-desktop')
|
||||
await login()
|
||||
await page.getByRole('alert').filter({ hasText: 'Check your username' }).waitFor()
|
||||
assert.equal(calls.at(-1).path, '/api/auth/jellyfin/login')
|
||||
await page.getByRole('button', { name: 'Magent', exact: true }).click()
|
||||
loginStatus = 429
|
||||
await login()
|
||||
await page.getByRole('alert').filter({ hasText: 'Too many attempts' }).waitFor()
|
||||
assert.equal(calls.at(-1).path, '/api/auth/login')
|
||||
loginStatus = 503
|
||||
await login()
|
||||
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
|
||||
loginStatus = 0
|
||||
await login()
|
||||
await page.getByRole('alert').filter({ hasText: 'Check your connection' }).waitFor()
|
||||
await page.getByRole('button', { name: 'Show password' }).click()
|
||||
assert.equal(await page.getByLabel('Password', { exact: true }).getAttribute('type'), 'text')
|
||||
await page.getByRole('button', { name: 'Hide password' }).click()
|
||||
|
||||
options.login.showJellyfinLogin = false
|
||||
await openLogin()
|
||||
assert.equal(await page.getByRole('group', { name: 'Sign-in account' }).count(), 0)
|
||||
loginStatus = 401
|
||||
await login()
|
||||
await page.getByRole('alert').waitFor()
|
||||
assert.equal(calls.at(-1).path, '/api/auth/login')
|
||||
options.login.showLocalLogin = false
|
||||
options.login.showForgotPassword = false
|
||||
options.login.showSignupLink = false
|
||||
options.banner.message = 'Maintenance tonight'
|
||||
await openLogin()
|
||||
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
|
||||
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
|
||||
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
|
||||
assert(await page.getByText('Maintenance tonight').isVisible())
|
||||
options = structuredClone(site)
|
||||
options.login.showLocalLogin = false
|
||||
await openLogin()
|
||||
loginStatus = 200
|
||||
await login()
|
||||
await page.waitForURL(base + '/')
|
||||
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
|
||||
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect and notices')
|
||||
|
||||
await page.goto(base + '/profile')
|
||||
const email = page.getByLabel('Email address', { exact: true })
|
||||
await email.waitFor()
|
||||
assert.equal(await page.locator('.workspace-sidebar,.admin-sidebar').count(), 0)
|
||||
assert(await page.getByRole('button', { name: 'Save email' }).isDisabled())
|
||||
await screenshot('account-profile-desktop')
|
||||
await email.fill('changed@example.com')
|
||||
await page.getByRole('button', { name: 'Discard' }).click()
|
||||
assert.equal(await email.inputValue(), savedEmail)
|
||||
await email.fill('not-an-email')
|
||||
assert.equal(await email.evaluate((el) => el.checkValidity()), false)
|
||||
await email.fill('changed@example.com')
|
||||
await page.getByRole('button', { name: 'Save email' }).click()
|
||||
await page.getByRole('status').filter({ hasText: 'Email saved.' }).waitFor()
|
||||
assert.equal(JSON.parse(calls.at(-1).body).email, 'changed@example.com')
|
||||
await email.fill('')
|
||||
assert(await page.getByText('Saving without an email stops account and issue emails.').isVisible())
|
||||
await page.getByRole('button', { name: 'Save email' }).click()
|
||||
await page.getByRole('status').filter({ hasText: 'Email removed.' }).waitFor()
|
||||
assert.equal(JSON.parse(calls.at(-1).body).email, null)
|
||||
emailStatus = 409
|
||||
await email.fill('duplicate@example.com')
|
||||
await page.getByRole('button', { name: 'Save email' }).click()
|
||||
await page.getByRole('alert').filter({ hasText: 'already in use' }).waitFor()
|
||||
await page.getByRole('tab', { name: 'Account', exact: true }).focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
assert.equal(await page.getByRole('tab', { name: 'Security', exact: true }).getAttribute('aria-selected'), 'true')
|
||||
await screenshot('account-security-desktop')
|
||||
await page.getByLabel('Current password', { exact: true }).fill('current-password')
|
||||
await page.getByLabel('New password', { exact: true }).fill('new-password')
|
||||
await page.getByLabel('Confirm new password', { exact: true }).fill('mismatch-password')
|
||||
const previousCalls = calls.length
|
||||
await page.getByRole('button', { name: 'Update password' }).click()
|
||||
await page.getByRole('alert').filter({ hasText: 'do not match' }).waitFor()
|
||||
assert.equal(calls.length, previousCalls)
|
||||
await page.getByLabel('Confirm new password', { exact: true }).fill('new-password')
|
||||
passwordStatus = 401
|
||||
await page.getByRole('button', { name: 'Update password' }).click()
|
||||
await page.getByRole('alert').filter({ hasText: 'Current password is incorrect' }).waitFor()
|
||||
assert.equal(new URL(page.url()).pathname, '/profile')
|
||||
passwordStatus = 200
|
||||
await page.getByRole('button', { name: 'Update password' }).click()
|
||||
await page.getByRole('status').filter({ hasText: 'Password updated for Grizzlyflix' }).waitFor()
|
||||
assert.equal(await page.getByLabel('Current password', { exact: true }).inputValue(), '')
|
||||
await page.getByRole('tab', { name: 'Activity', exact: true }).click()
|
||||
assert.equal(await page.locator('.account-access-list > li').count(), 5)
|
||||
assert.equal(await page.getByText('192.0.2.1', { exact: true }).isVisible(), false)
|
||||
await page.getByRole('button', { name: 'Show all activity' }).click()
|
||||
assert.equal(await page.locator('.account-access-list > li').count(), 7)
|
||||
await screenshot('account-activity-desktop')
|
||||
provider = 'local'
|
||||
await page.goto(base + '/profile?tab=security')
|
||||
await page.getByText('Keep your Magent account secure.').waitFor()
|
||||
supported = false
|
||||
await page.reload()
|
||||
await page.getByText('Password changes are managed by your sign-in provider.', { exact: false }).waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: 'Update password' }).count(), 0)
|
||||
profileStatus = 503
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: 'Try again' }).waitFor()
|
||||
assert.equal(new URL(page.url()).pathname, '/profile')
|
||||
profileStatus = 200
|
||||
supported = true
|
||||
await page.getByRole('button', { name: 'Try again' }).click()
|
||||
await page.getByRole('tab', { name: 'Account', exact: true }).waitFor()
|
||||
console.log('PASS: email save/removal/discard/validation, password validation/providers, keyboard tabs, activity and load recovery')
|
||||
|
||||
options = structuredClone(site)
|
||||
for (const width of [320, 390, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
for (const path of ['/login', '/profile', '/profile?tab=security', '/profile?tab=activity']) {
|
||||
await page.goto(base + path)
|
||||
await page.waitForTimeout(300)
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, path + ' overflows at ' + width)
|
||||
if (width === 390 && (path === '/profile' || path === '/login')) await screenshot('account-mobile-' + path.slice(1))
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('PASS: responsive layout at 320, 390, 768, 1024 and 1440px; no JavaScript errors')
|
||||
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await browser.close()
|
||||
})().catch((error) => { console.error(error.message); process.exit(1) })
|
||||
@@ -1,164 +0,0 @@
|
||||
// Run with Node and Playwright. REVIEW_DIR/session.json holds an authorised
|
||||
// short-lived session as { name, token }. Never commit this file. Live requests are read-only:
|
||||
// all writes are blocked, with save/validation paths checked using fixtures.
|
||||
const reviewDir = process.env.REVIEW_DIR || '/review'
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || reviewDir + '/node_modules/playwright')
|
||||
const fs = require('node:fs')
|
||||
const assert = require('node:assert/strict')
|
||||
const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||
const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||
const prefix = process.env.REVIEW_PREFIX || 'before'
|
||||
const session = JSON.parse(process.env.REVIEW_SESSION || fs.readFileSync(reviewDir + '/session.json', 'utf8'))
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
await context.addCookies([
|
||||
{ name: session.name, value: session.token, url: base, httpOnly: true, secure: base.startsWith('https') },
|
||||
{ name: 'magent_logged_in', value: '1', url: base },
|
||||
])
|
||||
const errors = []
|
||||
const httpErrors = []
|
||||
const blocked = []
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
if (request.url().includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
|
||||
blocked.push({ path: new URL(request.url()).pathname, method: request.method() })
|
||||
return route.fulfill({ status: 409, json: { detail: 'Read-only browser review' } })
|
||||
}
|
||||
if (base !== liveBase) {
|
||||
const remote = liveBase + new URL(request.url()).pathname + new URL(request.url()).search
|
||||
try {
|
||||
const response = await route.fetch({ url: remote, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
|
||||
return await route.fulfill({ response })
|
||||
} catch {
|
||||
// Browser cancellation must not print request headers (including cookies).
|
||||
await route.abort().catch(() => {})
|
||||
return
|
||||
}
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ path: new URL(response.url()).pathname, status: response.status() }) })
|
||||
const reports = []
|
||||
const paths = process.env.REVIEW_PATHS ? process.env.REVIEW_PATHS.split(',') : prefix === 'before' ? ['/admin', '/admin/radarr', '/admin/notifications', '/admin/site'] : [
|
||||
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr',
|
||||
'/admin/prowlarr', '/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow',
|
||||
'/admin/requests', '/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance',
|
||||
'/admin/invites', '/admin/diagnostics', '/', '/new-requests', '/portal/issues', '/profile/invites', '/profile',
|
||||
]
|
||||
for (const path of paths) {
|
||||
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1600)
|
||||
if (path.startsWith('/admin/') && !['/admin/invites','/admin/diagnostics'].includes(path)) {
|
||||
await page.locator('.admin-card').waitFor({ timeout: 30000 }).catch(async (error) => {
|
||||
console.log(JSON.stringify({ failedPath: path, location: page.url(), errors, httpErrors, blocked }))
|
||||
throw error
|
||||
})
|
||||
} else await page.locator('main').first().waitFor()
|
||||
const stats = await page.evaluate(() => ({
|
||||
title: document.querySelector('main h1')?.textContent,
|
||||
width: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
visibleInputs: [...document.querySelectorAll('main input,main select,main textarea')].filter((el) => el.getBoundingClientRect().height > 0).length,
|
||||
}))
|
||||
reports.push({ path, ...stats })
|
||||
if (['/admin','/admin/radarr','/admin/notifications','/admin/site','/admin/issue-workflow'].includes(path)) {
|
||||
await page.screenshot({ path: reviewDir + '/' + prefix + path.replaceAll('/','-') + '.png', fullPage: true })
|
||||
}
|
||||
console.log(JSON.stringify({ path, ...stats }))
|
||||
}
|
||||
for (const path of ['/admin','/admin/radarr','/admin/cache','/new-requests','/portal/issues','/profile/invites']) {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1800)
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
reports.push({ mobile: path, overflow })
|
||||
await page.screenshot({ path: reviewDir + '/' + prefix + '-mobile' + path.replaceAll('/','-') + '.png', fullPage: true })
|
||||
console.log(JSON.stringify({ mobile: path, overflow }))
|
||||
}
|
||||
if (prefix !== 'before') {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await page.goto(base + '/admin/radarr')
|
||||
const field = page.locator('input[name=radarr_base_url]')
|
||||
await field.waitFor()
|
||||
const saved = await field.inputValue()
|
||||
const region = page.locator('#config-radarr-connection')
|
||||
assert(await region.getByRole('button', { name: 'Save changes', exact: true }).isDisabled())
|
||||
await field.fill(saved + '/unsaved-review')
|
||||
assert(await region.getByRole('button', { name: 'Test connection', exact: true }).isDisabled())
|
||||
await region.getByRole('button', { name: 'Discard', exact: true }).click()
|
||||
assert.equal(await field.inputValue(), saved)
|
||||
// Saving is intercepted: verify only this region is submitted, and
|
||||
// leaving an existing secret blank never overwrites that secret.
|
||||
let payload
|
||||
await page.route('**/api/admin/settings', async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.fallback()
|
||||
payload = route.request().postDataJSON()
|
||||
return route.fulfill({ status: 200, json: { ok: true } })
|
||||
})
|
||||
await field.fill(saved + '/review')
|
||||
await region.getByRole('button', { name: 'Save changes', exact: true }).click()
|
||||
await page.waitForTimeout(1000)
|
||||
assert(payload && payload.radarr_base_url.endsWith('/review'))
|
||||
assert(!('radarr_api_key' in payload))
|
||||
assert(!('sonarr_base_url' in payload))
|
||||
await page.goto(base + '/admin/issue-workflow')
|
||||
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('0')
|
||||
assert(!(await page.locator('[name=issue_confirmation_interval_value]').count()))
|
||||
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('3')
|
||||
await page.locator('input[name=issue_confirmation_interval_value]').fill('366')
|
||||
assert(!(await page.locator('input[name=issue_confirmation_interval_value]').evaluate((el) => el.checkValidity())))
|
||||
await page.goto(base + '/admin/notifications')
|
||||
const discord = page.locator('#config-magent-notify-discord')
|
||||
await discord.getByRole('button', { name: /Discord/ }).click()
|
||||
const toggle = discord.getByRole('switch')
|
||||
await toggle.check()
|
||||
assert(await discord.locator('[name=magent_notify_discord_webhook_url]').isVisible())
|
||||
await discord.getByRole('button', { name: 'Discard', exact: true }).click()
|
||||
assert(!(await discord.locator('[name=magent_notify_discord_webhook_url]').count()))
|
||||
await page.setViewportSize({ width: 1024, height: 768 })
|
||||
await page.goto(base + '/admin/cache')
|
||||
await page.getByRole('button', { name: 'Load saved requests', exact: true }).waitFor()
|
||||
await page.screenshot({ path: reviewDir + '/tablet-cache.png', fullPage: true })
|
||||
assert(await page.getByRole('button', { name: 'Load saved requests', exact: true }).isVisible())
|
||||
await page.getByRole('button', { name: 'Load saved requests', exact: true }).click()
|
||||
await page.waitForTimeout(700)
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
|
||||
await page.goto(base + '/portal/issues')
|
||||
await page.waitForTimeout(1800)
|
||||
const report = page.locator('.portal-item-list > button').first()
|
||||
if (await report.count()) {
|
||||
await report.click()
|
||||
const modal = page.locator('.issue-detail-modal.is-open')
|
||||
await modal.waitFor()
|
||||
const top = await modal.boundingBox()
|
||||
const header = await page.locator('.header').boundingBox()
|
||||
assert(top.y >= header.y + header.height, 'Issue window must clear the header')
|
||||
await modal.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
assert(!(await page.locator('.issue-detail-modal.is-open').count()))
|
||||
console.log('PASS: issue popup opens, clears the header and closes')
|
||||
}
|
||||
await page.goto(base + '/')
|
||||
await page.locator('.recent-card').first().waitFor({ timeout: 30000 }).catch(() => {})
|
||||
const request = page.locator('.recent-card').first()
|
||||
if (await request.count()) {
|
||||
await request.click()
|
||||
await page.waitForTimeout(2500)
|
||||
assert(new URL(page.url()).pathname.startsWith('/requests/'))
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
|
||||
console.log('PASS: request detail navigation and layout')
|
||||
}
|
||||
assert(reports.every((report) => report.overflow == null || report.overflow === 0), 'Mobile overflow')
|
||||
console.log('PASS: dirty state, discard, region-only save, secret preservation, confirmation limits')
|
||||
}
|
||||
fs.writeFileSync(reviewDir + '/' + prefix + '-report.json', JSON.stringify({ reports, errors, blocked }, null, 2))
|
||||
console.log(JSON.stringify({ errors, blocked }))
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await browser.close()
|
||||
if (errors.length) process.exitCode = 1
|
||||
})().catch((error) => { console.error(error); process.exit(1) })
|
||||
@@ -2,7 +2,6 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||
Set-Location $repoRoot
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$CommandName)
|
||||
|
||||
Reference in New Issue
Block a user