chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
+20
View File
@@ -0,0 +1,20 @@
# Provision this as .env on the beta host. Do not copy production secrets or data.
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
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=json
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
AUTH_COOKIE_NAME=magent_beta_auth
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+18
View File
@@ -0,0 +1,18 @@
# Copy to .env for local development. Never reuse these example values in a deployed environment.
APP_NAME=Magent
CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=http://localhost:3000
MAGENT_API_URL=http://localhost:8000
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=text
# Generate independent values as documented in README.md.
JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+34 -29
View File
@@ -6,6 +6,10 @@ on:
- beta - beta
- main - main
- prod - prod
pull_request:
branches:
- beta
- main
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -22,7 +26,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.12" python-version: "3.14"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -37,39 +41,40 @@ jobs:
- name: Run backend quality gate - name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh run: bash scripts/ci_backend_quality_gate.sh
- name: Verify generated build metadata
run: python scripts/verify_build_metadata.py
- name: Audit frontend production dependencies
working-directory: frontend
run: npm audit --omit=dev --package-lock-only --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Check frontend formatting
working-directory: frontend
run: npm run format:check
- name: Type-check frontend
working-directory: frontend
run: npm run typecheck
- name: Test frontend
working-directory: frontend
run: npm test
- name: Build frontend - name: Build frontend
working-directory: frontend working-directory: frontend
run: npm run build run: npm run build
deploy-prod: - name: Validate Compose configuration
if: github.ref_name == 'prod'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # 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: | run: |
set -euo pipefail cp .env.example .env
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}" docker compose -f docker-compose.yml config --quiet
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy to AMS-DEV01 - name: Build and smoke-test container
env: run: bash scripts/ci_container_smoke.sh
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_ams_dev01.sh
deploy-beta: deploy-beta:
if: github.ref_name == 'beta' if: github.ref_name == 'beta'
@@ -97,6 +102,6 @@ jobs:
env: env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} BETA_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh run: bash scripts/deploy_beta_ams_dev01.sh
+4
View File
@@ -9,8 +9,12 @@ backend/__pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
backend/.pytest_cache/ backend/.pytest_cache/
.coverage
coverage.xml
htmlcov/
frontend/node_modules/ frontend/node_modules/
frontend/.next/ frontend/.next/
*.tsbuildinfo
*.log *.log
**/.pytest_cache/ **/.pytest_cache/
.env.* .env.*
+5
View File
@@ -60,4 +60,9 @@ USER magent:magent
EXPOSE 3000 8000 EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|| exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+2
View File
@@ -18,6 +18,8 @@ from `main`; use `prod-<short-commit>` tags to identify an exact release.
1. Run the backend tests and frontend production build. Review only the intended 1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`. changes, then commit and push `main`.
The repository workflow verifies `main` but intentionally does not deploy it;
production changes require the remaining explicit release steps below.
2. Build from a clean source export using the root Dockerfile. Never include 2. Build from a clean source export using the root Dockerfile. Never include
`.env`, databases or bootstrap credentials in the build context. `.env`, databases or bootstrap credentials in the build context.
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub. 3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
+20 -4
View File
@@ -136,6 +136,19 @@ Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite). Login uses the admin credentials above (or any other local user you create in SQLite).
### Local quality checks
```bash
bash scripts/ci_backend_quality_gate.sh
cd frontend
npm ci
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
```
## Public Hosting Notes ## Public Hosting Notes
The frontend proxies `/api/*` to the backend container. Set: The frontend proxies `/api/*` to the backend container. Set:
@@ -149,20 +162,23 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`. 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 `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`. - Push to `main` or `prod`: runs the same verification without automatically changing production.
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
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: The beta deploy step ships tracked repository files over SSH, preserves beta's own `.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:8000/health`
- `http://127.0.0.1:3000/login` - `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job: Configure these Gitea Actions secrets before enabling the deploy job:
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account. - `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`. - `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`. - `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`. - `PROD_DEPLOY_PATH`: beta app path, for example `/home/zak/magent-beta`.
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts. - `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
## Security and data handling ## Security and data handling
+58
View File
@@ -0,0 +1,58 @@
"""Shared HTTP request and error contracts."""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class StrictRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class ErrorResponse(BaseModel):
detail: str
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
400: {"model": ErrorResponse, "description": "Invalid request"},
401: {"model": ErrorResponse, "description": "Authentication required"},
403: {"model": ErrorResponse, "description": "Permission denied"},
404: {"model": ErrorResponse, "description": "Resource not found"},
409: {"model": ErrorResponse, "description": "Request conflict"},
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
500: {"model": ErrorResponse, "description": "Unexpected server error"},
502: {"model": ErrorResponse, "description": "Upstream service error"},
503: {"model": ErrorResponse, "description": "Service unavailable"},
}
class SignupRequest(StrictRequest):
invite_code: str = Field(min_length=1, max_length=256)
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=1024)
email: Optional[str] = Field(default=None, max_length=320)
class ForgotPasswordRequest(StrictRequest):
identifier: Optional[str] = Field(default=None, max_length=320)
username: Optional[str] = Field(default=None, max_length=100)
email: Optional[str] = Field(default=None, max_length=320)
class PasswordResetRequest(StrictRequest):
token: str = Field(min_length=1, max_length=512)
new_password: str = Field(min_length=1, max_length=1024)
class ProfileEmailUpdateRequest(StrictRequest):
email: Optional[str] = Field(default=None, max_length=320)
class ChangePasswordRequest(StrictRequest):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
return payload if isinstance(payload, dict) else payload.model_dump()
-7
View File
@@ -1,7 +1,6 @@
import re import re
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx import httpx
import time
from .base import ApiClient, _operation_error_message from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -186,7 +185,6 @@ class JellyfinClient(ApiClient):
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…") operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items" url = f"{self.base_url}/Items"
params = { params = {
@@ -214,7 +212,6 @@ class JellyfinClient(ApiClient):
if isinstance(item, dict) and item.get('Id'): if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)} result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
success=True, success=True,
@@ -223,7 +220,6 @@ class JellyfinClient(ApiClient):
) )
return result return result
except Exception as exc: 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 status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
@@ -277,7 +273,6 @@ class JellyfinClient(ApiClient):
async def refresh_library(self, recursive: bool = True) -> None: async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…") operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh" url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers() headers = self._emby_headers()
@@ -286,7 +281,6 @@ class JellyfinClient(ApiClient):
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params) response = await client.post(url, headers=headers, params=params)
response.raise_for_status() response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
success=True, success=True,
@@ -294,7 +288,6 @@ class JellyfinClient(ApiClient):
message="Jellyfin accepted the library refresh and is scanning for new media.", message="Jellyfin accepted the library refresh and is scanning for new media.",
) )
except Exception as exc: 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 status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
-10
View File
@@ -1,7 +1,6 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx import httpx
import logging import logging
import time
from .base import ApiClient, _operation_error_message from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient):
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url: if not self.base_url:
return None return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…") operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
@@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient):
response = await client.get(f"{self.base_url}{path}", params=params) response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
success=True, success=True,
@@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient):
) )
return result return result
except Exception as exc: 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 status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
@@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient):
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]: async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url: if not self.base_url:
return None return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent") operation_event_id = start_remote_call("qBittorrent")
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
@@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient):
response = await client.get(f"{self.base_url}{path}", params=params) response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status() response.raise_for_status()
result = response.text.strip() result = response.text.strip()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
success=True, success=True,
@@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient):
) )
return result return result
except Exception as exc: 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 status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
@@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient):
async def _post_form(self, path: str, data: Dict[str, Any]) -> None: async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url: if not self.base_url:
return None return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent") operation_event_id = start_remote_call("qBittorrent")
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client) await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data) response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status() response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
success=True, success=True,
@@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient):
message=_torrent_action_message(path), message=_torrent_action_message(path),
) )
except Exception as exc: 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 status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call( finish_remote_call(
operation_event_id, operation_event_id,
+1
View File
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME") default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
) )
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE")) log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field( log_file_max_bytes: int = Field(
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES") default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
+2 -155
View File
@@ -13,6 +13,7 @@ from .config import settings
from .models import Snapshot from .models import Snapshot
from .security import hash_password, verify_and_update_password, verify_password from .security import hash_password, verify_and_update_password, verify_password
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
from .schema_migrations import run_schema_migrations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -686,163 +687,9 @@ def init_db() -> None:
ON user_activity (last_seen_at) ON user_activity (last_seen_at)
""" """
) )
try: run_schema_migrations(conn)
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT")
except sqlite3.OperationalError:
pass
_protect_legacy_signup_invite_codes(conn) _protect_legacy_signup_invite_codes(conn)
_encrypt_legacy_sensitive_settings(conn) _encrypt_legacy_sensitive_settings(conn)
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_username_nocase
ON users (username COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
ON requests_cache (requested_by_id)
"""
)
except sqlite3.OperationalError:
pass
try: try:
conn.execute("PRAGMA optimize") conn.execute("PRAGMA optimize")
except sqlite3.OperationalError: except sqlite3.OperationalError:
+25 -4
View File
@@ -3,6 +3,7 @@ import json
import logging import logging
import os import os
import re import re
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Any, Mapping, Optional from typing import Any, Mapping, Optional
from urllib.parse import parse_qs from urllib.parse import parse_qs
@@ -39,6 +40,22 @@ class RequestContextFilter(logging.Filter):
return True return True
class JsonLogFormatter(logging.Formatter):
"""Stable JSON output for production log collectors."""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "-"),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def bind_request_id(request_id: str) -> contextvars.Token[str]: def bind_request_id(request_id: str) -> contextvars.Token[str]:
return REQUEST_ID_CONTEXT.set(request_id or "-") return REQUEST_ID_CONTEXT.set(request_id or "-")
@@ -150,6 +167,7 @@ def configure_logging(
log_file_backup_count: int = 10, log_file_backup_count: int = 10,
log_http_client_level: Optional[str] = "INFO", log_http_client_level: Optional[str] = "INFO",
log_background_sync_level: Optional[str] = "INFO", log_background_sync_level: Optional[str] = "INFO",
log_format: Optional[str] = "text",
) -> None: ) -> None:
level_name = (log_level or "INFO").upper() level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO) level = getattr(logging, level_name, logging.INFO)
@@ -176,10 +194,13 @@ def configure_logging(
handlers.append(file_handler) handlers.append(file_handler)
context_filter = RequestContextFilter() context_filter = RequestContextFilter()
formatter = logging.Formatter( if str(log_format or "text").strip().lower() == "json":
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s", formatter: logging.Formatter = JsonLogFormatter()
datefmt="%Y-%m-%d %H:%M:%S", else:
) formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers: for handler in handlers:
handler.addFilter(context_filter) handler.addFilter(context_filter)
handler.setFormatter(formatter) handler.setFormatter(formatter)
+2
View File
@@ -259,6 +259,7 @@ async def startup() -> None:
log_file_backup_count=settings.log_file_backup_count, log_file_backup_count=settings.log_file_backup_count,
log_http_client_level=settings.log_http_client_level, log_http_client_level=settings.log_http_client_level,
log_background_sync_level=settings.log_background_sync_level, log_background_sync_level=settings.log_background_sync_level,
log_format=settings.log_format,
) )
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings() _log_security_configuration_warnings()
@@ -273,6 +274,7 @@ async def startup() -> None:
log_file_backup_count=runtime.log_file_backup_count, log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level, log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level, log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
) )
logger.info( logger.info(
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s", "runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
+11 -9
View File
@@ -21,6 +21,7 @@ from ..auth import (
resolve_user_auth_provider, resolve_user_auth_provider,
) )
from ..config import normalize_banner_color, settings as env_settings from ..config import normalize_banner_color, settings as env_settings
from ..api_models import COMMON_ERROR_RESPONSES
from ..network_security import validate_notification_target_url from ..network_security import validate_notification_target_url
from ..db import ( from ..db import (
delete_setting, delete_setting,
@@ -35,8 +36,6 @@ from ..db import (
get_user_by_id, get_user_by_id,
get_user_by_username, get_user_by_username,
get_user_request_stats, get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting, set_setting,
set_user_blocked, set_user_blocked,
delete_user_data_by_username, delete_user_data_by_username,
@@ -59,7 +58,6 @@ from ..db import (
cleanup_history, cleanup_history,
update_request_cache_title, update_request_cache_title,
repair_request_cache_titles, repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles, list_user_profiles,
get_user_profile, get_user_profile,
create_user_profile, create_user_profile,
@@ -73,6 +71,7 @@ from ..db import (
delete_signup_invite, delete_signup_invite,
get_signup_invite_by_code, get_signup_invite_by_code,
disable_signup_invites_by_creator, disable_signup_invites_by_creator,
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -81,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import ( from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users, get_cached_jellyfin_users,
get_cached_jellyseerr_users, get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
save_jellyseerr_users_cache, save_jellyseerr_users_cache,
clear_user_import_caches, clear_user_import_caches,
@@ -109,7 +104,12 @@ from ..logging_config import configure_logging
from ..routers import requests as requests_router from ..routers import requests as requests_router
from ..routers.branding import save_branding_image from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
responses=COMMON_ERROR_RESPONSES,
)
events_router = APIRouter(prefix="/admin/events", tags=["admin"]) events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
@@ -247,6 +247,7 @@ SETTING_KEYS: List[str] = [
"qbittorrent_username", "qbittorrent_username",
"qbittorrent_password", "qbittorrent_password",
"log_level", "log_level",
"log_format",
"log_file", "log_file",
"log_file_max_bytes", "log_file_max_bytes",
"log_file_backup_count", "log_file_backup_count",
@@ -741,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
set_setting(key, value_to_store) set_setting(key, value_to_store)
updates += 1 updates += 1
changed_keys.append(key) changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}: if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True touched_logging = True
if touched_logging: if touched_logging:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -752,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
log_file_backup_count=runtime.log_file_backup_count, log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level, log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level, log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
) )
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys) logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates} return {"status": "ok", "updated": updates}
+24 -6
View File
@@ -60,6 +60,15 @@ from ..auth import (
set_auth_cookies, set_auth_cookies,
) )
from ..config import settings from ..config import settings
from ..api_models import (
COMMON_ERROR_RESPONSES,
ChangePasswordRequest,
ForgotPasswordRequest,
PasswordResetRequest,
ProfileEmailUpdateRequest,
SignupRequest,
request_data,
)
from ..network_security import request_trusts_forwarded_headers from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import ( from ..services.user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
@@ -81,7 +90,7 @@ from ..services.password_reset import (
verify_password_reset_token, verify_password_reset_token,
) )
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120 STREAM_TOKEN_TTL_SECONDS = 120
@@ -869,7 +878,8 @@ async def invite_details(code: str) -> dict:
@router.post("/signup") @router.post("/signup")
async def signup(payload: dict, response: Response) -> dict: async def signup(payload: SignupRequest, response: Response) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip() invite_code = str(payload.get("invite_code") or "").strip()
@@ -1054,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
@router.post("/password/forgot") @router.post("/password/forgot")
async def forgot_password(payload: dict, request: Request) -> dict: async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
identifier = payload.get("identifier") or payload.get("username") or payload.get("email") identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
@@ -1106,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
@router.post("/password/reset") @router.post("/password/reset")
async def password_reset(payload: dict) -> dict: async def password_reset(payload: PasswordResetRequest) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
token = payload.get("token") token = payload.get("token")
@@ -1169,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email") @router.put("/profile/email")
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: async def update_profile_email(
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
username = str(current_user.get("username") or "").strip() username = str(current_user.get("username") or "").strip()
@@ -1435,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
@router.post("/password") @router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: async def change_password(
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
current_password = payload.get("current_password") if isinstance(payload, dict) else None current_password = payload.get("current_password") if isinstance(payload, dict) else None
new_password = payload.get("new_password") if isinstance(payload, dict) else None new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str): if not isinstance(current_password, str) or not isinstance(new_password, str):
+1 -1
View File
@@ -3,7 +3,7 @@ import warnings
from io import BytesIO from io import BytesIO
from typing import Any, Dict from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
+1 -1
View File
@@ -3,7 +3,7 @@ import re
import mimetypes import mimetypes
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Response from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
import httpx import httpx
+7 -1
View File
@@ -11,6 +11,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..db import ( from ..db import (
add_portal_item_activity, add_portal_item_activity,
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)]) router = APIRouter(
prefix="/portal",
tags=["portal"],
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
responses=COMMON_ERROR_RESPONSES,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"} PORTAL_KINDS = {"request", "issue", "feature"}
+1 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..services.public_urls import magent_public_url from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin from ..auth import require_admin
from ..feature_guards import require_stats from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store from ..services import email_recaps as recaps, recap_store as store
+13 -17
View File
@@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
@@ -30,7 +31,6 @@ from ..db import (
save_action, save_action,
get_recent_actions, get_recent_actions,
get_recent_snapshots, get_recent_snapshots,
get_cached_requests,
get_cached_requests_since, get_cached_requests_since,
get_cached_request_by_media_id, get_cached_request_by_media_id,
get_request_cache_lookup, get_request_cache_lookup,
@@ -62,6 +62,7 @@ from ..db import (
) )
from ..services.media_repair import current_cycle_torrents from ..services.media_repair import current_cycle_torrents
from ..services.download_labels import label_episode_downloads from ..services.download_labels import label_episode_downloads
from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
from ..models import Snapshot, TriageResult, RequestType from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import ( from ..services.snapshot import (
_summarize_qbit, _summarize_qbit,
@@ -70,7 +71,12 @@ from ..services.snapshot import (
jellyfin_item_matches_request, jellyfin_item_matches_request,
) )
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)]) router = APIRouter(
prefix="/requests",
tags=["requests"],
dependencies=[Depends(get_current_user), Depends(require_request_access)],
responses=COMMON_ERROR_RESPONSES,
)
CACHE_TTL_SECONDS = 600 CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} _detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
@@ -1753,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) ->
"approved": accepted, "approved": accepted,
"rejected": item.get("rejected"), "rejected": item.get("rejected"),
"temporarilyRejected": item.get("temporarilyRejected"), "temporarilyRejected": item.get("temporarilyRejected"),
"rejections": item.get("rejections"),
"downloadAllowed": item.get("downloadAllowed"), "downloadAllowed": item.get("downloadAllowed"),
"fullSeason": item.get("fullSeason"), "fullSeason": item.get("fullSeason"),
"seasonNumber": item.get("seasonNumber"), "seasonNumber": item.get("seasonNumber"),
@@ -1971,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit(): try:
folders = await client.get_root_folders() return await resolve_root_folder_path(client, root_folder, service_name)
if isinstance(folders, list): except RootFolderNotFoundError as exc:
for folder in folders: raise HTTPException(status_code=400, detail=str(exc)) from exc
if folder.get("id") == int(root_folder):
path = folder.get("path")
if isinstance(path, str) and path:
return path
raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
return root_folder
@router.get("/{request_id}/issue-options") @router.get("/{request_id}/issue-options")
@@ -2979,7 +2978,6 @@ async def recent_requests(
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower()
# Browsing is always local. Synchronization is owned by background workers. # Browsing is always local. Synchronization is owned by background workers.
allow_remote = False allow_remote = False
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
@@ -3007,8 +3005,6 @@ async def recent_requests(
allow_title_hydrate = False allow_title_hydrate = False
allow_artwork_hydrate = False allow_artwork_hydrate = False
stage_cache = await asyncio.to_thread(get_request_stage_cache) stage_cache = await asyncio.to_thread(get_request_stage_cache)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -3946,7 +3942,7 @@ async def action_grab(
release_title = receipt.get('title') release_title = receipt.get('title')
arr_error: Optional[str] = None arr_error: Optional[str] = None
try: try:
response = await arr_client.grab_release(str(guid), arr_indexer_id) await arr_client.grab_release(str(guid), arr_indexer_id)
action_message = ( action_message = (
f"{release_title or 'Selected release'} was sent through {service_label} for download and import." f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '') + (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
+116
View File
@@ -0,0 +1,116 @@
"""Transactional, versioned SQLite schema migrations for Magent."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
MigrationStep = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True)
class Migration:
version: int
name: str
apply: MigrationStep
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
column = definition.split(maxsplit=1)[0].strip('"')
if column not in _column_names(conn, table):
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
for definition in (
"email TEXT",
"last_login_at TEXT",
"is_blocked INTEGER NOT NULL DEFAULT 0",
"auth_provider TEXT NOT NULL DEFAULT 'local'",
"jellyfin_password_hash TEXT",
"last_jellyfin_auth_at TEXT",
"jellyseerr_user_id INTEGER",
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
"profile_id INTEGER",
"expires_at TEXT",
"invited_by_code TEXT",
"invited_at TEXT",
"auth_version INTEGER NOT NULL DEFAULT 1",
):
_add_column(conn, "users", definition)
for definition in ("recipient_email TEXT", "code_hint TEXT"):
_add_column(conn, "signup_invites", definition)
for definition in (
"related_item_id INTEGER",
"workflow_request_status TEXT",
"workflow_media_status TEXT",
"issue_type TEXT",
"issue_resolved_at TEXT",
"metadata_json TEXT",
):
_add_column(conn, "portal_items", definition)
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
statements = (
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
"(related_item_id, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
"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)",
)
for statement in statements:
conn.execute(statement)
MIGRATIONS = (
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
)
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)
"""
)
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
completed: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
savepoint = f"magent_migration_{migration.version}"
conn.execute(f"SAVEPOINT {savepoint}")
try:
migration.apply(conn)
conn.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
)
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
raise
completed.append(migration.version)
return completed
+21
View File
@@ -0,0 +1,21 @@
"""Shared Sonarr/Radarr configuration helpers."""
from typing import Any
class RootFolderNotFoundError(ValueError):
pass
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
configured = str(root_folder or "").strip()
if not configured.isdigit():
return configured
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if isinstance(folder, dict) and folder.get("id") == int(configured):
path = str(folder.get("path") or "").strip()
if path:
return path
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Independent newsletter consent and immutable edition snapshots using the shared email queue.""" """Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib import hashlib
@@ -11,6 +10,7 @@ from datetime import datetime, timedelta, timezone
from .. import db from .. import db
from . import email_queue from . import email_queue
from .recap_store import read_one, transaction from .recap_store import read_one, transaction
from .public_urls import magent_public_url
class Conflict(ValueError): class Conflict(ValueError):
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Durable consent, schedule and delivery records for personal email recaps.""" """Durable consent, schedule and delivery records for personal email recaps."""
import hashlib import hashlib
@@ -11,6 +10,7 @@ from datetime import datetime
from .. import db from .. import db
from .monthly_reports import shift_month from .monthly_reports import shift_month
from . import email_queue from . import email_queue
from .public_urls import magent_public_url
def init_schema(conn: sqlite3.Connection) -> None: def init_schema(conn: sqlite3.Connection) -> None:
+17 -13
View File
@@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel
from .collector_search import read_search_status from .collector_search import read_search_status
from .media_repair import current_cycle_torrents, evaluate_media_repair from .media_repair import current_cycle_torrents, evaluate_media_repair
from .download_labels import label_episode_downloads from .download_labels import label_episode_downloads
from .arr import RootFolderNotFoundError, resolve_root_folder_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1234,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_item = None arr_item = None
arr_queue = None arr_queue = None
episodes = None episodes = None
media_status = jelly_request.get("media", {}).get("status")
try:
media_status_code = int(media_status) if media_status is not None else None
except (TypeError, ValueError):
media_status_code = None
if snapshot.request_type == RequestType.tv: if snapshot.request_type == RequestType.tv:
tvdb_id = jelly_request.get("media", {}).get("tvdbId") tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id: if tvdb_id:
@@ -1390,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder: if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if radarr_client.configured(): if radarr_client.configured():
root_folder = await _resolve_root_folder_path( try:
radarr_client, runtime.radarr_root_folder, "Radarr" root_folder = await resolve_root_folder_path(
) radarr_client, runtime.radarr_root_folder, "Radarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
root_folder = ""
tmdb_id = jelly_request.get("media", {}).get("tmdbId") tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id: if tmdb_id and root_folder:
try: try:
await radarr_client.add_movie( await radarr_client.add_movie(
int(tmdb_id), int(tmdb_id),
@@ -1409,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder: if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if sonarr_client.configured(): if sonarr_client.configured():
root_folder = await _resolve_root_folder_path( try:
sonarr_client, runtime.sonarr_root_folder, "Sonarr" root_folder = await resolve_root_folder_path(
) sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
root_folder = ""
tvdb_id = jelly_request.get("media", {}).get("tvdbId") tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id: if tvdb_id and root_folder:
try: try:
await sonarr_client.add_series( await sonarr_client.add_series(
int(tvdb_id), int(tvdb_id),
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
coverage==7.16.1
pip-audit==2.10.1
ruff==0.16.8
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pydantic import ValidationError
from backend.app.api_models import PasswordResetRequest, SignupRequest
class ApiRequestModelTests(unittest.TestCase):
def test_signup_rejects_unknown_fields(self) -> None:
with self.assertRaises(ValidationError):
SignupRequest(
invite_code="invite",
username="viewer",
password="strong password",
unexpected="value",
)
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
self.assertEqual(request.new_password, " leading and trailing ")
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
class _ArrClient:
async def get_root_folders(self):
return [{"id": 7, "path": "/media/tv"}]
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
async def test_resolves_numeric_root_folder_id(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
async def test_preserves_configured_path(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
async def test_rejects_missing_root_folder_id(self) -> None:
with self.assertRaises(RootFolderNotFoundError):
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
import json
import logging
import unittest
from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id
class JsonLoggingTests(unittest.TestCase):
def test_json_formatter_includes_request_context(self) -> None:
token = bind_request_id("request-123")
try:
record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
RequestContextFilter().filter(record)
payload = json.loads(JsonLogFormatter().format(record))
finally:
reset_request_id(token)
self.assertEqual(payload["level"], "INFO")
self.assertEqual(payload["logger"], "magent.test")
self.assertEqual(payload["request_id"], "request-123")
self.assertEqual(payload["message"], "hello world")
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
import sqlite3
import unittest
from backend.app.schema_migrations import run_schema_migrations
class SchemaMigrationTests(unittest.TestCase):
def setUp(self) -> None:
self.conn = sqlite3.connect(":memory:")
self.conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)"
)
self.conn.execute(
"CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)"
)
self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)")
self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)")
def tearDown(self) -> None:
self.conn.close()
def test_migrations_are_versioned_and_idempotent(self) -> None:
self.assertEqual(run_schema_migrations(self.conn), [1])
self.assertEqual(run_schema_migrations(self.conn), [])
user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")}
self.assertIn("auth_version", user_columns)
self.assertIn("email", user_columns)
request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")}
self.assertIn("requested_by_id", request_columns)
applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall()
self.assertEqual(applied, [(1, "legacy_columns_and_indexes")])
if __name__ == "__main__":
unittest.main()
+211 -237
View File
@@ -1,298 +1,261 @@
'use client' "use client";
import PageHeading from './ui/PageHeading' import PageHeading from "./ui/PageHeading";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth' import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
import {
const normalizeRecentResults = (items: any[]) => normalizeRecentResults,
items normalizeSearchResults,
.filter((item: any) => item?.id) type RecentRequest,
.map((item: any) => { type RequestSearchResult,
const id = item.id } from "./lib/request-results";
const rawTitle = item.title import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter();
const [query, setQuery] = useState('') const [query, setQuery] = useState("");
const [recent, setRecent] = useState< const [recent, setRecent] = useState<RecentRequest[]>([]);
{ const [recentError, setRecentError] = useState<string | null>(null);
id: number const [recentLoading, setRecentLoading] = useState(false);
title: string const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
year?: number const [searchError, setSearchError] = useState<string | null>(null);
type?: string const [role, setRole] = useState<string | null>(null);
statusLabel?: string const [recentDays, setRecentDays] = useState(90);
artwork?: { poster_url?: string; backdrop_url?: string } const [recentStage, setRecentStage] = useState<RequestStage>("all");
createdAt?: string | null const [authReady, setAuthReady] = useState(false);
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
const trimmed = query.trim() const trimmed = query.trim();
if (!trimmed) return if (!trimmed) return;
if (/^\d+$/.test(trimmed)) { if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`) router.push(`/requests/${encodeURIComponent(trimmed)}`);
return return;
} }
void runSearch(trimmed) void runSearch(trimmed);
} };
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
let cancelled = false let cancelled = false;
const load = async () => { const load = async () => {
setRecentLoading(true) setRecentLoading(true);
setRecentError(null) setRecentError(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const meResponse = await authFetch(`${baseUrl}/auth/me`) const meResponse = await authFetch(`${baseUrl}/auth/me`);
if (!meResponse.ok) { if (!meResponse.ok) {
if (meResponse.status === 401) { if (meResponse.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error(`Auth failed: ${meResponse.status}`) throw new Error(`Auth failed: ${meResponse.status}`);
} }
const me = await meResponse.json() const me = await meResponse.json();
if (cancelled) return if (cancelled) return;
const userRole = me?.role ?? null const userRole = me?.role ?? null;
setRole(userRole) setRole(userRole);
setAuthReady(true) setAuthReady(true);
const take = userRole === 'admin' ? 50 : 6 const take = userRole === "admin" ? 50 : 6;
const params = new URLSearchParams({ const params = new URLSearchParams({
take: String(take), take: String(take),
days: String(recentDays), days: String(recentDays),
}) });
if (recentStage !== 'all') { if (recentStage !== "all") {
params.set('stage', recentStage) params.set("stage", recentStage);
} }
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`) const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error(`Recent requests failed: ${response.status}`) throw new Error(`Recent requests failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
if (cancelled) return if (cancelled) return;
if (Array.isArray(data?.results)) { if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results)) setRecent(normalizeRecentResults(data.results));
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error);
if (!cancelled) setRecentError('Recent requests are not available right now.') if (!cancelled) setRecentError("Recent requests are not available right now.");
} finally { } finally {
if (!cancelled) setRecentLoading(false) if (!cancelled) setRecentLoading(false);
} }
} };
void load() void load();
return () => { cancelled = true } return () => {
}, [recentDays, recentStage]) cancelled = true;
};
}, [recentDays, recentStage, router]);
useEffect(() => { useEffect(() => {
if (!authReady) { if (!authReady) {
return return;
} }
if (!getToken()) { if (!getToken()) {
return return;
} }
const baseUrl = getApiBase() const baseUrl = getApiBase();
let closed = false let closed = false;
let source: EventSource | null = null let source: EventSource | null = null;
const connect = async () => { const connect = async () => {
try { try {
const streamToken = await getEventStreamToken() const streamToken = await getEventStreamToken();
if (closed) return if (closed) return;
const params = new URLSearchParams({ const params = new URLSearchParams({
stream_token: streamToken, stream_token: streamToken,
recent_days: String(recentDays), recent_days: String(recentDays),
}) });
if (recentStage !== 'all') { if (recentStage !== "all") {
params.set('recent_stage', recentStage) params.set("recent_stage", recentStage);
} }
const streamUrl = `${baseUrl}/events/stream?${params.toString()}` const streamUrl = `${baseUrl}/events/stream?${params.toString()}`;
source = new EventSource(streamUrl) source = new EventSource(streamUrl);
source.onmessage = (event) => { source.onmessage = (event) => {
if (closed) return if (closed) return;
try { try {
const payload = JSON.parse(event.data) const payload = JSON.parse(event.data);
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== "object") {
return return;
} }
if (payload.type === 'home_recent') { if (payload.type === "home_recent") {
if (Array.isArray(payload.results)) { if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results)) setRecent(normalizeRecentResults(payload.results));
setRecentError(null) setRecentError(null);
setRecentLoading(false) setRecentLoading(false);
} else if (typeof payload.error === 'string' && payload.error.trim()) { } else if (typeof payload.error === "string" && payload.error.trim()) {
setRecentError('Recent requests are not available right now.') setRecentError("Recent requests are not available right now.");
setRecentLoading(false) setRecentLoading(false);
} }
return return;
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} }
} };
} catch (error) { } catch (error) {
if (closed) return if (closed) return;
console.error(error) console.error(error);
} }
} };
void connect() void connect();
return () => { return () => {
closed = true closed = true;
source?.close() source?.close();
} };
}, [authReady, recentDays, recentStage]) }, [authReady, recentDays, recentStage]);
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error(`Search failed: ${response.status}`) throw new Error(`Search failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
if (Array.isArray(data?.results)) { if (Array.isArray(data?.results)) {
setSearchResults( setSearchResults(normalizeSearchResults(data.results));
data.results.map((item: any) => ({ setSearchError(null);
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error);
setSearchError('Search failed. Try a request ID instead.') setSearchError("Search failed. Try a request ID instead.");
setSearchResults([]) setSearchResults([]);
} }
} };
const resolveArtworkUrl = (url?: string | null) => { const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null if (!url) return null;
return url.startsWith('http') ? url : `${getApiBase()}${url}` return url.startsWith("http") ? url : `${getApiBase()}${url}`;
} };
const formatRequestTime = (value?: string | null) => { const formatRequestTime = (value?: string | null) => {
if (!value) return null if (!value) return null;
const date = new Date(value) const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString() return date.toLocaleString();
} };
const activeRecentCount = recent.filter((item) => { const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase() const label = String(item.statusLabel ?? "").toLowerCase();
return !label.includes('ready') && !label.includes('available') && !label.includes('declined') return !label.includes("ready") && !label.includes("available") && !label.includes("declined");
}).length }).length;
const readyRecentCount = recent.filter((item) => { const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase() const label = String(item.statusLabel ?? "").toLowerCase();
return label.includes('ready') || label.includes('available') return label.includes("ready") || label.includes("available");
}).length }).length;
const requestCardState = (value?: string) => { const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase() const label = String(value ?? "").toLowerCase();
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 } if (label.includes("partial")) return { key: "attention", label: value || "Partially ready", progress: 65 };
if (!/not |unavailable|waiting/.test(label) && (label.includes('ready') || label.includes('available'))) return { key: 'ready', label: value || 'Ready', progress: 100 } if (!/not |unavailable|waiting/.test(label) && (label.includes("ready") || label.includes("available")))
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 } return { key: "ready", label: value || "Ready", progress: 100 };
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 } if (label.includes("declined") || label.includes("failed") || label.includes("error"))
return { key: 'waiting', label: value || 'Waiting', progress: 4 } return { key: "attention", label: value || "Needs attention", progress: 12 };
} 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 ( return (
<main className="card home-page"> <main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={ <PageHeading
<form onSubmit={submit} className="home-search"> title="My requests"
<label htmlFor="request-search">Title, year, or request number</label> description="Follow your requests from collection to ready to watch."
<div className="home-search-row"> actions={
<input <form onSubmit={submit} className="home-search">
id="request-search" <label htmlFor="request-search">Title, year, or request number</label>
value={query} <div className="home-search-row">
onChange={(event) => setQuery(event.target.value)} <input
placeholder="Dune 2021 or 1289" id="request-search"
/> value={query}
<button type="submit">Find request</button> onChange={(event) => setQuery(event.target.value)}
</div> placeholder="Dune 2021 or 1289"
</form> />
} /> <button type="submit">Find request</button>
</div>
</form>
}
/>
{(searchError || searchResults.length > 0) && ( {(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite"> <section className="home-search-results" aria-live="polite">
<div className="home-section-heading"> <div className="home-section-heading">
<div> <div>
<span className="section-kicker">Search results</span> <span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2> <h2>
{searchError
? "Search unavailable"
: `${searchResults.length} match${searchResults.length === 1 ? "" : "es"} found`}
</h2>
</div> </div>
<button type="button" className="ghost-button" onClick={() => { <button
setSearchResults([]) type="button"
setSearchError(null) className="ghost-button"
}}> onClick={() => {
setSearchResults([]);
setSearchError(null);
}}
>
Clear Clear
</button> </button>
</div> </div>
@@ -302,17 +265,20 @@ export default function HomePage() {
<div className="home-result-grid"> <div className="home-result-grid">
{searchResults.map((item, index) => ( {searchResults.map((item, index) => (
<button <button
key={`${item.title || 'Untitled'}-${index}`} key={`${item.title || "Untitled"}-${index}`}
type="button" type="button"
className="home-result-card" className="home-result-card"
disabled={!item.requestId} disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)} onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
> >
<span> <span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong> <strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small> {item.title || "Untitled"}
{item.year ? ` (${item.year})` : ""}
</strong>
<small>{item.type?.toUpperCase() || "MEDIA"}</small>
</span> </span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span> <span>{!item.requestId ? "Not requested" : item.statusLabel || "Already requested"}</span>
</button> </button>
))} ))}
</div> </div>
@@ -321,16 +287,25 @@ export default function HomePage() {
)} )}
<section className="home-metric-strip" aria-label="Request summary"> <section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div> <div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div> <span>In view</span>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div> <strong>{recent.length}</strong>
</div>
<div>
<span>In progress</span>
<strong>{activeRecentCount}</strong>
</div>
<div>
<span>Ready</span>
<strong>{readyRecentCount}</strong>
</div>
</section> </section>
<section className="recent home-recent"> <section className="recent home-recent">
<div className="recent-header home-section-heading"> <div className="recent-header home-section-heading">
<div> <div>
<span className="section-kicker">Request activity</span> <span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2> <h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
</div> </div>
{authReady && ( {authReady && (
<div className="recent-filter-group"> <div className="recent-filter-group">
@@ -347,21 +322,7 @@ export default function HomePage() {
</div> </div>
)} )}
</div> </div>
{authReady && ( {authReady && <RequestStageFilter value={recentStage} onChange={setRecentStage} />}
<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)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid"> <div className="recent-grid home-recent-grid">
{recentLoading ? ( {recentLoading ? (
<div className="loading-center"> <div className="loading-center">
@@ -386,30 +347,43 @@ export default function HomePage() {
{item.artwork?.poster_url ? ( {item.artwork?.poster_url ? (
<img <img
className="recent-poster" className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''} src={resolveArtworkUrl(item.artwork.poster_url) ?? ""}
alt="" alt=""
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span> <span className="recent-poster recent-poster-placeholder" aria-hidden="true">
#{item.id}
</span>
)} )}
<span className="recent-info"> <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-status-badge"> <span className="recent-status-badge">
<span aria-hidden="true">{({ ready: '✓', processing: '↻', attention: '!', waiting: '◷' })[requestCardState(item.statusLabel).key]}</span> <span aria-hidden="true">
{item.statusLabel || 'Status not available yet'} {
{ ready: "✓", processing: "↻", attention: "!", waiting: "◷" }[
requestCardState(item.statusLabel).key
]
}
</span>
{item.statusLabel || "Status not available yet"}
</span> </span>
<span className="recent-meta"> <span className="recent-meta">
Request {item.id} Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''} {item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ""}
</span> </span>
</span> </span>
<span className="recent-open-cue" aria-hidden="true">Open</span> <span className="recent-open-cue" aria-hidden="true">
Open
</span>
</button> </button>
)) ))
)} )}
</div> </div>
</section> </section>
</main> </main>
) );
} }
+115 -53
View File
@@ -1,64 +1,105 @@
'use client' "use client";
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean } export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean };
type Option = { value: string; label: string } type Option = { value: string; label: string };
type Props = { type Props = {
setting: AdminSetting setting: AdminSetting;
label: string label: string;
value: string value: string;
help?: string help?: string;
placeholder?: string placeholder?: string;
boolean?: boolean boolean?: boolean;
numeric?: boolean numeric?: boolean;
multiline?: boolean multiline?: boolean;
options?: Option[] options?: Option[];
optionsUnavailable?: boolean optionsUnavailable?: boolean;
onChange: (value: string) => void onChange: (value: string) => void;
} };
const SELECTS: Record<string, Option[]> = { const SELECTS: Record<string, Option[]> = {
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })), 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_contact_attempts: Array.from({ length: 11 }, (_, index) => ({
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })), value: String(index),
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }], label: index === 0 ? "None — close when fixed" : String(index),
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 })), issue_confirmation_interval_unit: ["days", "weeks", "months"].map((value) => ({
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }], 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" },
],
};
const COLOR_DEFAULTS: Record<string, string> = { const COLOR_DEFAULTS: Record<string, string> = {
site_banner_background_color: '#332814', site_banner_background_color: "#332814",
site_banner_border_color: '#a27b32', site_banner_border_color: "#a27b32",
} };
export default function SettingField(props: Props) { export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props const { setting, label, value, help, placeholder, onChange } = props;
const id = `setting-${setting.key}` 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 options =
const selectedOptions = options && value && !options.some((option) => option.value === value) props.options ??
? [{ value, label: `Current selection (${value})` }, ...options] : options SELECTS[setting.key] ??
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' (setting.key === "log_http_client_level" || setting.key === "log_background_sync_level"
const zeroAllowed = setting.key === 'log_file_backup_count' ? SELECTS.log_level
const minimum = zeroAllowed ? 0 : 1 : undefined);
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined const selectedOptions =
const colorDefault = COLOR_DEFAULTS[setting.key] options && value && !options.some((option) => option.value === value)
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault ? [{ value, label: `Current selection (${value})` }, ...options]
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined } : 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 colorDefault = COLOR_DEFAULTS[setting.key];
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault;
const aria = { id, name: setting.key, "aria-describedby": help ? `${id}-help` : undefined };
if (props.boolean) { if (props.boolean) {
return ( return (
<div className="setting-field setting-switch"> <div className="setting-field setting-switch">
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div> <div>
<input {...aria} type="checkbox" role="switch" aria-checked={value.toLowerCase() === 'true'} checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} /> <label htmlFor={id}>{label}</label>
{help && <p id={`${id}-help`}>{help}</p>}
</div>
<input
{...aria}
type="checkbox"
role="switch"
aria-checked={value.toLowerCase() === "true"}
checked={value.toLowerCase() === "true"}
onChange={(event) => onChange(String(event.target.checked))}
/>
</div> </div>
) );
} }
return ( return (
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}> <div className={`setting-field ${props.multiline ? "field-span-full" : ""}`}>
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label> <label htmlFor={id}>
{label}
{setting.sensitive && setting.isSet && <small>Saved</small>}
</label>
{props.optionsUnavailable ? ( {props.optionsUnavailable ? (
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select> <select {...aria} disabled value={value}>
<option value={value}>Save the connection, then reload available options</option>
</select>
) : colorDefault ? ( ) : colorDefault ? (
<div className="setting-color-control"> <div className="setting-color-control">
<input <input
@@ -78,23 +119,44 @@ export default function SettingField(props: Props) {
spellCheck={false} spellCheck={false}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
/> />
{value ? <button type="button" className="ghost-button" onClick={() => onChange('')}>Use tone default</button> : null} {value ? (
<button type="button" className="ghost-button" onClick={() => onChange("")}>
Use tone default
</button>
) : null}
</div> </div>
) : selectedOptions ? ( ) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}> <select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>} {!value && <option value="">Choose an option</option>}
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} {selectedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select> </select>
) : props.multiline ? ( ) : props.multiline ? (
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} /> <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'} <input
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined} {...aria}
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false} type={setting.sensitive ? "password" : props.numeric ? "number" : isTime ? "time" : "text"}
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder} value={value}
onChange={(event) => onChange(event.target.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>} {help && <p id={`${id}-help`}>{help}</p>}
</div> </div>
) );
} }
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -1,13 +1,38 @@
'use client' "use client";
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from "react";
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) { export default function SettingsRegion({
const [open, setOpen] = useState(!collapsed) title,
id,
collapsed,
children,
}: {
title: string;
id: string;
collapsed: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(!collapsed);
return ( return (
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}> <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>} {collapsed && (
<div id={`${id}-content`} hidden={!open}>{children}</div> <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> </section>
) );
} }
+27 -27
View File
@@ -1,36 +1,36 @@
import { notFound } from 'next/navigation' import { notFound } from "next/navigation";
import SettingsPage from '../SettingsPage' import SettingsPage from "../SettingsPage";
const ALLOWED_SECTIONS = new Set([ const ALLOWED_SECTIONS = new Set([
'seerr', "seerr",
'jellyseerr', "jellyseerr",
'jellyfin', "jellyfin",
'jellystat', "jellystat",
'artwork', "artwork",
'sonarr', "sonarr",
'radarr', "radarr",
'bazarr', "bazarr",
'prowlarr', "prowlarr",
'qbittorrent', "qbittorrent",
'requests', "requests",
'issue-workflow', "issue-workflow",
'cache', "cache",
'logs', "logs",
'maintenance', "maintenance",
'magent', "magent",
'general', "general",
'notifications', "notifications",
'site', "site",
]) ]);
type PageProps = { type PageProps = {
params: Promise<{ section: string }> params: Promise<{ section: string }>;
} };
export default async function AdminSectionPage({ params }: PageProps) { export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params const { section } = await params;
if (!ALLOWED_SECTIONS.has(section)) { if (!ALLOWED_SECTIONS.has(section)) {
notFound() notFound();
} }
return <SettingsPage section={section} /> return <SettingsPage section={section} />;
} }
+97 -34
View File
@@ -1,37 +1,100 @@
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string } type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string };
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] } type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] };
export const CONFIG_GROUPS: ConfigGroup[] = [ 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' }, title: "Media services",
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' }, description: "Connect the services that collect, repair and play your content.",
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' }, items: [
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' }, { href: "/admin/seerr", label: "Seerr", description: "Requests and approvals", symbol: "SE", service: "Seerr" },
{ 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/jellyfin",
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' }, label: "Jellyfin",
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' }, description: "Playback and library availability",
]}, symbol: "JF",
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [ service: "Jellyfin",
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' }, },
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' }, {
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' }, href: "/admin/jellystat",
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' }, label: "Jellystat",
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' }, description: "Personal viewing statistics",
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' }, symbol: "JS",
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' }, service: "Jellystat",
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' }, },
]}, {
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [ href: "/admin/sonarr",
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' }, label: "Sonarr",
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' }, description: "TV collection and quality",
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' }, symbol: "SO",
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' }, service: "Sonarr",
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' }, },
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' }, {
]}, 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" },
{
href: "/admin/notifications",
label: "Email & notifications",
description: "Invites, password resets and repair updates",
},
{
href: "/admin/recaps",
label: "Monthly email recaps",
description: "Personal viewing emails, schedule and delivery history",
},
{
href: "/admin/newsletters",
label: "Newsletters",
description: "New arrivals, featured picks and weekly editions",
},
{
href: "/admin/issue-workflow",
label: "Issue follow-up",
description: "Confirmation emails and automatic closure",
},
{ href: "/admin/requests", label: "Request updates", description: "Refresh schedule and history retention" },
{ href: "/users", label: "User management", description: "Accounts, permissions, identity checks and repairs" },
{ href: "/admin/invites", label: "Invite policy & access", description: "Defaults, profiles and issued invites" },
],
},
{
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) => ({ export const serviceStatusLabel = (status?: string) =>
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up', ({
}[status ?? ''] ?? 'Not checked') up: "Connected",
down: "Unavailable",
degraded: "Needs attention",
not_configured: "Not set up",
})[status ?? ""] ?? "Not checked";
+5 -8
View File
@@ -1,15 +1,12 @@
'use client' "use client";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel' import AdminDiagnosticsPanel from "../../ui/AdminDiagnosticsPanel";
export default function AdminDiagnosticsPage() { export default function AdminDiagnosticsPage() {
return ( return (
<AdminShell <AdminShell title="Diagnostics" subtitle="Check connections and investigate service problems.">
title="Diagnostics"
subtitle="Check connections and investigate service problems."
>
<AdminDiagnosticsPanel /> <AdminDiagnosticsPanel />
</AdminShell> </AdminShell>
) );
} }
@@ -1,74 +1,232 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import { FEATURES, type FeatureAccess } from '../../lib/features' import { FEATURES, type FeatureAccess } from "../../lib/features";
import type { Row } from './IdentityReviewPanel' import type { Row } from "./IdentityReviewPanel";
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null } type Account = {
id: number;
username: string;
email: string | null;
profile_id: number | null;
last_login_at: string | null;
};
type Preview = { type Preview = {
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[] accounts: Account[];
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean } keep_id: number;
} recommended_id: number;
revision: string;
can_confirm: boolean;
issues: string[];
proposed: Account & {
jellyfin_user_id: string;
seerr_user_id: number;
features: FeatureAccess;
expires_at: string | null;
is_blocked: boolean;
auto_search_enabled: boolean;
};
};
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) { export default function DuplicateAccountRepair({
const dialog = useRef<HTMLDialogElement>(null) row,
const controller = useRef<AbortController | null>(null) onClose,
const [preview, setPreview] = useState<Preview | null>(null) onSaved,
const [busy, setBusy] = useState(false) }: {
const [saving, setSaving] = useState(false) row: Row;
const [acknowledged, setAcknowledged] = useState(false) onClose: () => void;
const [error, setError] = useState('') onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState("");
const submit = async (confirm = false, keepId?: number) => { const submit = async (confirm = false, keepId?: number) => {
const abort = new AbortController() const abort = new AbortController();
controller.current?.abort(); controller.current = abort controller.current?.abort();
setError(''); setAcknowledged(false) controller.current = abort;
if (confirm) setSaving(true) setError("");
else setBusy(true) setAcknowledged(false);
if (confirm) setSaving(true);
else setBusy(true);
try { try {
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, { const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? "confirm" : "check"}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' }, method: "POST",
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }), signal: abort.signal,
}) headers: { "Content-Type": "application/json" },
const data = await response.json() body: JSON.stringify({
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.') user_id: row.user.id,
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) } ...(keepId ? { keep_id: keepId } : {}),
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } } ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}),
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } } }),
} });
const data = await response.json();
if (!response.ok)
throw new Error(typeof data.detail === "string" ? data.detail : "Could not review these accounts.");
if (!abort.signal.aborted) {
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : "Repair failed. Preview again.");
setPreview(null);
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: The dialog preview runs once when this keyed modal mounts.
useEffect(() => { useEffect(() => {
const previous = document.activeElement as HTMLElement | null const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; dialog.current?.showModal() document.body.style.overflow = "hidden";
void submit() dialog.current?.showModal();
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() } void submit();
}, []) return () => {
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}> controller.current?.abort();
<div className="identity-resolve-content"> document.body.style.overflow = overflow;
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header> previous?.focus();
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p> };
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>} }, []);
{error && <p className="error-banner" role="alert">{error}</p>} return (
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>} <dialog
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview"> ref={dialog}
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}> className="identity-resolve-dialog"
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)} aria-labelledby="duplicates-title"
</select></label> onCancel={(event) => {
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p> event.preventDefault();
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div> if (!saving) onClose();
<h3>Resulting account</h3> }}
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p> >
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p> <div className="identity-resolve-content">
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p> <header>
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p> <h2 id="duplicates-title">Repair duplicate accounts</h2>
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul> <button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p> Close
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p> </button>
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p> </header>
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>} <p>
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label> Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button> the verified Jellyfin identity.
</section>} </p>
</div> {busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
</dialog> {error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{!preview && !busy && (
<button type="button" disabled={saving} onClick={() => void submit()}>
Check again
</button>
)}
{preview && (
<section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>
Magent account to keep
<select
disabled={busy || saving}
value={preview.keep_id}
onChange={(event) => void submit(false, Number(event.target.value))}
>
{preview.accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.username} Magent {account.id}
{account.id === preview.recommended_id ? " (recommended)" : ""}
</option>
))}
</select>
</label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">
{preview.accounts.map((account) => (
<div key={account.id}>
<strong>
Magent {account.id}
{account.id === preview.keep_id ? " · Keep" : " · Consolidate"}
</strong>
<p>{account.username}</p>
<p>
{account.email || "No email"} · Profile {account.profile_id ?? "None"}
</p>
<p>
Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : "Never"}
</p>
</div>
))}
</div>
<h3>Resulting account</h3>
<p>
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
{preview.proposed.seerr_user_id ?? "Not verified"}
</p>
<p>
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
</p>
<p>
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
</p>
<p>
Access: {preview.proposed.is_blocked ? "Blocked" : "Not blocked"} · Expiry:{" "}
{preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : "None"} ·
Automatic search: {preview.proposed.auto_search_enabled ? "Enabled" : "Disabled"}
</p>
<ul>
{FEATURES.map((feature) => (
<li key={feature.key}>
{feature.label}: {preview.proposed.features[feature.key] ? "Enabled" : "Disabled"}
</li>
))}
</ul>
<p>
Request, issue, invitation and login activity history is retained. The selected account keeps its email
and profile. Any block, earlier expiry or disabled permission on either row is preserved.
</p>
<p>
Extra Magent rows are removed from the active directory after their details are archived. Their
outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains
its own subscriptions where still eligible. Password reset links must be requested again.
</p>
<p>
Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different
Jellyfin identities or delete upstream users.
</p>
{preview.issues.length > 0 && (
<ul className="identity-issues">
{preview.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={acknowledged}
disabled={busy || saving || !preview.can_confirm}
onChange={(event) => setAcknowledged(event.target.checked)}
/>{" "}
I confirm these rows belong to the same person and have reviewed the account to keep.
</span>
</label>
<button
type="button"
disabled={!preview.can_confirm || !acknowledged || busy || saving}
onClick={() => void submit(true)}
>
{saving ? "Rechecking and repairing..." : "Confirm duplicate repair"}
</button>
</section>
)}
</div>
</dialog>
);
} }
@@ -1,171 +1,506 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import './identities.css' import "./identities.css";
import DuplicateAccountRepair from './DuplicateAccountRepair' import DuplicateAccountRepair from "./DuplicateAccountRepair";
import ResolveIdentityLink from './ResolveIdentityLink' import ResolveIdentityLink from "./ResolveIdentityLink";
type Identity = { id: string; name: string } type Identity = { id: string; name: string };
export type Row = { export type Row = {
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null } user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null };
jellyfin: Identity | null jellyfin: Identity | null;
candidate_jellyfin_id: string | null candidate_jellyfin_id: string | null;
stored_jellyfin_id: string | null stored_jellyfin_id: string | null;
seerr: { id: number; name: string; jellyfin_id: string }[] seerr: { id: number; name: string; jellyfin_id: string }[];
jellystat: { state: string; id?: string; name?: string } jellystat: { state: string; id?: string; name?: string };
basis: string basis: string;
issues: string[] issues: string[];
state: string state: string;
can_confirm: boolean can_confirm: boolean;
confirmed_at: string | null confirmed_at: string | null;
} };
type Report = { type Report = {
revision: string; checked_at: string; server_id: string | null revision: string;
services: Record<string, string> checked_at: string;
counts: Record<string, number> server_id: string | null;
jellyfin_users: Identity[] services: Record<string, string>;
rows: Row[] counts: Record<string, number>;
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[] jellyfin_users: Identity[];
} rows: Row[];
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' } upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[];
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' } };
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerrs Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' } const labels: Record<string, string> = {
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' } ready: "Ready to review",
confirmed: "Confirmed",
conflict: "Conflict",
unlinked: "Missing link",
unavailable: "Check incomplete",
};
const serviceLabels: Record<string, string> = {
available: "Checked",
unavailable: "Unavailable",
not_configured: "Not configured",
not_checked: "No IDs to check",
};
const basisLabels: Record<string, string> = {
confirmed_id: "Confirmed Jellyfin ID",
stored_jellyfin_id: "Stored Jellyfin ID",
stored_seerr_id: "Seerrs Jellyfin ID",
suggested_username: "Suggested from Jellyfin username — review before saving",
none: "No identity match",
};
const statsLabels: Record<string, string> = {
matched: "ID matches",
missing: "ID not found",
unavailable: "Could not check",
not_configured: "Not configured",
not_checked: "No ID to check",
};
export default function IdentityReviewPanel() { export default function IdentityReviewPanel() {
const router = useRouter() const router = useRouter();
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false);
const [report, setReport] = useState<Report | null>(null) const [report, setReport] = useState<Report | null>(null);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [query, setQuery] = useState('') const [query, setQuery] = useState("");
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState("all");
const [selected, setSelected] = useState<number[]>([]) const [selected, setSelected] = useState<number[]>([]);
const [duplicates, setDuplicates] = useState<Row | null>(null) const [duplicates, setDuplicates] = useState<Row | null>(null);
const [resolving, setResolving] = useState<Row | null>(null) const [resolving, setResolving] = useState<Row | null>(null);
const [reviewing, setReviewing] = useState(false) const [reviewing, setReviewing] = useState(false);
const controller = useRef<AbortController | null>(null) const controller = useRef<AbortController | null>(null);
const reviewPanel = useRef<HTMLElement | null>(null) const reviewPanel = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
setQuery(new URLSearchParams(window.location.search).get('user') ?? '') setQuery(new URLSearchParams(window.location.search).get("user") ?? "");
const abort = new AbortController() const abort = new AbortController();
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => { void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal })
if (response.status === 401) { router.replace('/login'); return } .then(async (response) => {
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.') if (response.status === 401) {
if ((await response.json()).role !== 'admin') { router.replace('/'); return } router.replace("/login");
if (!abort.signal.aborted) setReady(true) return;
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) }
return () => { abort.abort(); controller.current?.abort() } if (!response.ok) throw new Error("Could not check administrator access. Refresh to try again.");
}, [router]) if ((await response.json()).role !== "admin") {
router.replace("/");
return;
}
if (!abort.signal.aborted) setReady(true);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => {
abort.abort();
controller.current?.abort();
};
}, [router]);
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing]) useEffect(() => {
if (reviewing) reviewPanel.current?.focus();
}, [reviewing]);
const responseData = async (response: Response) => { const responseData = async (response: Response) => {
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') } if (response.status === 401) {
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') } router.replace("/login");
const data = await response.json().catch(() => ({})) throw new Error("Your session has ended. Sign in again.");
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.') }
return data if (response.status === 403) {
} router.replace("/");
throw new Error("Administrator access is required.");
}
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof data.detail === "string" ? data.detail : "The identity check could not complete. Try again.",
);
return data;
};
const runCheck = async () => { const runCheck = async () => {
controller.current?.abort() controller.current?.abort();
const abort = new AbortController() const abort = new AbortController();
controller.current = abort controller.current = abort;
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null) setBusy(true);
setError("");
setNotice("");
setSelected([]);
setReviewing(false);
setReport(null);
try { try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal })) const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }));
if (!abort.signal.aborted) setReport(data) if (!abort.signal.aborted) setReport(data);
} catch (err) { } catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.') if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not check identities.");
} finally { if (!abort.signal.aborted) setBusy(false) } } finally {
} if (!abort.signal.aborted) setBusy(false);
}
};
const save = async () => { const save = async () => {
if (!report || saving || !selected.length) return if (!report || saving || !selected.length) return;
setSaving(true); setError(''); setNotice('') setSaving(true);
setError("");
setNotice("");
try { try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, { const data = await responseData(
method: 'POST', headers: { 'Content-Type': 'application/json' }, await authFetch(`${getApiBase()}/admin/identities/confirm`, {
body: JSON.stringify({ revision: report.revision, user_ids: selected }), method: "POST",
})) headers: { "Content-Type": "application/json" },
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`) body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}),
);
setNotice(
`${data.confirmed} account ${data.confirmed === 1 ? "link" : "links"} confirmed and saved. Run another check to see the updated mappings.`,
);
// The scan describes the previous database state and cannot be reused for another write. // The scan describes the previous database state and cannot be reused for another write.
setReport(null); setSelected([]); setReviewing(false) setReport(null);
setSelected([]);
setReviewing(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Could not save identity links.') setError(err instanceof Error ? err.message : "Could not save identity links.");
setReport(null); setSelected([]); setReviewing(false) setReport(null);
} finally { setSaving(false) } setSelected([]);
} setReviewing(false);
} finally {
setSaving(false);
}
};
const needle = query.trim().toLowerCase() const needle = query.trim().toLowerCase();
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) && const filtered =
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? [] report?.rows.filter(
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [] (row) =>
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id) (filter === "all" || row.state === filter) &&
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) } [
row.user.username,
row.user.id,
row.candidate_jellyfin_id,
row.user.jellyseerr_user_id,
...row.seerr.map((entry) => entry.id),
]
.join(" ")
.toLowerCase()
.includes(needle),
) ?? [];
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [];
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id);
const toggle = (id: number) => {
setReviewing(false);
setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
};
return ( return (
<div className="identity-review"> <div className="identity-review">
{error && <p className="error-banner" role="alert">{error}</p>} {error && (
{notice && <p className="status-banner" role="status">{notice}</p>} <p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!ready && !error && <p role="status">Checking administrator access</p>} {!ready && !error && <p role="status">Checking administrator access</p>}
{ready && <> {ready && (
<section className="identity-intro admin-panel"> <>
<div><h2>Confirm user IDs</h2><p>Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</p></div> <section className="identity-intro admin-panel">
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button> <div>
</section> <h2>Confirm user IDs</h2>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>} <p>
{report && <> Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div> user ID.
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p> </p>
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div> <p>
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p> Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
<div className="identity-filters"> Duplicate ownership and upstream changes require individual review.
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label> </p>
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label> </div>
</div> <button type="button" onClick={runCheck} disabled={busy || saving}>
<div className="identity-selection"> {busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
<span>{filtered.length} accounts shown · {selected.length} selected</span> </button>
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
</div>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<h2>Save these {selected.length} account links?</h2>
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin <code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}</li>)}</ul>
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
</section>}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
<dl className="identity-mapping">
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
</dl>
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
</article>)}
</section> </section>
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>} {busy && (
</>} <p role="status">
</>} Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />} </p>
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => { )}
setResolving(null); setReport(null); setSelected([]); setReviewing(false) {report && (
setNotice('Account links repaired and saved. Run another check to see the updated mappings.') <>
}} />} <div className="identity-service-strip">
{Object.entries(report.services).map(([service, state]) => (
<span key={service}>
<strong>{service === "seerr" ? "Seerr" : service === "jellyfin" ? "Jellyfin" : "Jellystat"}</strong>{" "}
{serviceLabels[state] ?? state}
</span>
))}
</div>
<p className="identity-meta">
Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server{" "}
<code>{report.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-counts">
{["magent", "ready", "confirmed", "conflict", "unlinked", "unavailable"].map((state) => (
<div key={state}>
<strong>{report.counts[state]}</strong>
<span>{state === "magent" ? "Magent accounts" : labels[state]}</span>
</div>
))}
</div>
<p className="identity-meta">
Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in
Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.
</p>
<div className="identity-filters">
<label>
Find an account
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Username or user ID"
disabled={saving}
/>
</label>
<label>
Show
<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}>
<option value="all">All accounts</option>
{Object.entries(labels).map(([state, label]) => (
<option key={state} value={state}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="identity-selection">
<span>
{filtered.length} accounts shown · {selected.length} selected
</span>
<button
type="button"
className="ghost-button"
disabled={saving || !eligible.length}
onClick={() => {
setSelected((current) => [...new Set([...current, ...eligible])]);
setReviewing(false);
}}
>
Select ready accounts shown
</button>
<button
type="button"
className="ghost-button"
disabled={saving || !selected.length}
onClick={() => {
setSelected([]);
setReviewing(false);
}}
>
Clear selection
</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>
Review selected links ({selected.length})
</button>
</div>
{reviewing && (
<section
className="identity-confirm-panel"
ref={reviewPanel}
tabIndex={-1}
aria-label="Review links before saving"
>
<h2>Save these {selected.length} account links?</h2>
<p>
Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live
IDs will be checked again before saving.
</p>
<ul>
{selectedRows.map((row) => (
<li key={row.user.id}>
<strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin{" "}
<code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}
</li>
))}
</ul>
<p>
Saving links does not merge or delete accounts. Existing requests and playback history stay with
their service IDs.
</p>
<div className="identity-confirm-actions">
<button type="button" onClick={save} disabled={saving}>
{saving ? "Rechecking and saving…" : "Confirm and save links"}
</button>
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setReviewing(false)}
>
Back to review
</button>
</div>
</section>
)}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => (
<article className="identity-account" key={row.user.id}>
<header>
<div className="identity-account-name">
{row.can_confirm && (
<input
type="checkbox"
aria-label={`Select ${row.user.username} (Magent ${row.user.id})`}
checked={selected.includes(row.user.id)}
disabled={saving}
onChange={() => toggle(row.user.id)}
/>
)}
<div>
<h2>{row.user.username}</h2>
<span>
Magent {row.user.id} ·{" "}
{row.user.auth_provider === "jellyseerr" ? "Seerr" : row.user.auth_provider} sign-in
</span>
</div>
</div>
<span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span>
</header>
<dl className="identity-mapping">
<div>
<dt>Jellyfin user ID</dt>
<dd>
<code>{row.candidate_jellyfin_id ?? "No match"}</code>
{row.jellyfin && <span>{row.jellyfin.name}</span>}
<small>{basisLabels[row.basis]}</small>
{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && (
<small>Stored: {row.stored_jellyfin_id}</small>
)}
</dd>
</div>
<div>
<dt>Seerr user ID</dt>
<dd>
<strong>
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(", ") : "No match"}
</strong>
<span>{row.seerr.map((entry) => entry.name).join(", ")}</span>
<small>Stored in Magent: {row.user.jellyseerr_user_id ?? "Not linked"}</small>
</dd>
</div>
<div>
<dt>Jellystat user ID</dt>
<dd>
<code>{row.jellystat.id ?? "Not verified"}</code>
<span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span>
</dd>
</div>
</dl>
{row.issues.length > 0 && (
<ul className="identity-issues">
{row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{(row.state === "unlinked" || row.state === "conflict") && (
<div className="identity-resolution-entry">
<p className="identity-meta">
Compare the correct Jellyfin identity with the stored links and review the smallest safe
repair.
</p>
<button
type="button"
className="ghost-button"
disabled={saving || report.services.jellyfin !== "available"}
onClick={() => setResolving(row)}
>
Review repair
</button>
{row.issues.some(
(issue) =>
issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username"),
) && (
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setDuplicates(row)}
>
Repair duplicate accounts
</button>
)}
</div>
)}
{row.state === "unavailable" && (
<p className="identity-meta">
A required service could not be checked. Check its connection and run this again.
</p>
)}
{row.confirmed_at && (
<p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>
)}
</article>
))}
</section>
{report.upstream.length > 0 && (
<details className="identity-upstream">
<summary>{report.upstream.length} upstream accounts need review</summary>
<ul>
{report.upstream.map((entry) => (
<li key={`${entry.platform}-${entry.id}`}>
<strong>
{entry.platform}: {entry.name}
</strong>{" "}
· ID <code>{entry.id}</code>
{entry.jellyfin_id && (
<span>
{" "}
· Jellyfin <code>{entry.jellyfin_id}</code>
</span>
)}
<p>{entry.detail}</p>
</li>
))}
</ul>
</details>
)}
</>
)}
</>
)}
{duplicates && (
<DuplicateAccountRepair
row={duplicates}
onClose={() => setDuplicates(null)}
onSaved={() => {
setDuplicates(null);
void runCheck().then(() => setNotice("Duplicate accounts repaired. History retained and links rechecked."));
}}
/>
)}
{resolving && report && (
<ResolveIdentityLink
row={resolving}
accounts={report.jellyfin_users}
onClose={() => setResolving(null)}
onSaved={() => {
setResolving(null);
setReport(null);
setSelected([]);
setReviewing(false);
setNotice("Account links repaired and saved. Run another check to see the updated mappings.");
}}
/>
)}
</div> </div>
) );
} }
@@ -1,106 +1,290 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import type { Row } from './IdentityReviewPanel' import type { Row } from "./IdentityReviewPanel";
type Preview = { type Preview = {
revision: string; server_id: string; row: Row revision: string;
before: { jellyfin_user_id: string | null; seerr_user_id: number | null } server_id: string;
seerr_users: { id: number; name: string; jellyfin_id: string | null }[] row: Row;
scope: string before: { jellyfin_user_id: string | null; seerr_user_id: number | null };
action: string seerr_users: { id: number; name: string; jellyfin_id: string | null }[];
} scope: string;
action: string;
};
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: { export default function ResolveIdentityLink({
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void row,
accounts,
onClose,
onSaved,
}: {
row: Row;
accounts: { id: string; name: string }[];
onClose: () => void;
onSaved: () => void;
}) { }) {
const dialog = useRef<HTMLDialogElement>(null) const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null) const controller = useRef<AbortController | null>(null);
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '') const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? "");
const [inspectSeerr, setInspectSeerr] = useState('') const [inspectSeerr, setInspectSeerr] = useState("");
const [createSeerr, setCreateSeerr] = useState(false) const [createSeerr, setCreateSeerr] = useState(false);
const [preview, setPreview] = useState<Preview | null>(null) const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
const previous = document.activeElement as HTMLElement | null const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden' document.body.style.overflow = "hidden";
dialog.current?.showModal() dialog.current?.showModal();
return () => { return () => {
controller.current?.abort() controller.current?.abort();
document.body.style.overflow = overflow document.body.style.overflow = overflow;
previous?.focus() previous?.focus();
} };
}, []) }, []);
const submit = async (confirm: boolean) => { const submit = async (confirm: boolean) => {
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return;
const abort = new AbortController() const abort = new AbortController();
controller.current = abort controller.current = abort;
setError('') setError("");
if (confirm) setSaving(true) if (confirm) setSaving(true);
else { setBusy(true); setPreview(null) } else {
setBusy(true);
setPreview(null);
}
try { try {
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, { const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? "confirm" : "check"}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' }, method: "POST",
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }), signal: abort.signal,
}) headers: { "Content-Type": "application/json" },
const data = await response.json().catch(() => ({})) body: JSON.stringify({
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.') user_id: row.user.id,
jellyfin_user_id: chosen,
create_seerr: createSeerr,
...(confirm ? { revision: preview?.revision } : {}),
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
response.status === 401
? "Your session has ended. Sign in again."
: typeof data.detail === "string"
? data.detail
: "Could not check the account links. Try again.",
);
if (!abort.signal.aborted) { if (!abort.signal.aborted) {
if (confirm) onSaved() if (confirm) onSaved();
else setPreview(data) else setPreview(data);
} }
} catch (err) { } catch (err) {
if (!abort.signal.aborted) { if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : 'Could not resolve the link.') setError(err instanceof Error ? err.message : "Could not resolve the link.");
setPreview(null) setPreview(null);
} }
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } } } finally {
} if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}> return (
<div className="identity-resolve-content"> <dialog
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header> ref={dialog}
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p> className="identity-resolve-dialog"
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => { aria-labelledby="resolve-title"
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value) onCancel={(event) => {
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} {account.id}</option>)}</select></label> event.preventDefault();
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label> if (!saving) onClose();
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button> }}
{error && <p className="error-banner" role="alert">{error}</p>} >
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>} <div className="identity-resolve-content">
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite"> <header>
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3> <h2 id="resolve-title">Review account repair</h2>
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p> <button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
<div className="identity-mapping"> Close
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div> </button>
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div> </header>
</div> <p>
<dl className="identity-mapping"> Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div> that these identities belong to the same person before repairing Magent.
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again.'}</dd></div> </p>
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div> <label>
</dl> Jellyfin account
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>} <select
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>} value={chosen}
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance"> disabled={saving}
<h3>Check the existing Seerr account</h3> onChange={(event) => {
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p> controller.current?.abort();
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label> setBusy(false);
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)} setPreview(null);
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p> setError("");
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p> setCreateSeerr(false);
</div>} setChosen(event.target.value);
<p>{preview.scope}</p> }}
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p> >
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>} <option value="">Choose an account</option>
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button> {[...accounts]
</section>} .sort((a, b) => a.name.localeCompare(b.name))
</div> .map((account) => (
</dialog> <option key={account.id} value={account.id}>
{account.name} {account.id}
</option>
))}
</select>
</label>
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={createSeerr}
disabled={busy || saving}
onChange={(event) => {
setCreateSeerr(event.target.checked);
setPreview(null);
}}
/>{" "}
This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.
</span>
</label>
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>
{busy ? "Checking all platform links…" : "Preview repair"}
</button>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
{preview && (
<section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<h3>{preview.row.can_confirm ? "Ready to repair" : "This link needs attention"}</h3>
<p className="identity-meta">
Jellyfin server <code>{preview.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-mapping">
<div>
<strong>Current Magent links</strong>
<p>
Jellyfin: <code>{preview.before.jellyfin_user_id ?? "Not linked"}</code>
</p>
<p>Seerr: {preview.before.seerr_user_id ?? "Not linked"}</p>
</div>
<div>
<strong>Proposed Magent links</strong>
<p>
Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code>
</p>
<p>
Seerr:{" "}
{preview.row.seerr.length === 1
? preview.row.seerr[0].id
: preview.action === "import_seerr"
? "Assigned by Seerr during import"
: "Not verified"}
</p>
</div>
</div>
<dl className="identity-mapping">
<div>
<dt>Jellyfin</dt>
<dd>
{preview.row.jellyfin?.name ?? "Account not found"}
<code>{preview.row.candidate_jellyfin_id}</code>
</dd>
</div>
<div>
<dt>Seerr</dt>
<dd>
{preview.row.seerr.length
? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(", ")
: "No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again."}
</dd>
</div>
<div>
<dt>Jellystat</dt>
<dd>
<code>{preview.row.jellystat.id ?? "Not verified"}</code>
{preview.row.jellystat.state === "matched"
? "Same Jellyfin ID verified"
: preview.row.jellystat.state === "missing"
? "This ID is missing from Jellystat. Check its Jellyfin sync, then check again."
: "Could not verify this ID. Check the Jellystat connection and try again."}
</dd>
</div>
</dl>
{preview.row.issues.length > 0 && (
<ul className="identity-issues">
{preview.row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{preview.row.state === "unavailable" && (
<p>A required service is unavailable. Restore its connection and check again.</p>
)}
{preview.row.seerr.length !== 1 && (
<div className="identity-upstream-guidance">
<h3>Check the existing Seerr account</h3>
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
<label>
Seerr account to inspect
<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}>
<option value="">Choose an existing account</option>
{preview.seerr_users.map((account) => (
<option key={account.id} value={account.id}>
{account.name} (ID {account.id})
</option>
))}
</select>
</label>
{preview.seerr_users
.filter((account) => String(account.id) === inspectSeerr)
.map((account) => (
<p key={account.id}>
Current Jellyfin ID: <code>{account.jellyfin_id ?? "Not linked"}</code>
</p>
))}
<p>
If this is the same person, use Seerr's account settings to reconnect their existing account to
Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the
existing Seerr account to preserve its requests and settings.
</p>
<p>
If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page,
then preview again. Do not import a second account to work around an existing identity mismatch.
</p>
</div>
)}
<p>{preview.scope}</p>
<p>
Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate
ownership are rechecked before the change is saved.
</p>
{preview.before.jellyfin_user_id &&
preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && (
<p>
Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to
opt in again.
</p>
)}
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>
{saving
? "Rechecking and saving…"
: preview.action === "import_seerr"
? "Import Seerr account and repair links"
: "Confirm repair"}
</button>
</section>
)}
</div>
</dialog>
);
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function IdentityReviewPage() { export default function IdentityReviewPage() {
redirect('/users?view=identities') redirect("/users?view=identities");
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
import PortalClient from '../../portal/PortalClient' import PortalClient from "../../portal/PortalClient";
export default function AdminIssuesPage() { export default function AdminIssuesPage() {
return <PortalClient workspace="issue" /> return <PortalClient workspace="issue" />;
} }
File diff suppressed because it is too large Load Diff
+95 -46
View File
@@ -1,77 +1,126 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase, getToken } from '../lib/auth' import { authFetch, getApiBase, getToken } from "../lib/auth";
import AdminShell from '../ui/AdminShell' import AdminShell from "../ui/AdminShell";
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation' import { CONFIG_GROUPS, serviceStatusLabel } from "./configNavigation";
type ServiceState = { name: string; status: string } type ServiceState = { name: string; status: string };
export default function AdminLandingPage() { export default function AdminLandingPage() {
const router = useRouter() const router = useRouter();
const [services, setServices] = useState<ServiceState[]>([]) const [services, setServices] = useState<ServiceState[]>([]);
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let active = true let active = true;
const load = async () => { const load = async () => {
if (!getToken()) { router.replace('/login'); return } if (!getToken()) {
try { router.replace("/login");
const response = await authFetch(`${getApiBase()}/auth/me`) return;
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.')
} }
} try {
void load() const response = await authFetch(`${getApiBase()}/auth/me`);
return () => { active = false } if (!response.ok) {
}, [router]) 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 ( return (
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works."> <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> : ( {!ready ? (
error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<p role="status">Loading settings</p>
)
) : (
<div className="config-directory"> <div className="config-directory">
{error && <p className="error-banner" role="alert">{error}</p>} {error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => ( {CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
<section className="config-directory-region" key={group.title}> <section className="config-directory-region" key={group.title}>
<header><h2>{group.title}</h2><p>{group.description}</p></header> <header>
<h2>{group.title}</h2>
<p>{group.description}</p>
</header>
<div className="config-directory-links"> <div className="config-directory-links">
{group.items.map((item) => { {group.items.map((item) => {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase()) const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase());
return ( return (
<a href={item.href} key={item.href} className="config-directory-link"> <a href={item.href} key={item.href} className="config-directory-link">
{item.symbol && <span className="config-link-icon" aria-hidden="true">{item.symbol}</span>} {item.symbol && (
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span> <span className="config-link-icon" aria-hidden="true">
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>} {item.symbol}
<span className="config-link-arrow" aria-hidden="true"></span> </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> </a>
) );
})} })}
</div> </div>
</section> </section>
))} ))}
<details className="config-advanced-directory"> <details className="config-advanced-directory">
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary> <summary>
<strong>Advanced tools</strong>
<span>Hosting, logs, caches and recovery</span>
</summary>
<div className="config-directory-links"> <div className="config-directory-links">
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => ( {CONFIG_GROUPS.filter((group) => group.advanced)
<a href={item.href} key={item.href} className="config-directory-link"> .flatMap((group) => group.items)
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span> .map((item) => (
<span className="config-link-arrow" aria-hidden="true"></span> <a href={item.href} key={item.href} className="config-directory-link">
</a> <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> </div>
</details> </details>
</div> </div>
)} )}
</AdminShell> </AdminShell>
) );
} }
+2 -3
View File
@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function AdminProfilesRedirectPage() { export default function AdminProfilesRedirectPage() {
redirect('/admin/invites') redirect("/admin/invites");
} }
+506 -112
View File
@@ -1,132 +1,526 @@
'use client' "use client";
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import Link from 'next/link' import Link from "next/link";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import '../../email-recaps/recaps.css' import "../../email-recaps/recaps.css";
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null } type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null };
type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string } type Delivery = {
type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean } id: string;
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null } month: string;
const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' }) kind: string;
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled' email: string;
const stateLabels: Record<string, string> = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' } username: string | null;
state: string;
attempts: number;
created_at: number;
updated_at: number;
next_attempt_at: number;
detail: string;
};
type Overview = {
settings: Settings;
ready: boolean;
detail: string;
months: string[];
deliveries: Delivery[];
total: number;
subscribers: number;
worker_enabled: boolean;
};
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null };
const monthLabel = (month: string) =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
const dateLabel = (value?: number | null) =>
value
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
: "Not scheduled";
const stateLabels: Record<string, string> = {
queued: "Queued",
preparing: "Preparing report",
sending: "Sending",
sent: "Accepted by mail server",
retry: "Retry scheduled",
failed: "Failed",
unknown: "Needs review",
cancelled: "Cancelled",
};
export default function EmailRecapsAdminPage() { export default function EmailRecapsAdminPage() {
const router = useRouter() const router = useRouter();
const [data, setData] = useState<Overview | null>(null) const [data, setData] = useState<Overview | null>(null);
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: '' }) const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: "" });
const [month, setMonth] = useState('') const [month, setMonth] = useState("");
const [preview, setPreview] = useState<Preview | null>(null) const [preview, setPreview] = useState<Preview | null>(null);
const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html') const [previewMode, setPreviewMode] = useState<"html" | "text">("html");
const [error, setError] = useState('') const [error, setError] = useState("");
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [busy, setBusy] = useState('') const [busy, setBusy] = useState("");
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const testRequest = useRef<{ month: string; id: string } | null>(null) const testRequest = useRef<{ month: string; id: string } | null>(null);
const initialized = useRef(false) const initialized = useRef(false);
const previewController = useRef<AbortController | null>(null) const previewController = useRef<AbortController | null>(null);
const responseData = useCallback(async (response: Response) => { const responseData = useCallback(
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') } async (response: Response) => {
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') } if (response.status === 401) {
const result = await response.json().catch(() => ({})) router.replace("/login?next=%2Fadmin%2Frecaps");
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.') throw new Error("Sign in to continue.");
return result }
}, [router]) if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not complete this action. Check your settings and try again.",
);
return result;
},
[router],
);
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => { const abort = new AbortController();
if (abort.signal.aborted) return void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal })
setData(result) .then(responseData)
if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true } .then((result: Overview) => {
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) if (abort.signal.aborted) return;
return () => abort.abort() setData(result);
}, [offset, revision, responseData]) if (!initialized.current) {
setSettings(result.settings);
setMonth(result.months[0] || "");
initialized.current = true;
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [offset, revision, responseData]);
useEffect(() => { useEffect(() => {
if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return if (!data?.deliveries.some((delivery) => ["queued", "preparing", "sending", "retry"].includes(delivery.state)))
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000) return;
return () => window.clearInterval(timer) const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
}, [data]) return () => window.clearInterval(timer);
useEffect(() => () => previewController.current?.abort(), []) }, [data]);
useEffect(() => () => previewController.current?.abort(), []);
const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url) const dirty =
!!data &&
(settings.enabled !== data.settings.enabled ||
settings.day !== data.settings.day ||
settings.hour !== data.settings.hour ||
settings.public_url !== data.settings.public_url);
const save = async (event: FormEvent) => { const save = async (event: FormEvent) => {
event.preventDefault() event.preventDefault();
if (busy) return if (busy) return;
setBusy('save'); setError(''); setNotice('') setBusy("save");
setError("");
setNotice("");
try { try {
const { enabled, day, hour } = settings const { enabled, day, hour } = settings;
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour }) })) as Settings const result = (await responseData(
setSettings(result); setData((current) => current ? { ...current, settings: result } : current) await authFetch(`${getApiBase()}/admin/email-recaps`, {
setPreview(null) method: "PUT",
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.') headers: { "Content-Type": "application/json" },
setRevision((value) => value + 1) body: JSON.stringify({ enabled, day, hour }),
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') } }),
finally { setBusy('') } )) as Settings;
} setSettings(result);
setData((current) => (current ? { ...current, settings: result } : current));
setPreview(null);
setNotice(
result.enabled
? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.`
: "Settings saved. Scheduled delivery is paused.",
);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the schedule.");
} finally {
setBusy("");
}
};
const loadPreview = async () => { const loadPreview = async () => {
if (busy || !month) return if (busy || !month) return;
const abort = new AbortController() const abort = new AbortController();
previewController.current?.abort(); previewController.current = abort previewController.current?.abort();
setBusy('preview'); setError(''); setNotice(''); setPreview(null) previewController.current = abort;
setBusy("preview");
setError("");
setNotice("");
setPreview(null);
try { try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview const result = (await responseData(
if (!abort.signal.aborted) setPreview(result) await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal }),
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') } )) as Preview;
finally { if (!abort.signal.aborted) setBusy('') } if (!abort.signal.aborted) setPreview(result);
} } catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not prepare your preview.");
} finally {
if (!abort.signal.aborted) setBusy("");
}
};
const sendTest = async () => { const sendTest = async () => {
if (busy || !preview || preview.month !== month) return if (busy || !preview || preview.month !== month) return;
if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() } if (!testRequest.current || testRequest.current.month !== month)
setBusy('test'); setError(''); setNotice('') testRequest.current = { month, id: crypto.randomUUID() };
setBusy("test");
setError("");
setNotice("");
try { try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) })) const result = await responseData(
setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1) await authFetch(`${getApiBase()}/admin/email-recaps/test`, {
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') } method: "POST",
finally { setBusy('') } headers: { "Content-Type": "application/json" },
} body: JSON.stringify({ month, request_id: testRequest.current.id }),
}),
);
setNotice(result.message);
testRequest.current = null;
setOffset(0);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your test.");
} finally {
setBusy("");
}
};
return <AdminShell title="Monthly email recaps" subtitle="Give each user a personal look back at their month in viewing." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}> return (
<div className="recap-admin"> <AdminShell
{error && <p className="error-banner" role="alert">{error}</p>} title="Monthly email recaps"
{notice && <p className="status-banner" role="status">{notice}</p>} subtitle="Give each user a personal look back at their month in viewing."
{!data && !error && <p role="status">Loading email recaps</p>} actions={
{!data && error && <button className="ghost-button" type="button" onClick={() => { setError(''); setRevision((value) => value + 1) }}>Try again</button>} <a className="ghost-button" href="/admin/notifications">
{data && <> Email settings
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}</span><p>{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when youre ready for monthly delivery.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div> </a>
<div className="recap-admin-grid"> }
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div> >
<p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p> <div className="recap-admin">
<form className="recap-schedule-form" onSubmit={save}> {error && (
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" value={settings.public_url} readOnly /><small>Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when that address changes.</small></label> <p className="error-banner" role="alert">
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div> {error}
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label> </p>
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p> )}
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save schedule'}</button> {notice && (
</form> <p className="status-banner" role="status">
{!data.ready && <p className="recap-setup-note">{data.detail} <a href="/admin/notifications">Review email settings </a></p>} {notice}
</section> </p>
<section className="admin-panel recap-panel"><span className="recap-eyebrow">Make it yours</span><h2>Preview your recap</h2><p>See your own viewing highlights in the email design. A test goes only to your confirmed profile email.</p> )}
<label className="recap-month-label" htmlFor="recap-month">Report month<select id="recap-month" value={month} disabled={!!busy} onChange={(event) => { setMonth(event.target.value); setPreview(null); testRequest.current = null }}>{data.months.map((value) => <option key={value} value={value}>{monthLabel(value)}</option>)}</select></label> {!data && !error && <p role="status">Loading email recaps</p>}
<div className="recap-actions"><button type="button" className="account-primary" onClick={() => void loadPreview()} disabled={!!busy || dirty || !month}>{busy === 'preview' ? 'Preparing preview…' : 'Preview my recap'}</button><button type="button" className="account-secondary" onClick={() => void sendTest()} disabled={!!busy || dirty || !preview || !data.ready}>{busy === 'test' ? 'Queuing test…' : 'Send test to me'}</button></div> {!data && error && (
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>} <button
<div className="recap-preview-guidance"><h3>One email. Your month.</h3><ul><li>Minutes, movies, episodes and requests</li><li>Changes from the previous month</li><li>Most watched titles and your longest run</li><li>A link to the full report and easy unsubscribe</li></ul><a href="/profile#monthly-recaps">Confirm your email in Profile </a></div> className="ghost-button"
</section> type="button"
</div> onClick={() => {
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>} setError("");
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div> setRevision((value) => value + 1);
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>} }}
</section> >
</>} Try again
</div> </button>
</AdminShell> )}
{data && (
<>
<div className="recap-overview-strip">
<div>
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
{data.settings.enabled ? "Schedule running" : "Schedule paused"}
</span>
<p>
{data.settings.enabled
? `Next send ${dateLabel(data.settings.next_send_at)}`
: "Start the schedule when youre ready for monthly delivery."}
</p>
</div>
<div className="recap-subscriber-count">
<strong>{data.subscribers}</strong>
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
</div>
</div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Set the rhythm</span>
<h2>Monthly schedule</h2>
</div>
</div>
<p>
Send the previous months report to users who have opted in and confirmed their email. All report
periods and send times use UTC.
</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">
Public Magent address
<input id="recap-public-url" type="url" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when
that address changes.
</small>
</label>
<div className="recap-schedule-fields">
<label htmlFor="recap-day">
Day of the month
<select
id="recap-day"
value={settings.day}
onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 28 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
</label>
<label htmlFor="recap-hour">
Send time (UTC)
<select
id="recap-hour"
value={settings.hour}
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 24 }, (_, hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, "0")}:00 UTC
</option>
))}
</select>
</label>
</div>
<label className="recap-checkbox">
<input
type="checkbox"
checked={settings.enabled}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
/>
<span>Enable scheduled monthly recaps</span>
</label>
<p className="recap-muted">
Starting or changing the schedule begins at its next future send time. Pausing cancels queued
monthly emails.
</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>
{busy === "save" ? "Saving…" : "Save schedule"}
</button>
</form>
{!data.ready && (
<p className="recap-setup-note">
{data.detail} <a href="/admin/notifications">Review email settings </a>
</p>
)}
</section>
<section className="admin-panel recap-panel">
<span className="recap-eyebrow">Make it yours</span>
<h2>Preview your recap</h2>
<p>
See your own viewing highlights in the email design. A test goes only to your confirmed profile email.
</p>
<label className="recap-month-label" htmlFor="recap-month">
Report month
<select
id="recap-month"
value={month}
disabled={!!busy}
onChange={(event) => {
setMonth(event.target.value);
setPreview(null);
testRequest.current = null;
}}
>
{data.months.map((value) => (
<option key={value} value={value}>
{monthLabel(value)}
</option>
))}
</select>
</label>
<div className="recap-actions">
<button
type="button"
className="account-primary"
onClick={() => void loadPreview()}
disabled={!!busy || dirty || !month}
>
{busy === "preview" ? "Preparing preview…" : "Preview my recap"}
</button>
<button
type="button"
className="account-secondary"
onClick={() => void sendTest()}
disabled={!!busy || dirty || !preview || !data.ready}
>
{busy === "test" ? "Queuing test…" : "Send test to me"}
</button>
</div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance">
<h3>One email. Your month.</h3>
<ul>
<li>Minutes, movies, episodes and requests</li>
<li>Changes from the previous month</li>
<li>Most watched titles and your longest run</li>
<li>A link to the full report and easy unsubscribe</li>
</ul>
<a href="/profile#monthly-recaps">Confirm your email in Profile </a>
</div>
</section>
</div>
{preview && (
<section className="admin-panel recap-panel recap-preview">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Email preview</span>
<h2>{preview.subject}</h2>
<p>For {preview.email || "your profile email"} · Preview links use your saved public address.</p>
</div>
<div className="recap-mode-buttons">
<button type="button" aria-pressed={previewMode === "html"} onClick={() => setPreviewMode("html")}>
Email design
</button>
<button type="button" aria-pressed={previewMode === "text"} onClick={() => setPreviewMode("text")}>
Plain text
</button>
</div>
</div>
{previewMode === "html" ? (
<iframe
title="Monthly recap email preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={preview.body_html}
/>
) : (
<pre className="recap-plain-preview">{preview.body_text}</pre>
)}
</section>
)}
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">From queue to inbox</span>
<h2>Delivery history</h2>
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
</div>
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Refresh history
</button>
</div>
{data.deliveries.length ? (
<>
<div className="recap-history-scroll">
<table className="recap-history">
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Report</th>
<th scope="col">Delivery</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{data.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td>
<strong>{delivery.username || "Removed account"}</strong>
<small>{delivery.email}</small>
</td>
<td>
{monthLabel(delivery.month)}
<small>
{delivery.kind === "test"
? "Test email"
: delivery.kind === "on_demand"
? "Requested by user"
: "Scheduled recap"}
</small>
</td>
<td>
<span
className={`recap-pill ${delivery.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(delivery.state) ? "is-attention" : ""}`}
>
{stateLabels[delivery.state] || delivery.state}
</span>
<small>
{delivery.attempts} {delivery.attempts === 1 ? "attempt" : "attempts"} ·{" "}
{delivery.detail || "Waiting for the next worker check."}
</small>
{delivery.state === "retry" && (
<small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>
)}
{delivery.state === "unknown" && (
<small>Automatic retries are stopped to avoid a duplicate email.</small>
)}
</td>
<td>{dateLabel(delivery.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="recap-pagination">
<span>
{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}
</span>
<div className="recap-actions">
<button
className="ghost-button"
type="button"
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 50))}
>
Previous
</button>
<button
className="ghost-button"
type="button"
disabled={offset + 50 >= data.total}
onClick={() => setOffset(offset + 50)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Your first recap starts here</h3>
<p>
Preview your email, send yourself a test, then start the monthly schedule. Delivery results will
appear here.
</p>
</div>
)}
</section>
</>
)}
</div>
</AdminShell>
);
} }
+76 -88
View File
@@ -1,115 +1,111 @@
'use client' "use client";
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
type RequestRow = { type RequestRow = {
id: number id: number;
title?: string | null title?: string | null;
year?: number | null year?: number | null;
type?: string | null type?: string | null;
statusLabel?: string | null statusLabel?: string | null;
requestedBy?: string | null requestedBy?: string | null;
createdAt?: string | null createdAt?: string | null;
} };
const REQUEST_STAGE_OPTIONS = [ const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' }, { value: "all", label: "All stages" },
{ value: 'pending', label: 'Waiting for approval' }, { value: "pending", label: "Waiting for approval" },
{ value: 'approved', label: 'Approved' }, { value: "approved", label: "Approved" },
{ value: 'in_progress', label: 'In progress' }, { value: "in_progress", label: "In progress" },
{ value: 'working', label: 'Working on it' }, { value: "working", label: "Working on it" },
{ value: 'partial', label: 'Partially ready' }, { value: "partial", label: "Partially ready" },
{ value: 'ready', label: 'Ready to watch' }, { value: "ready", label: "Ready to watch" },
{ value: 'declined', label: 'Declined' }, { value: "declined", label: "Declined" },
] ];
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown' if (!value) return "Unknown";
const date = new Date(value) const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString() return date.toLocaleString();
} };
export default function AdminRequestsAllPage() { export default function AdminRequestsAllPage() {
const router = useRouter() const router = useRouter();
const [rows, setRows] = useState<RequestRow[]>([]) const [rows, setRows] = useState<RequestRow[]>([]);
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [stage, setStage] = useState('all') const [stage, setStage] = useState("all");
const pageCount = useMemo(() => { const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1 if (!total || pageSize <= 0) return 1;
return Math.max(1, Math.ceil(total / pageSize)) return Math.max(1, Math.ceil(total / pageSize));
}, [total, pageSize]) }, [total, pageSize]);
const load = async () => { const load = useCallback(async () => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize;
const params = new URLSearchParams({ const params = new URLSearchParams({
take: String(pageSize), take: String(pageSize),
skip: String(skip), skip: String(skip),
}) });
if (stage !== 'all') { if (stage !== "all") {
params.set('stage', stage) params.set("stage", stage);
} }
const response = await authFetch( const response = await authFetch(`${baseUrl}/admin/requests/all?${params.toString()}`);
`${baseUrl}/admin/requests/all?${params.toString()}`
)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
if (response.status === 403) { if (response.status === 403) {
router.push('/') router.push("/");
return return;
} }
throw new Error(`Load failed: ${response.status}`) throw new Error(`Load failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
setRows(Array.isArray(data?.results) ? data.results : []) setRows(Array.isArray(data?.results) ? data.results : []);
setTotal(Number(data?.total ?? 0)) setTotal(Number(data?.total ?? 0));
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError('Unable to load requests.') setError("Unable to load requests.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }, [page, pageSize, router, stage]);
useEffect(() => { useEffect(() => {
void load() void load();
}, [page, pageSize, stage]) }, [load]);
useEffect(() => { useEffect(() => {
if (page > pageCount) { if (page > pageCount) {
setPage(pageCount) setPage(pageCount);
} }
}, [pageCount, page]) }, [pageCount, page]);
useEffect(() => { useEffect(() => {
setPage(1) void stage;
}, [stage]) setPage(1);
}, [stage]);
return ( return (
<AdminShell <AdminShell title="All requests" subtitle="Paginated view of every cached request.">
title="All requests"
subtitle="Paginated view of every cached request."
>
<section className="admin-section"> <section className="admin-section">
<div className="admin-toolbar"> <div className="admin-toolbar">
<div className="admin-toolbar-info"> <div className="admin-toolbar-info">
@@ -160,10 +156,10 @@ export default function AdminRequestsAllPage() {
> >
<span> <span>
{row.title || `Request #${row.id}`} {row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''} {row.year ? ` (${row.year})` : ""}
</span> </span>
<span>{row.statusLabel || 'Unknown'}</span> <span>{row.statusLabel || "Unknown"}</span>
<span>{row.requestedBy || 'Unknown'}</span> <span>{row.requestedBy || "Unknown"}</span>
<span>{formatDateTime(row.createdAt)}</span> <span>{formatDateTime(row.createdAt)}</span>
</button> </button>
))} ))}
@@ -179,22 +175,14 @@ export default function AdminRequestsAllPage() {
<span> <span>
Page {page} of {pageCount} Page {page} of {pageCount}
</span> </span>
<button <button type="button" onClick={() => setPage(page + 1)} disabled={page >= pageCount}>
type="button"
onClick={() => setPage(page + 1)}
disabled={page >= pageCount}
>
Next Next
</button> </button>
<button <button type="button" onClick={() => setPage(pageCount)} disabled={page >= pageCount}>
type="button"
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
>
Last Last
</button> </button>
</div> </div>
</section> </section>
</AdminShell> </AdminShell>
) );
} }
+92 -115
View File
@@ -1,106 +1,106 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
type FlowStage = { type FlowStage = {
title: string title: string;
input: string input: string;
action: string action: string;
output: string output: string;
} };
const REQUEST_FLOW: FlowStage[] = [ const REQUEST_FLOW: FlowStage[] = [
{ {
title: 'Identity + access', title: "Identity + access",
input: 'Jellyfin/local login', input: "Jellyfin/local login",
action: 'Magent validates credentials and role', action: "Magent validates credentials and role",
output: 'JWT token + user scope', output: "JWT token + user scope",
}, },
{ {
title: 'Request intake', title: "Request intake",
input: 'Seerr request ID', input: "Seerr request ID",
action: 'Magent snapshots request + media metadata', action: "Magent snapshots request + media metadata",
output: 'Unified request state', output: "Unified request state",
}, },
{ {
title: 'Queue orchestration', title: "Queue orchestration",
input: 'Approved request', input: "Approved request",
action: 'Sonarr/Radarr add/search operations', action: "Sonarr/Radarr add/search operations",
output: 'Grab decision', output: "Grab decision",
}, },
{ {
title: 'Download execution', title: "Download execution",
input: 'Selected release', input: "Selected release",
action: 'qBittorrent downloads + reports progress', action: "qBittorrent downloads + reports progress",
output: 'Import-ready payload', output: "Import-ready payload",
}, },
{ {
title: 'Library import', title: "Library import",
input: 'Completed download', input: "Completed download",
action: 'Sonarr/Radarr import and finalize', action: "Sonarr/Radarr import and finalize",
output: 'Available media object', output: "Available media object",
}, },
{ {
title: 'Playback availability', title: "Playback availability",
input: 'Imported media', input: "Imported media",
action: 'Jellyfin refresh + link resolution', action: "Jellyfin refresh + link resolution",
output: 'Ready-to-watch state', output: "Ready-to-watch state",
}, },
] ];
export default function AdminSystemGuidePage() { export default function AdminSystemGuidePage() {
const router = useRouter() const router = useRouter();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false) const [authorized, setAuthorized] = useState(false);
useEffect(() => { useEffect(() => {
let active = true let active = true;
const load = async () => { const load = async () => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
router.push('/') router.push("/");
return return;
} }
const me = await response.json() const me = await response.json();
if (!active) return if (!active) return;
if (me?.role !== 'admin') { if (me?.role !== "admin") {
router.push('/') router.push("/");
return return;
} }
setAuthorized(true) setAuthorized(true);
} catch (error) { } catch (error) {
console.error(error) console.error(error);
router.push('/') router.push("/");
} finally { } finally {
if (active) setLoading(false) if (active) setLoading(false);
} }
} };
void load() void load();
return () => { return () => {
active = false active = false;
} };
}, [router]) }, [router]);
if (loading) { if (loading) {
return <main className="card">Loading system guide...</main> return <main className="card">Loading system guide...</main>;
} }
if (!authorized) { if (!authorized) {
return null return null;
} }
const rail = ( const rail = (
@@ -112,26 +112,23 @@ export default function AdminSystemGuidePage() {
<span className="small-pill">Admin only</span> <span className="small-pill">Admin only</span>
</div> </div>
</div> </div>
) );
return ( return (
<AdminShell <AdminShell title="System guide" subtitle="Service connections, controls, and recovery paths." rail={rail}>
title="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
>
<section className="admin-section system-guide"> <section className="admin-section system-guide">
<div className="admin-panel"> <div className="admin-panel">
<h2>End-to-end system flow</h2> <h2>End-to-end system flow</h2>
<p className="lede"> <p className="lede">
This is the runtime path the platform follows from authentication through to playback This is the runtime path the platform follows from authentication through to playback availability.
availability.
</p> </p>
<div className="system-flow-track"> <div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => ( {REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment"> <div key={stage.title} className="system-flow-segment">
<article className="system-flow-card"> <article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div> <div className="system-flow-card-title">
{index + 1}. {stage.title}
</div>
<div className="system-flow-card-row"> <div className="system-flow-card-row">
<span>Input</span> <span>Input</span>
<strong>{stage.input}</strong> <strong>{stage.input}</strong>
@@ -145,7 +142,11 @@ export default function AdminSystemGuidePage() {
<strong>{stage.output}</strong> <strong>{stage.output}</strong>
</div> </div>
</article> </article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>} {index < REQUEST_FLOW.length - 1 && (
<div className="system-flow-arrow" aria-hidden="true">
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -157,30 +158,23 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Magent</h3> <h3>Magent</h3>
<p> <p>
Handles authentication, request pages, live event updates, invite workflows, Handles authentication, request pages, live event updates, invite workflows, diagnostics, notifications,
diagnostics, notifications, and admin operations. and admin operations.
</p> </p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Seerr</h3> <h3>Seerr</h3>
<p> <p>
Stores the request itself and remains the request-state source for approval and Stores the request itself and remains the request-state source for approval and media request metadata.
media request metadata.
</p> </p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Jellyfin</h3> <h3>Jellyfin</h3>
<p> <p>Provides user sign-in identity and the final playback destination once content is available.</p>
Provides user sign-in identity and the final playback destination once content is
available.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Sonarr / Radarr</h3> <h3>Sonarr / Radarr</h3>
<p> <p>Control queue placement, quality-profile decisions, import handling, and release monitoring.</p>
Control queue placement, quality-profile decisions, import handling, and release
monitoring.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Prowlarr</h3> <h3>Prowlarr</h3>
@@ -188,10 +182,7 @@ export default function AdminSystemGuidePage() {
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>qBittorrent</h3> <h3>qBittorrent</h3>
<p> <p>Executes the download and exposes live progress, paused states, and queue visibility.</p>
Executes the download and exposes live progress, paused states, and queue
visibility.
</p>
</article> </article>
</div> </div>
</div> </div>
@@ -213,10 +204,7 @@ export default function AdminSystemGuidePage() {
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Invite management</h3> <h3>Invite management</h3>
<p> <p>Master template, profile assignment, invite access policy, invite emails, and trace map lineage.</p>
Master template, profile assignment, invite access policy, invite emails, and trace
map lineage.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Requests + cache</h3> <h3>Requests + cache</h3>
@@ -225,8 +213,8 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Maintenance + diagnostics</h3> <h3>Maintenance + diagnostics</h3>
<p> <p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and Connectivity checks, live diagnostics, database repair, cleanup, log review, and nuclear flush/resync
nuclear flush/resync operations. operations.
</p> </p>
</article> </article>
</div> </div>
@@ -235,23 +223,11 @@ export default function AdminSystemGuidePage() {
<div className="admin-panel"> <div className="admin-panel">
<h2>User and invite model</h2> <h2>User and invite model</h2>
<ol className="system-decision-list"> <ol className="system-decision-list">
<li> <li>Jellyfin is used for sign-in identity and user presence across the platform.</li>
Jellyfin is used for sign-in identity and user presence across the platform. <li>Seerr provides request ownership and request-state data for Magent request pages.</li>
</li> <li>Invite links, invite profiles, blanket rules, and invite-access controls are managed inside Magent.</li>
<li> <li>If invite tracing is enabled, the lineage view shows who invited whom and how the chain branches.</li>
Seerr provides request ownership and request-state data for Magent request pages. <li>Cross-system removal and ban flows are initiated from Magent admin controls.</li>
</li>
<li>
Invite links, invite profiles, blanket rules, and invite-access controls are managed
inside Magent.
</li>
<li>
If invite tracing is enabled, the lineage view shows who invited whom and how the
chain branches.
</li>
<li>
Cross-system removal and ban flows are initiated from Magent admin controls.
</li>
</ol> </ol>
</div> </div>
@@ -265,7 +241,8 @@ export default function AdminSystemGuidePage() {
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options. In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li> </li>
<li> <li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>. Release exists and user should not pick manually <span></span> run{" "}
<strong>Search + auto-download</strong>.
</li> </li>
<li> <li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>. Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
@@ -295,5 +272,5 @@ export default function AdminSystemGuidePage() {
</div> </div>
</section> </section>
</AdminShell> </AdminShell>
) );
} }
+56 -56
View File
@@ -1,95 +1,95 @@
'use client' "use client";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type SiteInfo = { type SiteInfo = {
changelog?: string changelog?: string;
} };
type ChangelogGroup = { type ChangelogGroup = {
date: string date: string;
entries: string[] entries: string[];
} };
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const parseChangelog = (raw: string): ChangelogGroup[] => { const parseChangelog = (raw: string): ChangelogGroup[] => {
const groups: ChangelogGroup[] = [] const groups: ChangelogGroup[] = [];
for (const rawLine of raw.split('\n')) { for (const rawLine of raw.split("\n")) {
const line = rawLine.trim() const line = rawLine.trim();
if (!line) continue if (!line) continue;
const [candidateDate, ...messageParts] = line.split('|') const [candidateDate, ...messageParts] = line.split("|");
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) { if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
const message = messageParts.join('|').trim() const message = messageParts.join("|").trim();
if (!message) continue if (!message) continue;
const currentGroup = groups[groups.length - 1] const currentGroup = groups[groups.length - 1];
if (currentGroup?.date === candidateDate) { if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message) currentGroup.entries.push(message);
} else { } else {
groups.push({ date: candidateDate, entries: [message] }) groups.push({ date: candidateDate, entries: [message] });
} }
continue continue;
} }
if (groups.length === 0) { if (groups.length === 0) {
groups.push({ date: 'Updates', entries: [line] }) groups.push({ date: "Updates", entries: [line] });
} else { } else {
groups[groups.length - 1].entries.push(line) groups[groups.length - 1].entries.push(line);
} }
} }
return groups return groups;
} };
export default function ChangelogPage() { export default function ChangelogPage() {
const router = useRouter() const router = useRouter();
const [groups, setGroups] = useState<ChangelogGroup[]>([]) const [groups, setGroups] = useState<ChangelogGroup[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const token = getToken() const token = getToken();
if (!token) { if (!token) {
router.push('/login') router.push("/login");
return return;
} }
let active = true let active = true;
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/site/info`) const response = await authFetch(`${baseUrl}/site/info`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error('Failed to load changelog') throw new Error("Failed to load changelog");
} }
const data: SiteInfo = await response.json() const data: SiteInfo = await response.json();
if (!active) return if (!active) return;
setGroups(parseChangelog(data?.changelog ?? '')) setGroups(parseChangelog(data?.changelog ?? ""));
} catch (err) { } catch (err) {
console.error(err) console.error(err);
if (!active) return if (!active) return;
setGroups([]) setGroups([]);
} finally { } finally {
if (active) setLoading(false) if (active) setLoading(false);
} }
} };
void load() void load();
return () => { return () => {
active = false active = false;
} };
}, [router]) }, [router]);
const content = useMemo(() => { const content = useMemo(() => {
if (loading) { if (loading) {
return <div className="loading-text">Loading changelog...</div> return <div className="loading-text">Loading changelog...</div>;
} }
if (groups.length === 0) { if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div> return <div className="meta">No updates posted yet.</div>;
} }
return ( return (
<div className="changelog-groups"> <div className="changelog-groups">
@@ -104,13 +104,13 @@ export default function ChangelogPage() {
</section> </section>
))} ))}
</div> </div>
) );
}, [groups, loading]) }, [groups, loading]);
return ( return (
<main className="card changelog-page"> <main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." /> <PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content} {content}
</main> </main>
) );
} }
+30 -13
View File
@@ -1,17 +1,34 @@
import './style.css' import "./style.css";
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' } export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
export default function ComingSoonPage() { export default function ComingSoonPage() {
return <main className="launch-cover"> return (
<div className="launch-brand">GRIZZLYFLIX</div> <main className="launch-cover">
<span className="launch-badge">COMING SOON</span> <div className="launch-brand">GRIZZLYFLIX</div>
<h1>Your next watch.<br /><em>Made simpler.</em></h1> <span className="launch-badge">COMING SOON</span>
<p className="launch-intro">The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p> <h1>
<div className="launch-path" aria-label="Request journey"> Your next watch.
{['Request', 'Track', 'Watch'].map((label, index) => <div key={label}><span>0{index + 1}</span><strong>{label}</strong></div>)} <br />
</div> <em>Made simpler.</em>
<p className="launch-note">Were getting everything ready. Check back soon.</p> </h1>
<footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer> <p className="launch-intro">
</main> The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.
</p>
<ol className="launch-path" aria-label="Request journey">
{["Request", "Track", "Watch"].map((label, index) => (
<li key={label}>
<span>0{index + 1}</span>
<strong>{label}</strong>
</li>
))}
</ol>
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer>
<strong>Magent</strong>
<span>Grizzlyflix member portal</span>
<a href="/login">Admin sign in</a>
</footer>
</main>
);
} }
+3 -3
View File
@@ -5,9 +5,9 @@
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; } .launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
.launch-cover h1 em { color: #c7bdff; font-style: normal; } .launch-cover h1 em { color: #c7bdff; font-style: normal; }
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; } .launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; } .launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; padding: 0; list-style: none; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > div { padding: 22px 12px; display: grid; gap: 8px; } .launch-path > li { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > div + div { border-left: 1px solid #ffffff15; } .launch-path > li + li { border-left: 1px solid #ffffff15; }
.launch-path span { color: #8be7f1; font-size: 12px; } .launch-path span { color: #8be7f1; font-size: 12px; }
.launch-path strong { font-size: 18px; } .launch-path strong { font-size: 18px; }
.launch-note { color: #a9a4b5; font-size: 14px; } .launch-note { color: #a9a4b5; font-size: 14px; }
+131 -52
View File
@@ -1,66 +1,145 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
import BrandingLogo from '../ui/BrandingLogo' import BrandingLogo from "../ui/BrandingLogo";
import './recaps.css' import "./recaps.css";
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string } type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
export default function EmailRecapLinkPage() { export default function EmailRecapLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null) const [link, setLink] = useState<LinkAction | null>(null);
const [state, setState] = useState('loading') const [state, setState] = useState("loading");
const [error, setError] = useState('') const [error, setError] = useState("");
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
let controller: AbortController | null = null let controller: AbortController | null = null;
const checkLink = () => { const checkLink = () => {
controller?.abort() controller?.abort();
const abort = new AbortController() const abort = new AbortController();
controller = abort controller = abort;
setError(''); setState('loading'); setLink(null) setError("");
setState("loading");
setLink(null);
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it. // Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1)) const params = new URLSearchParams(window.location.hash.slice(1));
const action = params.get('action') const action = params.get("action");
const token = params.get('token') || '' const token = params.get("token") || "";
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) { if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return setError("This email link is incomplete. Open Profile to manage your monthly recaps.");
setState("error");
return;
} }
const payload = { action, token } as LinkAction const payload = { action, token } as LinkAction;
setLink(payload) setLink(payload);
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => { void fetch(`${getApiBase()}/email-recaps/check`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.') headers: { "Content-Type": "application/json" },
if (!abort.signal.aborted) setState(result.state) body: JSON.stringify(payload),
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } }) signal: abort.signal,
} credentials: "omit",
checkLink() })
window.addEventListener('hashchange', checkLink) .then(async (response) => {
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) } const result = await response.json().catch(() => ({}));
}, []) if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not check this email link. Please open it again.",
);
if (!abort.signal.aborted) setState(result.state);
})
.catch((err: Error) => {
if (!abort.signal.aborted) {
setError(err.message);
setState("error");
}
});
};
checkLink();
window.addEventListener("hashchange", checkLink);
return () => {
controller?.abort();
window.removeEventListener("hashchange", checkLink);
};
}, []);
const apply = async () => { const apply = async () => {
if (!link || busy) return if (!link || busy) return;
setBusy(true); setError('') setBusy(true);
setError("");
try { try {
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' }) const response = await fetch(`${getApiBase()}/email-recaps/confirm`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.') headers: { "Content-Type": "application/json" },
setState(result.state) body: JSON.stringify(link),
window.history.replaceState(null, '', '/email-recaps') credentials: "omit",
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') } });
finally { setBusy(false) } const result = await response.json().catch(() => ({}));
} if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
);
setState(result.state);
window.history.replaceState(null, "", "/email-recaps");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not update your preference.");
} finally {
setBusy(false);
}
};
const done = state === 'enabled' || state === 'off' const done = state === "enabled" || state === "off";
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel"> return (
<span className="recap-eyebrow">Personal viewing reports</span> <main className="recap-link-page">
<h1>{state === 'enabled' ? 'Youre on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1> <a className="recap-brand" href="/login">
<p>{state === 'enabled' ? 'Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You wont receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off all personal viewing report emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile.' : ''}</p> <BrandingLogo className="brand-logo" />
{error && <p className="account-notice is-error" role="alert">{error}</p>} <span>Magent</span>
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>} </a>
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences </a>} <section className="account-panel">
{state === 'loading' && <p role="status">One moment</p>} <span className="recap-eyebrow">Personal viewing reports</span>
</section></main> <h1>
{state === "enabled"
? "Youre on the list."
: state === "off"
? "Recaps are turned off."
: state === "loading"
? "Checking your email link"
: state === "error"
? "This link needs another look"
: link?.action === "unsubscribe"
? "Unsubscribe from recaps?"
: "Your month, delivered."}
</h1>
<p>
{state === "enabled"
? "Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs."
: state === "off"
? "You wont receive further monthly recaps. You can turn them back on in Profile."
: state === "ready" && link?.action === "unsubscribe"
? "This turns off all personal viewing report emails. You can still explore all your reports in Magent."
: state === "ready"
? "Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile."
: ""}
</p>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
{state === "ready" && (
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
{busy ? "Updating…" : link?.action === "unsubscribe" ? "Unsubscribe from recaps" : "Confirm email recaps"}
</button>
)}
{(done || state === "error") && (
<a className="recap-text-link" href="/profile#monthly-recaps">
Manage email preferences
</a>
)}
{state === "loading" && <p role="status">One moment</p>}
</section>
</main>
);
} }
+49 -53
View File
@@ -1,83 +1,83 @@
'use client' "use client";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth' import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from "../lib/auth";
type Profile = { type Profile = {
username?: string username?: string;
} };
export default function FeedbackPage() { export default function FeedbackPage() {
const router = useRouter() const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null) const [profile, setProfile] = useState<Profile | null>(null);
const [category, setCategory] = useState('bug') const [category, setCategory] = useState("bug");
const [message, setMessage] = useState('') const [message, setMessage] = useState("");
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`);
if (!response.ok) { if (!response.ok) {
throw new Error('Could not load profile.') throw new Error("Could not load profile.");
} }
const data = await response.json() const data = await response.json();
setProfile({ username: data?.username }) setProfile({ username: data?.username });
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) { if (error instanceof UnauthorizedError) {
router.push('/login') router.push("/login");
return return;
} }
console.error(error) console.error(error);
} }
} };
void load() void load();
}, [router]) }, [router]);
const submit = async (event: React.FormEvent<HTMLFormElement>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault();
setStatus(null) setStatus(null);
if (!message.trim()) { if (!message.trim()) {
setStatus('Please write a short message before sending.') setStatus("Please write a short message before sending.");
return return;
} }
setSubmitting(true) setSubmitting(true);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/feedback`, { const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
type: category, type: category,
message: message.trim(), message: message.trim(),
}), }),
}) });
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`);
} }
setMessage('') setMessage("");
setStatus('Thanks! Your message has been sent.') setStatus("Thanks! Your message has been sent.");
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) { if (error instanceof UnauthorizedError) {
router.push('/login') router.push("/login");
return return;
} }
console.error(error) console.error(error);
setStatus('That did not send. Please try again.') setStatus("That did not send. Please try again.");
} finally { } finally {
setSubmitting(false) setSubmitting(false);
} }
} };
return ( return (
<main className="card feedback-page"> <main className="card feedback-page">
@@ -85,14 +85,10 @@ export default function FeedbackPage() {
<form className="account-panel account-form feedback-form" onSubmit={submit}> <form className="account-panel account-form feedback-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label> <label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly /> <input id="feedback-user" value={profile?.username ?? ""} readOnly />
<label htmlFor="feedback-type">What is this about?</label> <label htmlFor="feedback-type">What is this about?</label>
<select <select id="feedback-type" value={category} onChange={(event) => setCategory(event.target.value)}>
id="feedback-type"
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="bug">Bug (something is broken)</option> <option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option> <option value="feature">Feature idea (new option)</option>
</select> </select>
@@ -109,9 +105,9 @@ export default function FeedbackPage() {
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}> <button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send feedback'} {submitting ? "Sending..." : "Send feedback"}
</button> </button>
</form> </form>
</main> </main>
) );
} }
+42 -34
View File
@@ -1,49 +1,49 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AuthLayout from '../ui/AuthLayout' import AuthLayout from "../ui/AuthLayout";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const router = useRouter() const router = useRouter();
const [identifier, setIdentifier] = useState('') const [identifier, setIdentifier] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
if (!identifier.trim()) { if (!identifier.trim()) {
setError('Enter your username or email.') setError("Enter your username or email.");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/forgot`, { const response = await fetch(`${baseUrl}/auth/password/forgot`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: identifier.trim() }), body: JSON.stringify({ identifier: identifier.trim() }),
}) });
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.') throw new Error(typeof data?.detail === "string" ? data.detail : "Unable to send reset link.");
} }
setStatus( setStatus(
typeof data?.message === 'string' typeof data?.message === "string"
? data.message ? data.message
: 'If an account exists for that username or email, a password reset link has been sent.', : "If an account exists for that username or email, a password reset link has been sent.",
) );
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Unable to send reset link.') setError(err instanceof Error ? err.message : "Unable to send reset link.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
return ( return (
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link."> <AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
@@ -57,17 +57,25 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com" placeholder="you@example.com"
/> />
</label> </label>
{error && <div className="account-notice is-error" role="alert">{error}</div>} {error && (
{status && <div className="account-notice is-status" role="status">{status}</div>} <div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions"> <div className="auth-actions">
<button type="submit" className="account-primary" disabled={loading}> <button type="submit" className="account-primary" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'} {loading ? "Sending reset link…" : "Send reset link"}
</button> </button>
</div> </div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}> <button type="button" className="ghost-button" onClick={() => router.push("/login")} disabled={loading}>
Back to sign in Back to sign in
</button> </button>
</form> </form>
</AuthLayout> </AuthLayout>
) );
} }
-108
View File
@@ -1,41 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
color-scheme: light;
--ink: #0f1117;
--ink-muted: #3f4656;
--paper: #f0f4ff;
--paper-strong: #ffffff;
--accent: #ff6b2b;
--accent-2: #1c6bff;
--accent-3: #11d6c6;
--border: rgba(15, 17, 23, 0.12);
--shadow: rgba(15, 17, 23, 0.18);
--glow: 0 0 18px rgba(28, 107, 255, 0.25);
--input-bg: rgba(15, 17, 23, 0.04);
--input-ink: var(--ink);
--error-bg: rgba(255, 107, 43, 0.12);
--error-ink: #6b2c17;
}
[data-theme='dark'] {
color-scheme: dark;
--ink: #e9ecf5;
--ink-muted: #9aa3b8;
--paper: #0b0f18;
--paper-strong: #111827;
--accent: #ff6b2b;
--accent-2: #3b82f6;
--accent-3: #22f6e3;
--border: rgba(255, 255, 255, 0.08);
--shadow: rgba(0, 0, 0, 0.6);
--glow: 0 0 22px rgba(59, 130, 246, 0.45);
--input-bg: rgba(255, 255, 255, 0.08);
--input-ink: var(--ink);
--error-bg: rgba(255, 107, 43, 0.18);
--error-ink: #ffd3bf;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -2505,38 +2469,6 @@ button span {
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */ /* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
:root {
--ink: #10151d;
--ink-muted: #5b6472;
--paper: #eaedf1;
--paper-strong: #f8fafc;
--accent: #3f78d7;
--accent-2: #5ea0ff;
--accent-3: #8fa7c8;
--border: rgba(16, 21, 29, 0.1);
--shadow: rgba(16, 21, 29, 0.14);
--glow: 0 0 0 transparent;
--input-bg: rgba(16, 21, 29, 0.03);
--error-bg: rgba(185, 28, 28, 0.08);
--error-ink: #7f1d1d;
}
[data-theme='dark'] {
--ink: #edf1f7;
--ink-muted: #98a2b3;
--paper: #090c10;
--paper-strong: #11151b;
--accent: #4b7fdb;
--accent-2: #66a3ff;
--accent-3: #93a6c4;
--border: rgba(255, 255, 255, 0.07);
--shadow: rgba(0, 0, 0, 0.45);
--glow: 0 0 0 transparent;
--input-bg: rgba(255, 255, 255, 0.035);
--error-bg: rgba(248, 113, 113, 0.12);
--error-ink: #fecaca;
}
body { body {
font-family: "Manrope", "Segoe UI", sans-serif; font-family: "Manrope", "Segoe UI", sans-serif;
background: background:
@@ -3075,40 +3007,6 @@ button:disabled {
} }
/* Release 1.1 UI Refresh: Professional control-panel theme */ /* Release 1.1 UI Refresh: Professional control-panel theme */
:root {
--ink: #111318;
--ink-muted: #5f6776;
--paper: #eef1f6;
--paper-strong: #ffffff;
--accent: #4e8ef7;
--accent-2: #77abff;
--accent-3: #9dbdff;
--border: rgba(17, 19, 24, 0.1);
--shadow: rgba(17, 19, 24, 0.16);
--glow: 0 0 0 1px rgba(78, 142, 247, 0.08), 0 14px 30px rgba(16, 20, 28, 0.08);
--input-bg: rgba(17, 19, 24, 0.035);
--input-ink: var(--ink);
--error-bg: rgba(225, 81, 81, 0.12);
--error-ink: #6f1f1f;
}
[data-theme='dark'] {
--ink: #eef1f7;
--ink-muted: #9aa3b2;
--paper: #0a0d12;
--paper-strong: #12161d;
--accent: #5d9cff;
--accent-2: #87b5ff;
--accent-3: #a5c4ff;
--border: rgba(255, 255, 255, 0.08);
--shadow: rgba(0, 0, 0, 0.55);
--glow: 0 0 0 1px rgba(93, 156, 255, 0.12), 0 18px 42px rgba(0, 0, 0, 0.38);
--input-bg: rgba(255, 255, 255, 0.035);
--input-ink: var(--ink);
--error-bg: rgba(248, 113, 113, 0.14);
--error-ink: #ffd4d4;
}
body { body {
font-family: "Manrope", "Segoe UI", sans-serif; font-family: "Manrope", "Segoe UI", sans-serif;
background: background:
@@ -3853,12 +3751,6 @@ button:disabled {
} }
/* Enterprise polish pass */ /* Enterprise polish pass */
[data-theme='dark'] {
--accent: #6f95c6;
--accent-2: #8aa9d1;
--accent-3: #b2c5de;
}
body { body {
background: background:
radial-gradient(circle at 12% -8%, rgba(111, 149, 198, 0.08), transparent 40%), radial-gradient(circle at 12% -8%, rgba(111, 149, 198, 0.08), transparent 40%),
+174 -53
View File
@@ -1,56 +1,177 @@
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import '../welcome.css' import "../welcome.css";
export default function HowItWorksPage() { export default function HowItWorksPage() {
return <main className="friendly-guide"> return (
<PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." /> <main className="friendly-guide">
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav> <PageHeading
<details open><summary>Request a movie or TV show</summary> title="A little help getting started."
<ol> description="Magent looks after your requests. GrizzlyFlix is where you watch them."
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">New Requests</a> and pick what youre looking for.</p></li> />
<li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If its already requested, open that request to see its progress.</p></li> <nav aria-label="Quick links">
<li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the librarys settings.</p></li> <a href="/welcome">Welcome page</a>
<li><strong>Follow it in My Requests.</strong><p>Well show whats happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li> <a href="/">My Requests</a>
</ol> <a href="/profile">My profile</a>
</details> </nav>
<details><summary>Understand the six progress steps</summary> <details open>
<ol> <summary>Request a movie or TV show</summary>
<li><strong>Requested:</strong> Your request has been received.</li> <ol>
<li><strong>Approved:</strong> It has permission to go ahead.</li> <li>
<li><strong>Library collection:</strong> The library is tracking whats collected and whats missing.</li> <strong>Choose Movie or TV show.</strong>
<li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt a good match yet.</li> <p>
<li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li> Open <a href="/new-requests">New Requests</a> and pick what youre looking for.
<li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li> </p>
</ol> </li>
<p>A finished download still needs to be added to the media library. Wait for Available to watch before heading over.</p> <li>
</details> <strong>Search and choose the right title.</strong>
<details><summary>Something looks stuck</summary> <p>
<ol> For TV, choose the seasons you want. If its already requested, open that request to see its progress.
<li><strong>Open the request.</strong><p>Read its current status and next step.</p></li> </p>
<li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li> </li>
<li><strong>Follow the action offered.</strong><p>You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre unsure.</p></li> <li>
</ol> <strong>Check your choices and send it.</strong>
<p>Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always mean a download was found.</p> <p>Choose from the quality options shown. These come from the librarys settings.</p>
</details> </li>
<details><summary>Report a problem and follow the fix</summary> <li>
<ol> <strong>Follow it in My Requests.</strong>
<li><strong>Open <a href="/portal/issues">Issues</a>.</strong><p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li> <p>
<li><strong>Choose the affected content.</strong><p>Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.</p></li> Well show whats happening and any next step you can take. Some titles need approval or may not have a
<li><strong>Read What will happen, then submit.</strong><p>It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.</p></li> suitable download yet.
<li><strong>Follow the issues progress.</strong><p>Open your reported issue to see the work recorded and where the fix is up to.</p></li> </p>
<li><strong>Tell us if it worked.</strong><p>When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose Yes if its fixed or No if you still need help.</p></li> </li>
</ol> </ol>
<p>Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure timings depend on the sites settings.</p> </details>
</details> <details>
<details><summary>Invite someone</summary> <summary>Understand the six progress steps</summary>
<ol> <ol>
<li><strong>Open <a href="/profile/invites">Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name youll recognise.</p></li> <li>
<li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li> <strong>Requested:</strong> Your request has been received.
<li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li> </li>
<li><strong>Manage it later.</strong><p>You can return to your invites to check them or disable a link. Your accounts invite limits apply automatically.</p></li> <li>
</ol> <strong>Approved:</strong> It has permission to go ahead.
</details> </li>
<details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.</p></details> <li>
<footer>Ready? <a href="/welcome">Choose where to go next </a></footer> <strong>Library collection:</strong> The library is tracking whats collected and whats missing.
</main> </li>
<li>
<strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt
a good match yet.
</li>
<li>
<strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a
season pack.
</li>
<li>
<strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.
</li>
</ol>
<p>
A finished download still needs to be added to the media library. Wait for Available to watch before heading
over.
</p>
</details>
<details>
<summary>Something looks stuck</summary>
<ol>
<li>
<strong>Open the request.</strong>
<p>Read its current status and next step.</p>
</li>
<li>
<strong>Choose Recheck request.</strong>
<p>Magent checks the connected services again to refresh where things are up to.</p>
</li>
<li>
<strong>Follow the action offered.</strong>
<p>
You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre
unsure.
</p>
</li>
</ol>
<p>
Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always
mean a download was found.
</p>
</details>
<details>
<summary>Report a problem and follow the fix</summary>
<ol>
<li>
<strong>
Open <a href="/portal/issues">Issues</a>.
</strong>
<p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p>
</li>
<li>
<strong>Choose the affected content.</strong>
<p>
Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.
</p>
</li>
<li>
<strong>Read What will happen, then submit.</strong>
<p>
It tells you whether the selected files will be replaced, missing content searched for, subtitles checked,
or playback investigated.
</p>
</li>
<li>
<strong>Follow the issues progress.</strong>
<p>Open your reported issue to see the work recorded and where the fix is up to.</p>
</li>
<li>
<strong>Tell us if it worked.</strong>
<p>
When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose
Yes if its fixed or No if you still need help.
</p>
</li>
</ol>
<p>
Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure
timings depend on the sites settings.
</p>
</details>
<details>
<summary>Invite someone</summary>
<ol>
<li>
<strong>
Open <a href="/profile/invites">Invites</a>.
</strong>
<p>If invites are enabled for your account, give your invite a name youll recognise.</p>
</li>
<li>
<strong>Add a welcome note, or skip it.</strong>
<p>A custom invite code is optional too.</p>
</li>
<li>
<strong>Choose how to share it.</strong>
<p>Copy the link yourself, or enter an email address to send it directly.</p>
</li>
<li>
<strong>Manage it later.</strong>
<p>
You can return to your invites to check them or disable a link. Your accounts invite limits apply
automatically.
</p>
</li>
</ol>
</details>
<details>
<summary>Update your account</summary>
<p>
Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your
activity, or use the password options available for your account.
</p>
<p>
Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.
</p>
</details>
<footer>
Ready? <a href="/welcome">Choose where to go next </a>
</footer>
</main>
);
} }
+251 -63
View File
@@ -1,94 +1,282 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
export type Breakdown = { name: string; minutes: number } export type Breakdown = { name: string; minutes: number };
export type Day = { date: string; minutes: number } export type Day = { date: string; minutes: number };
export type Transcoding = { export type Transcoding = {
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number video_minutes: number;
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number audio_minutes: number;
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null hardware_video_minutes: number;
} software_video_minutes: number;
unknown_hardware_minutes: number;
unknown_video_minutes: number;
unknown_audio_minutes: number;
hardware: Breakdown[];
audio_codecs: Breakdown[];
gpu_busy_minutes: null;
};
export type Stats = { export type Stats = {
state: 'ready' | 'not_configured' | 'unlinked' state: "ready" | "not_configured" | "unlinked";
is_admin: boolean is_admin: boolean;
days: number days: number;
updated_at?: string updated_at?: string;
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number } summary: null | {
daily?: Day[] minutes: number;
patterns?: { average_play_minutes: number; longest_play_minutes: number; weekend_percent: number; weekdays: Breakdown[]; media: Breakdown[] } movies: number;
top_titles?: { artwork_url?: string | null; title: string; type: string; minutes: number; plays: number }[] episodes: number;
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[] plays: number;
clients?: Breakdown[] current_streak: number;
methods?: Breakdown[] longest_streak: number;
transcoding?: Transcoding active_days: number;
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] } };
} daily?: Day[];
patterns?: {
average_play_minutes: number;
longest_play_minutes: number;
weekend_percent: number;
weekdays: Breakdown[];
media: Breakdown[];
};
top_titles?: { artwork_url?: string | null; title: string; type: string; minutes: number; plays: number }[];
recent?: {
id: string;
title: string;
series: string;
episode?: string;
type: string;
minutes: number;
played_at: string;
client: string;
method: string;
artwork_url?: string | null;
}[];
clients?: Breakdown[];
methods?: Breakdown[];
transcoding?: Transcoding;
requests: {
total: number;
movies: number;
tv: number;
pending: number;
approved: number;
declined: number;
recent: { request_id: number; title: string; media_type: string; status: number }[];
};
};
export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 }) export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 });
export const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) export const dateLabel = (date: string) =>
new Date(date).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
export function ViewingChart({ daily }: { daily: Day[] }) { export function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null) const [selected, setSelected] = useState<number | null>(null);
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1 const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1;
const bars: { start: string; end: string; minutes: number }[] = [] const bars: { start: string; end: string; minutes: number }[] = [];
for (let i = 0; i < daily.length; i += bucket) { for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket) const group = daily.slice(i, i + bucket);
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) }) bars.push({
start: group[0].date,
end: group[group.length - 1].date,
minutes: group.reduce((sum, day) => sum + day.minutes, 0),
});
} }
const peak = Math.max(1, ...bars.map((bar) => bar.minutes)) const peak = Math.max(1, ...bars.map((bar) => bar.minutes));
const active = selected === null ? null : bars[selected] const active = selected === null ? null : bars[selected];
return ( return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title"> <section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div> <div className="stats-panel-heading">
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div> <div>
<h2 id="viewing-title">Your viewing rhythm</h2>
<p>{bucket === 1 ? "Daily" : `${bucket}-day`} watch time · UTC</p>
</div>
<span className="stats-unit">Minutes</span>
</div>
<div className="stats-chart-detail" aria-live="polite">
{active
? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ""} · ${number(active.minutes)} minutes`
: "Select a bar to explore your watch time."}
</div>
<div className="stats-chart"> <div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div> <div className="stats-chart-scale" aria-hidden="true">
<span>{number(peak)}</span>
<span>{number(peak / 2)}</span>
<span>0</span>
</div>
<div className="stats-chart-bars"> <div className="stats-chart-bars">
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)} {bars.map((bar, index) => (
<button
type="button"
className={selected === index ? "is-selected" : ""}
key={bar.start}
aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ""}: ${number(bar.minutes)} minutes`}
aria-pressed={selected === index}
onClick={() => setSelected(index)}
onFocus={() => setSelected(index)}
>
<span style={{ height: `${bar.minutes > 0 ? Math.max(2, (bar.minutes / peak) * 100) : 1}%` }} />
</button>
))}
</div> </div>
</div> </div>
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div> <div className="stats-chart-axis" aria-hidden="true">
<span>{bars[0] && dateLabel(bars[0].start)}</span>
<span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span>
</div>
</section> </section>
) );
} }
export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) { export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0) const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section> return (
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>{title}</h2>
</div>
{rows.length ? (
<div className="stats-breakdown">
{rows.map((row) => (
<div key={row.name}>
<div className="stats-breakdown-label">
<span>{row.name}</span>
<strong>{number(row.minutes)} min</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
) : (
<p className="stats-muted">Your next watch will start the story here.</p>
)}
</section>
);
} }
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min` const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`;
export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) { export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0) const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return <section className="stats-panel stats-streaming"> return (
<div className="stats-panel-heading"><h2>How you streamed</h2></div> <section className="stats-panel stats-streaming">
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> <div className="stats-panel-heading">
{transcoding && <div className="stats-transcoding"> <h2>How you streamed</h2>
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
</div> </div>
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl> <div className="stats-breakdown">
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>} {rows.map((row) => (
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p> <div key={row.name}>
</div>} <div className="stats-breakdown-label">
</section> <span>{row.name}</span>
<strong>{minutes(row.minutes)}</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
{transcoding && (
<div className="stats-transcoding">
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div>
<span>GPU-assisted video</span>
<strong>
{transcoding.hardware_video_minutes === 0 &&
(transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0)
? "Not recorded"
: minutes(transcoding.hardware_video_minutes)}
</strong>
<small>
{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(" / ") ||
"Hardware-accelerated video"}
</small>
</div>
<div>
<span>Audio transcoding</span>
<strong>
{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0
? "Not recorded"
: minutes(transcoding.audio_minutes)}
</strong>
<small>
{transcoding.audio_codecs
.slice(0, 3)
.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`)
.join(" / ") || "Audio converted for your player"}
</small>
</div>
</div>
<dl className="stats-transcode-details">
<div>
<dt>Total video transcoding</dt>
<dd>
{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0
? "Not recorded"
: minutes(transcoding.video_minutes)}
</dd>
</div>
{transcoding.software_video_minutes > 0 && (
<div>
<dt>Software video</dt>
<dd>{minutes(transcoding.software_video_minutes)}</dd>
</div>
)}
{transcoding.unknown_hardware_minutes > 0 && (
<div>
<dt>Video hardware not recorded</dt>
<dd>{minutes(transcoding.unknown_hardware_minutes)}</dd>
</div>
)}
</dl>
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && (
<p className="stats-muted">
Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio{" "}
{minutes(transcoding.unknown_audio_minutes)}.
</p>
)}
<p className="stats-muted">
Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded
by Jellystat.
</p>
</div>
)}
</section>
);
} }
export function RecentArtwork({ url, type }: { url?: string | null; type: string }) { export function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false);
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true"> return (
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>} <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
</div> {url && !failed ? (
<img
src={`${getApiBase()}${url}`}
alt=""
width={44}
height={66}
loading="lazy"
onError={() => setFailed(true)}
/>
) : (
<span>{type === "episode" ? "TV" : type === "movie" ? "MV" : "▶"}</span>
)}
</div>
);
} }
export function StatsNavigation({ reports = false }: { reports?: boolean }) { export function StatsNavigation({ reports = false }: { reports?: boolean }) {
return <nav className="stats-view-tabs" aria-label="My Stats views"> return (
<a href="/insights" aria-current={!reports ? 'page' : undefined}>Overview</a> <nav className="stats-view-tabs" aria-label="My Stats views">
<a href="/insights/reports" aria-current={reports ? 'page' : undefined}>Monthly reports</a> <a href="/insights" aria-current={!reports ? "page" : undefined}>
</nav> Overview
</a>
<a href="/insights/reports" aria-current={reports ? "page" : undefined}>
Monthly reports
</a>
</nav>
);
} }
+318 -68
View File
@@ -1,86 +1,336 @@
'use client' "use client";
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../lib/auth' import { authFetch, getApiBase } from "../lib/auth";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from './components' import {
import './stats.css' type Stats,
BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "./components";
import "./stats.css";
export default function InsightsPage() { export default function InsightsPage() {
const router = useRouter() const router = useRouter();
const [days, setDays] = useState(30) const [days, setDays] = useState(30);
const [data, setData] = useState<Stats | null>(null) const [data, setData] = useState<Stats | null>(null);
const [busy, setBusy] = useState(true) const [busy, setBusy] = useState(true);
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const load = useCallback(async (signal: AbortSignal) => { const load = useCallback(
setBusy(true) async (signal: AbortSignal) => {
setError('') setBusy(true);
setData(null) setError("");
try { setData(null);
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal }) try {
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return } const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal });
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.') if (response.status === 401) {
if (!response.ok) { router.replace("/login?next=%2Finsights");
const result = await response.json().catch(() => ({})) return;
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.') }
if (response.status === 403)
throw new Error("Your account cannot access viewing stats. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your viewing stats are temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as Stats;
if (!signal.aborted) setData(result);
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your stats.");
} finally {
if (!signal.aborted) setBusy(false);
} }
const result = await response.json() as Stats },
if (!signal.aborted) setData(result) [days, router],
} catch (err) { );
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [days, router])
useEffect(() => { useEffect(() => {
const controller = new AbortController() void revision;
void load(controller.signal) const controller = new AbortController();
return () => controller.abort() void load(controller.signal);
}, [load, revision]) return () => controller.abort();
}, [load, revision]);
const summary = data?.summary const summary = data?.summary;
return ( return (
<main className="stats-page"> <main className="stats-page">
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} /> <PageHeading
title="My Stats"
description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all."
actions={
<button
className="ghost-button"
type="button"
disabled={busy}
onClick={() => setRevision((value) => value + 1)}
>
{busy ? "Loading…" : "Refresh stats"}
</button>
}
/>
<StatsNavigation /> <StatsNavigation />
<div className="stats-toolbar"> <div className="stats-toolbar">
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset> <fieldset className="stats-period">
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p> <legend className="stats-sr-only">Stats period</legend>
{[7, 30, 90, 365].map((value) => (
<button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>
{value === 365 ? "Past year" : `${value} days`}
</button>
))}
</fieldset>
<p className="stats-source">
<span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
From Jellystat
{data?.updated_at && (
<span>
{" "}
· Updated{" "}
{new Date(data.updated_at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</p>
</div> </div>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>} {busy && (
{error && <div className="stats-state" role="alert"><h2>Stats couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>} <div className="stats-state" role="status">
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>} <span className="stats-state-symbol" aria-hidden="true">
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
{summary && <> </span>
<section className="stats-metrics" aria-label="Viewing totals"> <h2>Gathering your stats</h2>
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article> <p>Fetching your viewing history from Jellystat.</p>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article> </div>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article> )}
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article> {error && (
<div className="stats-state" role="alert">
<h2>Stats couldnt load</h2>
<p>{error}</p>
<button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Your viewing story starts here</h2>
<p>
{data.is_admin
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
: "Viewing stats will appear here once your administrator connects Jellystat."}
</p>
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section> </section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>} )}
{data?.state === "unlinked" && (
<section className="stats-state">
<h2>Link your viewing account</h2>
<p>
Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your
administrator to sync Jellyfin users.
</p>
</section>
)}
{summary && (
<>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{number(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests
</small>
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history in this period yet. Try a longer period, or come back after your next watch.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights">
<div className="stats-panel-heading">
<h2>A little watch history</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.current_streak}
<small> days</small>
</span>
<div>
<strong>Current streak</strong>
<p>Consecutive viewing days through today or yesterday.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Your best streak in this period.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Time for a story</strong>
<p>Days with at least a minute watched.</p>
</div>
</div>
</section>
</div>
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<span className="stats-rank">{String(index + 1).padStart(2, "0")}</span>
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your favourites will find their place here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
</>
)}
{data && (
<div className="stats-main-grid"> <div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} /> {summary && (
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div> <section className="stats-panel stats-history">
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div> <div className="stats-panel-heading">
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div> <h2>Recently watched</h2>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div> <span className="stats-unit">Latest 20 plays</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded by Jellystat will appear here.</p>
)}
</section>
)}
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in the past {days} days</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length > 0 ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">
Something on your watchlist? <a href="/new-requests">Make a request.</a>
</p>
)}
</section> </section>
</div> </div>
<div className="stats-three-grid"> )}
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section> {summary && (
<BreakdownCard title="Your players" rows={data.clients ?? []} /> <p className="stats-footnote">
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} /> Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays,
</div> including unfinished watches. Movies are identified from Jellystats movie libraries; other media still
</>} contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.
{data && <div className="stats-main-grid"> </p>
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>} )}
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
</div>}
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystats movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
</main> </main>
) );
} }
@@ -1,60 +1,108 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
type Delivery = { id: string; month: string; state: string; detail: string } type Delivery = { id: string; month: string; state: string; detail: string };
type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] } type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] };
export default function EmailReportControl({ month }: { month: string }) { export default function EmailReportControl({ month }: { month: string }) {
const [data, setData] = useState<Preference | null>(null) const [data, setData] = useState<Preference | null>(null);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const request = useRef<{ month: string; id: string } | null>(null) const request = useRef<{ month: string; id: string } | null>(null);
const pending = data?.deliveries.some((item) => ['queued', 'preparing', 'sending', 'retry'].includes(item.state)) ?? false const pending =
data?.deliveries.some((item) => ["queued", "preparing", "sending", "retry"].includes(item.state)) ?? false;
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => { const abort = new AbortController();
if (!response.ok) throw new Error('Could not load your report email preferences. Refresh to try again.') void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
const result = await response.json() .then(async (response) => {
if (!abort.signal.aborted) setData(result) if (!response.ok) throw new Error("Could not load your report email preferences. Refresh to try again.");
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) const result = await response.json();
return () => abort.abort() if (!abort.signal.aborted) setData(result);
}, [revision]) })
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision]);
useEffect(() => { useEffect(() => {
if (!pending) return if (!pending) return;
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000) const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
return () => window.clearInterval(timer) return () => window.clearInterval(timer);
}, [pending]) }, [pending]);
const send = async () => { const send = async () => {
if (busy || !data?.can_send) return if (busy || !data?.can_send) return;
setBusy(true); setError(''); setNotice('') setBusy(true);
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() } setError("");
setNotice("");
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() };
try { try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, { const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ month, request_id: request.current.id }), body: JSON.stringify({ month, request_id: request.current.id }),
}) });
const result = await response.json().catch(() => ({})) const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not queue your report. Try again.') if (!response.ok)
setNotice(result.message); request.current = null throw new Error(typeof result.detail === "string" ? result.detail : "Could not queue your report. Try again.");
setRevision((value) => value + 1) setNotice(result.message);
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your report.') } request.current = null;
finally { setBusy(false) } setRevision((value) => value + 1);
} } catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your report.");
} finally {
setBusy(false);
}
};
return <section className="stats-panel report-email-panel" aria-label="Email your report"> return (
<div className="stats-panel-heading"><h2>Email yourself this report</h2><a href="/profile#monthly-recaps">Email preferences</a></div> <section className="stats-panel report-email-panel" aria-label="Email your report">
<p>Choose a month above, including the current month so far, then send its viewing and request summary to your confirmed profile email.</p> <div className="stats-panel-heading">
{data?.can_send ? <p><strong>{data.email}</strong> · One report email every five minutes.</p> : data && <p>{data.state === 'enabled' ? data.detail : 'Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails.'}</p>} <h2>Email yourself this report</h2>
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>{busy ? 'Queueing report…' : 'Email this report'}</button> <a href="/profile#monthly-recaps">Email preferences</a>
{notice && <p role="status">{notice}</p>} </div>
{error && <p role="alert">{error}</p>} <p>
{!!data?.deliveries.length && <details><summary>Recent report emails</summary><ul>{data.deliveries.map((item) => <li key={item.id}><strong>{item.month}</strong> · {item.state === 'sent' ? 'Accepted by mail server' : item.state} {item.detail || 'Waiting for delivery'}</li>)}</ul></details>} Choose a month above, including the current month so far, then send its viewing and request summary to your
</section> confirmed profile email.
</p>
{data?.can_send ? (
<p>
<strong>{data.email}</strong> · One report email every five minutes.
</p>
) : (
data && (
<p>
{data.state === "enabled"
? data.detail
: "Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails."}
</p>
)
)}
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>
{busy ? "Queueing report…" : "Email this report"}
</button>
{notice && <p role="status">{notice}</p>}
{error && <p role="alert">{error}</p>}
{!!data?.deliveries.length && (
<details>
<summary>Recent report emails</summary>
<ul>
{data.deliveries.map((item) => (
<li key={item.id}>
<strong>{item.month}</strong> · {item.state === "sent" ? "Accepted by mail server" : item.state} {" "}
{item.detail || "Waiting for delivery"}
</li>
))}
</ul>
</details>
)}
</section>
);
} }
+508 -154
View File
@@ -1,177 +1,531 @@
'use client' "use client";
import EmailReportControl from './EmailReportControl' import EmailReportControl from "./EmailReportControl";
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import PageHeading from '../../ui/PageHeading' import PageHeading from "../../ui/PageHeading";
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from '../components' import {
import '../stats.css' type Stats,
import './reports.css' BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "../components";
import "../stats.css";
import "./reports.css";
type Change = { current: number; previous: number; difference: number; percent: number | null } type Change = { current: number; previous: number; difference: number; percent: number | null };
type MonthlyReport = Omit<Stats, 'days'> & { type MonthlyReport = Omit<Stats, "days"> & {
month: string; available_months: string[]; is_partial: boolean; comparison_capped: boolean month: string;
period_start: string; period_end: string; comparison_month: string; comparison_start: string; comparison_end: string available_months: string[];
previous_summary?: Stats['summary']; previous_requests?: Omit<Stats['requests'], 'recent'> is_partial: boolean;
changes?: Record<'minutes' | 'movies' | 'episodes' | 'plays' | 'active_days' | 'longest_streak' | 'requests', Change> comparison_capped: boolean;
} period_start: string;
period_end: string;
comparison_month: string;
comparison_start: string;
comparison_end: string;
previous_summary?: Stats["summary"];
previous_requests?: Omit<Stats["requests"], "recent">;
changes?: Record<"minutes" | "movies" | "episodes" | "plays" | "active_days" | "longest_streak" | "requests", Change>;
};
const monthLabel = (month: string, short = false) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: short ? 'short' : 'long', year: 'numeric', timeZone: 'UTC' }) const monthLabel = (month: string, short = false) =>
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 }) new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, {
month: short ? "short" : "long",
year: "numeric",
timeZone: "UTC",
});
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 });
function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) { function ChangeLabel({ change, unit = "" }: { change: Change; unit?: string }) {
const delta = change.difference const delta = change.difference;
return <div className={`report-change ${delta > 0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}> return (
<span>{delta === 0 ? 'No change' : `${delta > 0 ? '+' : ''}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : ''}${decimal(Math.abs(change.percent))}%)`}`}</span> <div className={`report-change ${delta > 0 ? "is-up" : delta < 0 ? "is-down" : "is-flat"}`}>
<small>{change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}</small> <span>
</div> {delta === 0
? "No change"
: `${delta > 0 ? "+" : ""}${decimal(Math.abs(delta))}${unit}${change.percent === null ? "" : ` (${delta > 0 ? "+" : ""}${decimal(Math.abs(change.percent))}%)`}`}
</span>
<small>
{change.percent === null
? "No activity recorded in the comparison period"
: `Previously ${decimal(change.previous)}${unit}`}
</small>
</div>
);
} }
export default function MonthlyReportsPage() { export default function MonthlyReportsPage() {
const router = useRouter() const router = useRouter();
const [month, setMonth] = useState('') const [month, setMonth] = useState("");
const [monthReady, setMonthReady] = useState(false) const [monthReady, setMonthReady] = useState(false);
const [months, setMonths] = useState<string[]>([]) const [months, setMonths] = useState<string[]>([]);
const [data, setData] = useState<MonthlyReport | null>(null) const [data, setData] = useState<MonthlyReport | null>(null);
const [busy, setBusy] = useState(true) const [busy, setBusy] = useState(true);
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const [downloading, setDownloading] = useState(false) const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState('') const [downloadError, setDownloadError] = useState("");
const downloadController = useRef<AbortController | null>(null) const downloadController = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
setMonth(new URLSearchParams(window.location.search).get('month') || '') setMonth(new URLSearchParams(window.location.search).get("month") || "");
setMonthReady(true) setMonthReady(true);
}, []) }, []);
useEffect(() => { useEffect(() => {
if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`) if (monthReady)
}, [month, monthReady]) window.history.replaceState(null, "", `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ""}`);
useEffect(() => () => downloadController.current?.abort(), []) }, [month, monthReady]);
const load = useCallback(async (signal: AbortSignal) => { useEffect(() => () => downloadController.current?.abort(), []);
setBusy(true) const load = useCallback(
setError('') async (signal: AbortSignal) => {
setData(null) setBusy(true);
setDownloadError('') setError("");
try { setData(null);
const query = month ? `?month=${encodeURIComponent(month)}` : '' setDownloadError("");
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal }) try {
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return } const query = month ? `?month=${encodeURIComponent(month)}` : "";
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.') const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal });
if (!response.ok) { if (response.status === 401) {
const result = await response.json().catch(() => ({})) router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`);
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your report is temporarily unavailable. Please try again shortly.') return;
}
if (response.status === 403)
throw new Error("Your account cannot access viewing reports. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your report is temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as MonthlyReport;
if (!signal.aborted) {
setData(result);
setMonths(result.available_months);
}
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your report.");
} finally {
if (!signal.aborted) setBusy(false);
} }
const result = await response.json() as MonthlyReport },
if (!signal.aborted) { setData(result); setMonths(result.available_months) } [month, router],
} catch (err) { );
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your report.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [month, router])
useEffect(() => { useEffect(() => {
if (!monthReady) return void revision;
const controller = new AbortController() if (!monthReady) return;
void load(controller.signal) const controller = new AbortController();
return () => controller.abort() void load(controller.signal);
}, [load, revision, monthReady]) return () => controller.abort();
}, [load, revision, monthReady]);
const download = async () => { const download = async () => {
if (data?.state !== 'ready' || downloading) return if (data?.state !== "ready" || downloading) return;
const selected = data.month const selected = data.month;
const controller = new AbortController() const controller = new AbortController();
downloadController.current = controller downloadController.current = controller;
setDownloading(true) setDownloading(true);
setDownloadError('') setDownloadError("");
try { try {
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal }) const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, {
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return } signal: controller.signal,
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.') });
const blob = await response.blob() if (response.status === 401) {
if (controller.signal.aborted) return router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`);
const url = URL.createObjectURL(blob) return;
const link = document.createElement('a') }
link.href = url if (!response.ok) throw new Error("The report could not be downloaded. Please try again.");
link.download = `magent-monthly-report-${selected}.csv` const blob = await response.blob();
document.body.appendChild(link) if (controller.signal.aborted) return;
link.click() const url = URL.createObjectURL(blob);
link.remove() const link = document.createElement("a");
window.setTimeout(() => URL.revokeObjectURL(url), 1000) link.href = url;
link.download = `magent-monthly-report-${selected}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) { } catch (err) {
if (!controller.signal.aborted) setDownloadError(err instanceof Error ? err.message : 'Could not download your report.') if (!controller.signal.aborted)
setDownloadError(err instanceof Error ? err.message : "Could not download your report.");
} finally { } finally {
if (!controller.signal.aborted) setDownloading(false) if (!controller.signal.aborted) setDownloading(false);
} }
} };
const selectedMonth = month || data?.month || '' const selectedMonth = month || data?.month || "";
const monthIndex = months.indexOf(selectedMonth) const monthIndex = months.indexOf(selectedMonth);
const summary = data?.summary const summary = data?.summary;
const changes = data?.changes const changes = data?.changes;
return <main className="stats-page reports-page"> return (
<PageHeading title="Monthly report" description="Your month in viewing. See what you watched, what changed, and what you requested." actions={<> <main className="stats-page reports-page">
<button className="ghost-button" type="button" disabled={busy || downloading} onClick={() => setRevision((value) => value + 1)}>Refresh report</button> <PageHeading
<button className="ghost-button" type="button" disabled={busy || downloading || data?.state !== 'ready'} onClick={() => void download()}>{downloading ? 'Downloading…' : 'Download CSV'}</button> title="Monthly report"
</>} /> description="Your month in viewing. See what you watched, what changed, and what you requested."
<StatsNavigation reports /> actions={
<div className="stats-toolbar"> <>
<div className="report-month-picker"> <button
<button type="button" className="ghost-button" aria-label="Previous month" disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1} onClick={() => setMonth(months[monthIndex + 1])}></button> className="ghost-button"
<label><span className="stats-sr-only">Report month</span><select value={selectedMonth} disabled={busy || downloading || !months.length} onChange={(event) => setMonth(event.target.value)}>{!selectedMonth && <option value="">Latest complete month</option>}{months.map((value, index) => <option value={value} key={value}>{monthLabel(value)}{index === 0 ? ' · month to date' : ''}</option>)}</select></label> type="button"
<button type="button" className="ghost-button" aria-label="Next month" disabled={busy || downloading || monthIndex <= 0} onClick={() => setMonth(months[monthIndex - 1])}></button> disabled={busy || downloading}
</div> onClick={() => setRevision((value) => value + 1)}
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p> >
</div> Refresh report
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>} </button>
{data?.state === 'ready' && !busy && <EmailReportControl month={data.month} />} <button
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous months comparison.</p></div>} className="ghost-button"
{error && <div className="stats-state" role="alert"><h2>Report couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>} type="button"
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>} disabled={busy || downloading || data?.state !== "ready"}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>} onClick={() => void download()}
{data && summary && changes && <> >
<section className="report-intro" aria-label="Report period"> {downloading ? "Downloading…" : "Download CSV"}
<div><span className="report-kicker">{data.is_partial ? 'Month to date' : 'Your monthly recap'}</span><h2>{monthLabel(data.month)}</h2><p>{data.is_partial ? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ', capped at the end of that month' : ''}.` : `Compared with ${monthLabel(data.comparison_month)}.`}</p></div> </button>
<div className="report-period-meta"><span>{data.is_partial ? 'In progress' : 'Complete month'}</span><small>{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}</small></div> </>
</section> }
<section className="stats-metrics" aria-label="Monthly totals"> />
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays</small><ChangeLabel change={changes.minutes} unit=" min" /></article> <StatsNavigation reports />
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small><ChangeLabel change={changes.movies} /></article> <div className="stats-toolbar">
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small><ChangeLabel change={changes.episodes} /></article> <div className="report-month-picker">
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{data.requests.movies} movies · {data.requests.tv} TV requests</small><ChangeLabel change={changes.requests} /></article> <button
</section> type="button"
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history was recorded for this month. Your request totals and comparison are still shown.</div>} className="ghost-button"
<div className="stats-main-grid"> aria-label="Previous month"
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} /> disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1}
<section className="stats-panel report-highlights"><div className="stats-panel-heading"><h2>Your viewing habits</h2></div> onClick={() => setMonth(months[monthIndex + 1])}
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Days you watched</strong><p>At least one minute of viewing.</p><ChangeLabel change={changes.active_days} unit=" days" /></div></div> >
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Consecutive viewing days this month.</p><ChangeLabel change={changes.longest_streak} unit=" days" /></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div> </button>
</section> <label>
</div> <span className="stats-sr-only">Report month</span>
{data.patterns && <> <select
<section className="report-pattern-summary" aria-label="Viewing insights"> value={selectedMonth}
<article><span>Average play</span><strong>{decimal(data.patterns.average_play_minutes)} <small>min</small></strong><p>Time per recorded playback session.</p></article> disabled={busy || downloading || !months.length}
<article><span>Longest play</span><strong>{decimal(data.patterns.longest_play_minutes)} <small>min</small></strong><p>Your longest recorded session this month.</p></article> onChange={(event) => setMonth(event.target.value)}
<article><span>Weekend viewing</span><strong>{decimal(data.patterns.weekend_percent)}<small>%</small></strong><p>Share of viewing on Saturday and Sunday (UTC).</p></article> >
</section> {!selectedMonth && <option value="">Latest complete month</option>}
<div className="stats-main-grid"> {months.map((value, index) => (
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} /> <option value={value} key={value}>
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} /> {monthLabel(value)}
{index === 0 ? " · month to date" : ""}
</option>
))}
</select>
</label>
<button
type="button"
className="ghost-button"
aria-label="Next month"
disabled={busy || downloading || monthIndex <= 0}
onClick={() => setMonth(months[monthIndex - 1])}
>
</button>
</div> </div>
</>} <p className="stats-source">
<div className="stats-three-grid"> <span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><RecentArtwork url={title.artwork_url} type={title.type} /><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section> From Jellystat · UTC
<BreakdownCard title="Your players" rows={data.clients ?? []} /> </p>
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div> </div>
<div className="stats-main-grid"> {downloadError && (
<section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>A look back</h2><span className="stats-unit">Latest 20 plays this month</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded during this month will appear here.</p>}</section> <p className="stats-notice" role="alert">
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in {monthLabel(data.month, true)}</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">No requests recorded during this month.</p>}<p className="stats-muted">Statuses reflect where these requests are now.</p></section> {downloadError}
</div> </p>
<p className="stats-footnote">Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical totals can change when retained history or library metadata changes.</p> )}
</>} {data?.state === "ready" && !busy && <EmailReportControl month={data.month} />}
</main> {busy && (
<div className="stats-state" role="status">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Putting your month together</h2>
<p>Gathering your viewing history and the previous months comparison.</p>
</div>
)}
{error && (
<div className="stats-state" role="alert">
<h2>Report couldnt load</h2>
<p>{error}</p>
<button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
{month && (
<button type="button" className="ghost-button" onClick={() => setMonth("")}>
Latest complete month
</button>
)}
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<h2>Your monthly story starts here</h2>
<p>
{data.is_admin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section>
)}
{data?.state === "unlinked" && (
<section className="stats-state">
<h2>Link your viewing account</h2>
<p>
Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
your user identities.
</p>
{data.is_admin && (
<a className="stats-action" href="/admin/identities">
Review user identities
</a>
)}
</section>
)}
{data && summary && changes && (
<>
<section className="report-intro" aria-label="Report period">
<div>
<span className="report-kicker">{data.is_partial ? "Month to date" : "Your monthly recap"}</span>
<h2>{monthLabel(data.month)}</h2>
<p>
{data.is_partial
? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ", capped at the end of that month" : ""}.`
: `Compared with ${monthLabel(data.comparison_month)}.`}
</p>
</div>
<div className="report-period-meta">
<span>{data.is_partial ? "In progress" : "Complete month"}</span>
<small>
{data.updated_at &&
`Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`}
</small>
</div>
</section>
<section className="stats-metrics" aria-label="Monthly totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
<ChangeLabel change={changes.minutes} unit=" min" />
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
<ChangeLabel change={changes.movies} />
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
<ChangeLabel change={changes.episodes} />
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{data.requests.movies} movies · {data.requests.tv} TV requests
</small>
<ChangeLabel change={changes.requests} />
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history was recorded for this month. Your request totals and comparison are still shown.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel report-highlights">
<div className="stats-panel-heading">
<h2>Your viewing habits</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Days you watched</strong>
<p>At least one minute of viewing.</p>
<ChangeLabel change={changes.active_days} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Consecutive viewing days this month.</p>
<ChangeLabel change={changes.longest_streak} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}
<small> min</small>
</span>
<div>
<strong>Daily average</strong>
<p>Across the calendar days in this report.</p>
</div>
</div>
</section>
</div>
{data.patterns && (
<>
<section className="report-pattern-summary" aria-label="Viewing insights">
<article>
<span>Average play</span>
<strong>
{decimal(data.patterns.average_play_minutes)} <small>min</small>
</strong>
<p>Time per recorded playback session.</p>
</article>
<article>
<span>Longest play</span>
<strong>
{decimal(data.patterns.longest_play_minutes)} <small>min</small>
</strong>
<p>Your longest recorded session this month.</p>
</article>
<article>
<span>Weekend viewing</span>
<strong>
{decimal(data.patterns.weekend_percent)}
<small>%</small>
</strong>
<p>Share of viewing on Saturday and Sunday (UTC).</p>
</article>
</section>
<div className="stats-main-grid">
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
</div>
</>
)}
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles report-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<RecentArtwork url={title.artwork_url} type={title.type} />
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your most watched titles will appear here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
<div className="stats-main-grid">
<section className="stats-panel stats-history">
<div className="stats-panel-heading">
<h2>A look back</h2>
<span className="stats-unit">Latest 20 plays this month</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded during this month will appear here.</p>
)}
</section>
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in {monthLabel(data.month, true)}</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">No requests recorded during this month.</p>
)}
<p className="stats-muted">Statuses reflect where these requests are now.</p>
</section>
</div>
<p className="stats-footnote">
Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays
include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical
totals can change when retained history or library metadata changes.
</p>
</>
)}
</main>
);
} }
+108 -50
View File
@@ -1,63 +1,121 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from "next/navigation";
import { authFetch, getApiBase, clearToken } from '../../../lib/auth' import { authFetch, getApiBase, clearToken } from "../../../lib/auth";
import ResolutionChoice from '../../../ui/ResolutionChoice' import ResolutionChoice from "../../../ui/ResolutionChoice";
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } } type Issue = {
id: number;
kind: string;
title: string;
status: string;
permissions?: { can_confirm_resolution?: boolean };
};
export default function ConfirmIssuePage() { export default function ConfirmIssuePage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const router = useRouter() const router = useRouter();
const [item, setItem] = useState<Issue | null>(null) const [item, setItem] = useState<Issue | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [result, setResult] = useState('') const [result, setResult] = useState("");
const login = () => { const login = useCallback(() => {
clearToken() clearToken();
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`) router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`);
} }, [id, router]);
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController();
setLoading(true); setItem(null); setError(''); setResult('') setLoading(true);
setItem(null);
setError("");
setResult("");
const load = async () => { const load = async () => {
try { try {
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' }) const response = await authFetch(`${getApiBase()}/portal/items/${id}`, {
if (response.status === 401) { login(); return } signal: controller.signal,
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.') cache: "no-store",
const data = await response.json() });
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.') if (response.status === 401) {
setItem(data.item) login();
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') } return;
finally { if (!controller.signal.aborted) setLoading(false) } }
} if (!response.ok)
void load() throw new Error("This issue is unavailable. Please sign in with the account that reported it.");
return () => controller.abort() const data = await response.json();
if (data.item?.kind !== "issue") throw new Error("This link does not belong to an issue.");
setItem(data.item);
} catch (err) {
if (!controller.signal.aborted)
setError(err instanceof Error ? err.message : "Could not load this issue. Please try again.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
void load();
return () => controller.abort();
// The confirmation link identifies one issue. Never submit an answer on GET. // The confirmation link identifies one issue. Never submit an answer on GET.
// eslint-disable-next-line react-hooks/exhaustive-deps }, [id, login]);
}, [id])
const answer = async (resolved: boolean) => { const answer = async (resolved: boolean) => {
if (busy) return if (busy) return;
setBusy(true); setError('') setBusy(true);
setError("");
try { try {
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, { const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }), method: "POST",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) { login(); return } body: JSON.stringify({ resolved }),
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.') });
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.') if (response.status === 401) {
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') } login();
finally { setBusy(false) } return;
} }
return <main className="resolution-response-page"> if (!response.ok)
{error && <p role="alert" className="status-banner">{error}</p>} throw new Error(
{loading ? <p role="status">Loading your issue</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? ( "Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.",
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution );
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} /> setResult(
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section> resolved
) : null} ? "Thanks! Your issue is now closed."
</main> : "Thanks for letting us know. Your issue stays open for another look.",
);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save your answer. Please try again.");
} finally {
setBusy(false);
}
};
return (
<main className="resolution-response-page">
{error && (
<p role="alert" className="status-banner">
{error}
</p>
)}
{loading ? (
<p role="status">Loading your issue</p>
) : result ? (
<section className="resolution-choice" role="status">
<h2>{result}</h2>
<a href="/portal/issues">Back to issues</a>
</section>
) : item ? (
item.status === "awaiting_confirmation" && item.permissions?.can_confirm_resolution ? (
<ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
) : (
<section className="resolution-choice">
<h2>
{item.status === "awaiting_confirmation"
? "This question is for the person who reported the issue."
: "No answer is needed right now."}
</h2>
<p>{item.title}</p>
<a href={`/portal/issues?item=${item.id}`}>View issue</a>
</section>
)
) : null}
</main>
);
} }
+15 -14
View File
@@ -1,18 +1,19 @@
import './globals.css' import "./styles/tokens.css";
import './ops-redesign.css' import "./globals.css";
import './admin/config.css' import "./ops-redesign.css";
import './account.css' import "./admin/config.css";
import './workspace.css' import "./account.css";
import './portal/issue-flow.css' import "./workspace.css";
import type { ReactNode } from 'react' import "./portal/issue-flow.css";
import BrandingFavicon from './ui/BrandingFavicon' import type { ReactNode } from "react";
import FeatureGate from './ui/FeatureGate' import BrandingFavicon from "./ui/BrandingFavicon";
import ApplicationChrome from './ui/ApplicationChrome' import FeatureGate from "./ui/FeatureGate";
import ApplicationChrome from "./ui/ApplicationChrome";
export const metadata = { export const metadata = {
title: 'Magent', title: "Magent",
description: 'Request timeline and AI triage for media requests', description: "Request timeline and AI triage for media requests",
} };
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
@@ -25,5 +26,5 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</div> </div>
</body> </body>
</html> </html>
) );
} }
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { apiUrl, requestJson } from "./api-client";
describe("api client", () => {
it("normalizes relative API paths", () => {
expect(apiUrl("health")).toBe("/api/health");
expect(apiUrl("/health")).toBe("/api/health");
});
it("returns typed JSON from successful responses", async () => {
const transport = async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 });
const result = await requestJson<{ status: string }>("/health", undefined, transport);
expect(result).toEqual({ status: "ok" });
});
it("uses the API error detail when a request fails", async () => {
const transport = async () => new Response(JSON.stringify({ detail: "Not available" }), { status: 409 });
await expect(requestJson("/requests/1", undefined, transport)).rejects.toEqual(
expect.objectContaining({ status: 409, message: "Not available" }),
);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { authFetchOrThrow, getApiBase } from "./auth";
export type ApiTransport = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export class ApiClientError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiClientError";
this.status = status;
}
}
const errorMessage = (payload: unknown, fallback: string) => {
if (!payload || typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
for (const key of ["detail", "error", "message"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fallback;
};
export const apiUrl = (path: string) => {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${getApiBase()}${normalizedPath}`;
};
export async function requestJson<T>(
path: string,
init?: RequestInit,
transport: ApiTransport = authFetchOrThrow,
): Promise<T> {
const response = await transport(apiUrl(path), init);
if (response.status === 204) return undefined as T;
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (!response.ok) {
throw new ApiClientError(response.status, errorMessage(payload, text || `Request failed: ${response.status}`));
}
return payload as T;
}
+55 -55
View File
@@ -1,100 +1,100 @@
const AUTH_STATE_COOKIE = 'magent_logged_in' const AUTH_STATE_COOKIE = "magent_logged_in";
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api' export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? "/api";
const setCookie = (name: string, value: string, maxAgeSeconds: number) => { const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === 'undefined') return if (typeof document === "undefined") return;
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax` document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
} };
const clearCookie = (name: string) => { const clearCookie = (name: string) => {
if (typeof document === 'undefined') return if (typeof document === "undefined") return;
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax` document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
} };
export const getToken = () => { export const getToken = () => {
if (typeof document === 'undefined') return null if (typeof document === "undefined") return null;
const cookies = document.cookie.split(';').map((entry) => entry.trim()) const cookies = document.cookie.split(";").map((entry) => entry.trim());
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`)) const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`));
if (!marker) return null if (!marker) return null;
const [, value] = marker.split('=', 2) const [, value] = marker.split("=", 2);
return value || null return value || null;
} };
export const setToken = (_token: string) => { export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12) setCookie(AUTH_STATE_COOKIE, "1", 60 * 60 * 12);
} };
export const clearToken = () => { export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE) clearCookie(AUTH_STATE_COOKIE);
if (typeof window === 'undefined') return if (typeof window === "undefined") return;
const baseUrl = getApiBase() const baseUrl = getApiBase();
void fetch(`${baseUrl}/auth/logout`, { void fetch(`${baseUrl}/auth/logout`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
keepalive: true, keepalive: true,
}).catch(() => undefined) }).catch(() => undefined);
} };
export const logout = async () => { export const logout = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase();
clearCookie(AUTH_STATE_COOKIE) clearCookie(AUTH_STATE_COOKIE);
await fetch(`${baseUrl}/auth/logout`, { await fetch(`${baseUrl}/auth/logout`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
}) });
} };
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => { export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {}) const headers = new Headers(init?.headers || {});
return fetch(input, { ...init, headers, credentials: 'include' }) return fetch(input, { ...init, headers, credentials: "include" });
} };
export const getEventStreamToken = async () => { export const getEventStreamToken = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/stream-token`) const response = await authFetch(`${baseUrl}/auth/stream-token`);
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || `Stream token request failed: ${response.status}`) throw new Error(text || `Stream token request failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
const token = typeof data?.stream_token === 'string' ? data.stream_token : '' const token = typeof data?.stream_token === "string" ? data.stream_token : "";
if (!token) { if (!token) {
throw new Error('Stream token not returned') throw new Error("Stream token not returned");
} }
return token return token;
} };
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
constructor() { constructor() {
super('Unauthorized') super("Unauthorized");
this.name = 'UnauthorizedError' this.name = "UnauthorizedError";
} }
} }
export class ForbiddenError extends Error { export class ForbiddenError extends Error {
constructor() { constructor() {
super('Forbidden') super("Forbidden");
this.name = 'ForbiddenError' this.name = "ForbiddenError";
} }
} }
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => { export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init) const response = await authFetch(input, init);
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
throw new UnauthorizedError() throw new UnauthorizedError();
} }
if (response.status === 403) { if (response.status === 403) {
throw new ForbiddenError() throw new ForbiddenError();
} }
return response return response;
} };
export const readResponseText = async (response: Response) => { export const readResponseText = async (response: Response) => {
try { try {
return (await response.text()).trim() return (await response.text()).trim();
} catch { } catch {
return '' return "";
} }
} };
+39 -20
View File
@@ -1,24 +1,43 @@
export const FEATURES = [ export const FEATURES = [
{ key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' }, { key: "stats", label: "My Stats", description: "View personal viewing history, reports and request report emails." },
{ key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' }, { key: "requests", label: "My Requests", description: "View existing requests, their progress and request actions." },
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' }, {
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' }, key: "new_requests",
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' }, label: "New Requests",
{ key: 'ignore_profile_limits', label: 'Ignore profile limits', description: 'Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.' }, description: "Search for movies and TV shows and submit new requests.",
] as const },
export type Feature = typeof FEATURES[number]['key'] {
export type FeatureAccess = Record<Feature, boolean> key: "issues",
label: "Issues",
description: "Report problems, follow up on issues and use available repair tools.",
},
{ key: "invites", label: "Invites", description: "Create and manage invitations within the existing invite limits." },
{
key: "ignore_profile_limits",
label: "Ignore profile limits",
description:
"Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.",
},
] as const;
export type Feature = (typeof FEATURES)[number]["key"];
export type FeatureAccess = Record<Feature, boolean>;
export function featureForPath(path: string): Feature | undefined { export function featureForPath(path: string): Feature | undefined {
if (path === '/insights' || path.startsWith('/insights/')) return 'stats' if (path === "/insights" || path.startsWith("/insights/")) return "stats";
if (path === '/' || path.startsWith('/requests/')) return 'requests' if (path === "/" || path.startsWith("/requests/")) return "requests";
if (path === '/new-requests') return 'new_requests' if (path === "/new-requests") return "new_requests";
if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues' if (path.startsWith("/issues/confirm/") || path.startsWith("/portal/issues")) return "issues";
if (path.startsWith('/profile/invites')) return 'invites' if (path.startsWith("/profile/invites")) return "invites";
if (path === '/portal/requests') return 'requests' if (path === "/portal/requests") return "requests";
} }
export function canAccess(user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null, feature?: Feature) { export function canAccess(
if (!feature) return true user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null,
if (!user) return false feature?: Feature,
if (user.role === 'admin') return true ) {
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : feature !== 'ignore_profile_limits') if (!feature) return true;
if (!user) return false;
if (user.role === "admin") return true;
return (
user.features?.[feature] ??
(feature === "invites" ? Boolean(user.invite_management_enabled) : feature !== "ignore_profile_limits")
);
} }
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
describe("request result normalization", () => {
it("replaces placeholder request titles", () => {
expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
]);
});
it("drops malformed search results", () => {
expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
expect.objectContaining({ title: "Drive", requestId: 3991 }),
]);
});
});
+74
View File
@@ -0,0 +1,74 @@
export interface RecentRequest {
id: number;
title: string;
year?: number;
type?: string;
statusLabel?: string;
artwork?: { poster_url?: string; backdrop_url?: string };
createdAt?: string | null;
}
export interface RequestSearchResult {
title: string;
year?: number;
type?: string;
requestId?: number;
statusLabel?: string;
requestedBy?: string | null;
accessible?: boolean;
}
const recordValue = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const id = optionalNumber(item?.id);
if (!item || id === undefined) return [];
const rawTitle = optionalString(item.title);
const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
const rawArtwork = recordValue(item.artwork);
const artwork = rawArtwork
? {
poster_url: optionalString(rawArtwork.poster_url),
backdrop_url: optionalString(rawArtwork.backdrop_url),
}
: undefined;
return [
{
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: optionalNumber(item.year),
type: optionalString(item.type),
statusLabel: optionalString(item.statusLabel),
artwork,
createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
},
];
});
};
export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const title = optionalString(item?.title);
if (!item || !title) return [];
return [
{
title,
year: optionalNumber(item.year),
type: optionalString(item.type),
requestId: optionalNumber(item.requestId),
statusLabel: optionalString(item.statusLabel),
requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
accessible: Boolean(item.accessible),
},
];
});
};
+9 -9
View File
@@ -1,15 +1,15 @@
let locks = 0 let locks = 0;
let previous = '' let previous = "";
export function lockBodyScroll() { export function lockBodyScroll() {
if (locks++ === 0) { if (locks++ === 0) {
previous = document.body.style.overflow previous = document.body.style.overflow;
document.body.style.overflow = 'hidden' document.body.style.overflow = "hidden";
} }
let released = false let released = false;
return () => { return () => {
if (released) return if (released) return;
released = true released = true;
if (--locks === 0) document.body.style.overflow = previous if (--locks === 0) document.body.style.overflow = previous;
} };
} }
+30 -30
View File
@@ -1,48 +1,48 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview' const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
const USER_VIEW_EVENT = 'magent:user-view-change' const USER_VIEW_EVENT = "magent:user-view-change";
const readUserViewPreview = () => { const readUserViewPreview = () => {
if (typeof window === 'undefined') return false if (typeof window === "undefined") return false;
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1' return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
} };
const applyDocumentMode = (enabled: boolean) => { const applyDocumentMode = (enabled: boolean) => {
if (typeof document === 'undefined') return if (typeof document === "undefined") return;
document.documentElement.dataset.userView = enabled ? 'true' : 'false' document.documentElement.dataset.userView = enabled ? "true" : "false";
} };
export const setUserViewPreview = (enabled: boolean) => { export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === 'undefined') return if (typeof window === "undefined") return;
if (enabled) { if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1') window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
} else { } else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY) window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
} }
applyDocumentMode(enabled) applyDocumentMode(enabled);
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } })) window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
} };
export const useUserViewPreview = () => { export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false);
useEffect(() => { useEffect(() => {
const sync = () => { const sync = () => {
const nextValue = readUserViewPreview() const nextValue = readUserViewPreview();
applyDocumentMode(nextValue) applyDocumentMode(nextValue);
setEnabled(nextValue) setEnabled(nextValue);
} };
sync() sync();
window.addEventListener(USER_VIEW_EVENT, sync) window.addEventListener(USER_VIEW_EVENT, sync);
window.addEventListener('storage', sync) window.addEventListener("storage", sync);
return () => { return () => {
window.removeEventListener(USER_VIEW_EVENT, sync) window.removeEventListener(USER_VIEW_EVENT, sync);
window.removeEventListener('storage', sync) window.removeEventListener("storage", sync);
} };
}, []) }, []);
return enabled return enabled;
} };
+201 -81
View File
@@ -1,108 +1,228 @@
'use client' "use client";
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from "react";
import { getApiBase, setToken } from '../lib/auth' import { getApiBase, setToken } from "../lib/auth";
import AuthLayout from '../ui/AuthLayout' import AuthLayout from "../ui/AuthLayout";
type LoginMode = 'jellyfin' | 'local' type LoginMode = "jellyfin" | "local";
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean } type LoginOptions = {
const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true } showJellyfinLogin: boolean;
showLocalLogin: boolean;
showForgotPassword: boolean;
showSignupLink: boolean;
};
const DEFAULT_OPTIONS: LoginOptions = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
};
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false);
const [mode, setMode] = useState<LoginMode>('jellyfin') const [mode, setMode] = useState<LoginMode>("jellyfin");
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS) const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS);
const [optionsReady, setOptionsReady] = useState(false) const [optionsReady, setOptionsReady] = useState(false);
const [loginMessage, setLoginMessage] = useState('') const [loginMessage, setLoginMessage] = useState("");
const [error, setError] = useState('') const [error, setError] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const canSignIn = options.showJellyfinLogin || options.showLocalLogin const canSignIn = options.showJellyfinLogin || options.showLocalLogin;
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin' const selectedMode: LoginMode =
mode === "jellyfin" && options.showJellyfinLogin ? "jellyfin" : options.showLocalLogin ? "local" : "jellyfin";
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController();
const load = async () => { const load = async () => {
try { try {
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal }) const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal });
if (!response.ok) throw new Error('Options unavailable') if (!response.ok) throw new Error("Options unavailable");
const data = await response.json() const data = await response.json();
if (controller.signal.aborted) return if (controller.signal.aborted) return;
setOptions({ setOptions({
showJellyfinLogin: data?.login?.showJellyfinLogin !== false, showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
showLocalLogin: data?.login?.showLocalLogin !== false, showLocalLogin: data?.login?.showLocalLogin !== false,
showForgotPassword: data?.login?.showForgotPassword !== false, showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false, showSignupLink: data?.login?.showSignupLink !== false,
}) });
setLoginMessage(typeof data?.login?.message === 'string' ? data.login.message.trim() : '') setLoginMessage(typeof data?.login?.message === "string" ? data.login.message.trim() : "");
} catch { } catch {
// Keep the normal sign-in methods available during a settings outage. // Keep the normal sign-in methods available during a settings outage.
} finally { } finally {
if (!controller.signal.aborted) setOptionsReady(true) if (!controller.signal.aborted) setOptionsReady(true);
} }
} };
void load() void load();
return () => controller.abort() return () => controller.abort();
}, []) }, []);
const submit = async (event: FormEvent<HTMLFormElement>) => { const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault();
if (loading || !canSignIn || !optionsReady) return if (loading || !canSignIn || !optionsReady) return;
setError('') setError("");
setLoading(true) setLoading(true);
try { try {
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, { const response = await fetch(
method: 'POST', `${getApiBase()}${selectedMode === "jellyfin" ? "/auth/jellyfin/login" : "/auth/login"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, {
body: new URLSearchParams({ username: username.trim(), password }), method: "POST",
credentials: 'include', headers: { "Content-Type": "application/x-www-form-urlencoded" },
}) body: new URLSearchParams({ username: username.trim(), password }),
credentials: "include",
},
);
if (!response.ok) { if (!response.ok) {
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.' setError(
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.' response.status === 429
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.' ? "Too many attempts. Please wait a moment and try again."
: 'Check your username and password, then try again.') : response.status >= 500
return ? "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.",
);
return;
} }
const data = await response.json() const data = await response.json();
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } if (!data?.authenticated) {
setToken('cookie') setError("Could not sign in. Please try again.");
const next = new URLSearchParams(window.location.search).get('next') || '' return;
const allowedNext = ['/insights', '/insights/reports', '/profile', '/profile#monthly-recaps', '/profile#newsletters', '/admin/recaps', '/admin/newsletters'].includes(next) }
|| /^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) setToken("cookie");
|| /^\/issues\/confirm\/\d+$/.test(next) const next = new URLSearchParams(window.location.search).get("next") || "";
window.location.assign(allowedNext ? next : '/welcome') const allowedNext =
[
"/insights",
"/insights/reports",
"/profile",
"/profile#monthly-recaps",
"/profile#newsletters",
"/admin/recaps",
"/admin/newsletters",
].includes(next) ||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
/^\/issues\/confirm\/\d+$/.test(next);
window.location.assign(allowedNext ? next : "/welcome");
} catch { } catch {
setError('Could not reach Magent. Check your connection and try again.') setError("Could not reach Magent. Check your connection and try again.");
} finally { setLoading(false) } } finally {
} setLoading(false);
}
};
return ( return (
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={ <AuthLayout
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></> title="Welcome back."
}> description="Sign in to your media workspace."
{loginMessage && <p className="account-notice account-login-message" role="status">{loginMessage}</p>} footer={
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <fieldset className="login-methods" aria-label="Sign-in account"> optionsReady &&
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button> options.showSignupLink && (
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button> <>
</fieldset>} Have an invite?{" "}
{!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> : ( <a href="/signup">
<form className="account-form login-form" onSubmit={submit}> Create an account <span aria-hidden="true"></span>
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p> </a>
<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} /> {loginMessage && (
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}> <p className="account-notice account-login-message" role="status">
<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> {loginMessage}
</button> </p>
</div> )}
{error && <p className="account-notice is-error" role="alert">{error}</p>} {optionsReady && options.showJellyfinLogin && options.showLocalLogin && (
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true"></span></button> <fieldset className="login-methods" aria-label="Sign-in account">
</form> <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>
</fieldset>
)}
{!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>
</button>
</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>
</form>
)}
</AuthLayout> </AuthLayout>
) );
} }
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,9 +1,9 @@
import NewRequestClient from './NewRequestClient' import NewRequestClient from "./NewRequestClient";
export const metadata = { export const metadata = {
title: 'New Requests | Magent', title: "New Requests | Magent",
} };
export default function NewRequestsPage() { export default function NewRequestsPage() {
return <NewRequestClient /> return <NewRequestClient />;
} }
+143 -56
View File
@@ -1,70 +1,157 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
import BrandingLogo from '../ui/BrandingLogo' import BrandingLogo from "../ui/BrandingLogo";
import '../email-recaps/recaps.css' import "../email-recaps/recaps.css";
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string } type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
export default function NewsletterLinkPage() { export default function NewsletterLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null) const [link, setLink] = useState<LinkAction | null>(null);
const [state, setState] = useState('loading') const [state, setState] = useState("loading");
const [error, setError] = useState('') const [error, setError] = useState("");
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const currentLink = useRef<LinkAction | null>(null) const currentLink = useRef<LinkAction | null>(null);
useEffect(() => { useEffect(() => {
let controller: AbortController | null = null let controller: AbortController | null = null;
const checkLink = () => { const checkLink = () => {
controller?.abort() controller?.abort();
const abort = new AbortController() const abort = new AbortController();
controller = abort controller = abort;
setError(''); setState('loading'); setLink(null); setBusy(false); currentLink.current = null setError("");
setState("loading");
setLink(null);
setBusy(false);
currentLink.current = null;
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it. // Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1)) const params = new URLSearchParams(window.location.hash.slice(1));
const action = params.get('action') const action = params.get("action");
const token = params.get('token') || '' const token = params.get("token") || "";
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) { if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError('This email link is incomplete. Open Profile to manage your newsletters.'); setState('error'); return setError("This email link is incomplete. Open Profile to manage your newsletters.");
setState("error");
return;
} }
const payload = { action, token } as LinkAction const payload = { action, token } as LinkAction;
currentLink.current = payload currentLink.current = payload;
setLink(payload) setLink(payload);
void fetch(`${getApiBase()}/newsletter-subscription/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => { void fetch(`${getApiBase()}/newsletter-subscription/check`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.') headers: { "Content-Type": "application/json" },
if (!abort.signal.aborted) setState(result.state) body: JSON.stringify(payload),
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } }) signal: abort.signal,
} credentials: "omit",
checkLink() })
window.addEventListener('hashchange', checkLink) .then(async (response) => {
return () => { currentLink.current = null; controller?.abort(); window.removeEventListener('hashchange', checkLink) } const result = await response.json().catch(() => ({}));
}, []) if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not check this email link. Please open it again.",
);
if (!abort.signal.aborted) setState(result.state);
})
.catch((err: Error) => {
if (!abort.signal.aborted) {
setError(err.message);
setState("error");
}
});
};
checkLink();
window.addEventListener("hashchange", checkLink);
return () => {
currentLink.current = null;
controller?.abort();
window.removeEventListener("hashchange", checkLink);
};
}, []);
const apply = async () => { const apply = async () => {
if (!link || busy) return if (!link || busy) return;
const payload = link const payload = link;
setBusy(true); setError('') setBusy(true);
setError("");
try { try {
const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'omit' }) const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (currentLink.current !== payload) return headers: { "Content-Type": "application/json" },
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.') body: JSON.stringify(payload),
setState(result.state) credentials: "omit",
window.history.replaceState(null, '', '/newsletter-subscription') });
} catch (err) { if (currentLink.current === payload) setError(err instanceof Error ? err.message : 'Could not update your preference.') } const result = await response.json().catch(() => ({}));
finally { if (currentLink.current === payload) setBusy(false) } if (currentLink.current !== payload) return;
} if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
);
setState(result.state);
window.history.replaceState(null, "", "/newsletter-subscription");
} catch (err) {
if (currentLink.current === payload)
setError(err instanceof Error ? err.message : "Could not update your preference.");
} finally {
if (currentLink.current === payload) setBusy(false);
}
};
const done = state === 'enabled' || state === 'off' const done = state === "enabled" || state === "off";
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel"> return (
<span className="recap-eyebrow">Grizzlyflix newsletters</span> <main className="recap-link-page">
<h1>{state === 'enabled' ? 'Youre on the list.' : state === 'off' ? 'Newsletters are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from newsletters?' : 'Your next watch starts here.'}</h1> <a className="recap-brand" href="/login">
<p>{state === 'enabled' ? 'Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You wont receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off new-arrival newsletters. Your personal monthly recaps are managed separately.' : state === 'ready' ? 'Confirm to receive new movies, TV updates and featured picks, with posters and links to watch.' : ''}</p> <BrandingLogo className="brand-logo" />
{error && <p className="account-notice is-error" role="alert">{error}</p>} <span>Magent</span>
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from newsletters' : 'Confirm newsletter subscription'}</button>} </a>
{(done || state === 'error') && <a className="recap-text-link" href="/profile#newsletters">Manage email preferences </a>} <section className="account-panel">
{state === 'loading' && <p role="status">One moment</p>} <span className="recap-eyebrow">Grizzlyflix newsletters</span>
</section></main> <h1>
{state === "enabled"
? "Youre on the list."
: state === "off"
? "Newsletters are turned off."
: state === "loading"
? "Checking your email link"
: state === "error"
? "This link needs another look"
: link?.action === "unsubscribe"
? "Unsubscribe from newsletters?"
: "Your next watch starts here."}
</h1>
<p>
{state === "enabled"
? "Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs."
: state === "off"
? "You wont receive further monthly recaps. You can turn them back on in Profile."
: state === "ready" && link?.action === "unsubscribe"
? "This turns off new-arrival newsletters. Your personal monthly recaps are managed separately."
: state === "ready"
? "Confirm to receive new movies, TV updates and featured picks, with posters and links to watch."
: ""}
</p>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
{state === "ready" && (
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
{busy
? "Updating…"
: link?.action === "unsubscribe"
? "Unsubscribe from newsletters"
: "Confirm newsletter subscription"}
</button>
)}
{(done || state === "error") && (
<a className="recap-text-link" href="/profile#newsletters">
Manage email preferences
</a>
)}
{state === "loading" && <p role="status">One moment</p>}
</section>
</main>
);
} }
+6 -4
View File
@@ -1,11 +1,13 @@
import Link from 'next/link' import Link from "next/link";
import PageHeading from './ui/PageHeading' import PageHeading from "./ui/PageHeading";
export default function NotFound() { export default function NotFound() {
return ( return (
<main className="card"> <main className="card">
<PageHeading title="Page not found" description="This link may have moved or no longer be available." /> <PageHeading title="Page not found" description="This link may have moved or no longer be available." />
<p><Link href="/"> Back to my requests</Link></p> <p>
<Link href="/"> Back to my requests</Link>
</p>
</main> </main>
) );
} }
-81
View File
@@ -1,87 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
:root,
[data-theme='dark'],
[data-theme='light'] {
color-scheme: dark;
--ops-bg: #070d1c;
--ops-bg-2: #0b1326;
--ops-panel: #10182b;
--ops-panel-2: #151e33;
--ops-panel-3: #1c263d;
--ops-line: #334057;
--ops-line-soft: rgba(176, 190, 226, 0.16);
--ops-text: #eff4ff;
--ops-muted: #aeb7ce;
--ops-faint: #737d96;
--ops-primary: #5a50f0;
--ops-primary-2: #c6c1ff;
--ops-cyan: #7ed7ff;
--ops-cyan-2: #0ea5e9;
--ops-coral: #ffb08a;
--ops-green: #85efac;
--ops-red: #ff8d8d;
--ops-warn: #ffd082;
--ops-radius-sm: 4px;
--ops-radius: 6px;
--ops-radius-lg: 8px;
--ink: var(--ops-text);
--ink-muted: var(--ops-muted);
--paper: var(--ops-bg);
--paper-strong: var(--ops-panel);
--accent: var(--ops-coral);
--accent-2: var(--ops-primary);
--accent-3: var(--ops-cyan);
--border: var(--ops-line-soft);
--shadow: transparent;
--glow: 0 0 0 1px rgba(126, 215, 255, 0.16);
--input-bg: rgba(255, 255, 255, 0.035);
--input-ink: var(--ops-text);
--line: var(--ops-line-soft);
--panel: var(--ops-panel);
--panel-soft: rgba(255, 255, 255, 0.035);
--text: var(--ops-text);
--muted: var(--ops-muted);
--error-bg: rgba(122, 36, 53, 0.44);
--error-ink: #ffd6d6;
}
/* Stitch production handoff: Media-Ops master system */ /* Stitch production handoff: Media-Ops master system */
:root,
[data-theme='dark'],
[data-theme='light'] {
--ops-bg: #131315;
--ops-bg-2: #0e0e10;
--ops-panel: #1c1b1d;
--ops-panel-2: #201f21;
--ops-panel-3: #2a2a2c;
--ops-line: #46464d;
--ops-line-soft: rgba(145, 144, 152, 0.24);
--ops-text: #e5e1e4;
--ops-muted: #c7c5ce;
--ops-faint: #919098;
--ops-primary: #090d25;
--ops-primary-2: #c2c4e5;
--ops-cyan: #22d3ee;
--ops-cyan-2: #3b82f6;
--ops-coral: #ffb5a0;
--ops-green: #14b8a6;
--ops-red: #ef4444;
--ops-warn: #f59e0b;
--ops-radius-sm: 4px;
--ops-radius: 8px;
--ops-radius-lg: 12px;
--ink: var(--ops-text);
--ink-muted: var(--ops-muted);
--paper: var(--ops-bg);
--paper-strong: var(--ops-panel);
--border: var(--ops-line-soft);
--panel: var(--ops-panel);
--panel-soft: rgba(255, 255, 255, 0.035);
--input-bg: #0e0e10;
--input-ink: var(--ops-text);
}
* { * {
letter-spacing: 0 !important; letter-spacing: 0 !important;
} }
+5 -5
View File
@@ -1,9 +1,9 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import MyRequests from './MyRequests' import MyRequests from "./MyRequests";
export const dynamic = 'force-dynamic' export const dynamic = "force-dynamic";
export default function HomePage() { export default function HomePage() {
if (process.env.MAGENT_COMING_SOON === 'true') redirect('/coming-soon') if (process.env.MAGENT_COMING_SOON === "true") redirect("/coming-soon");
return <MyRequests /> return <MyRequests />;
} }
+43 -23
View File
@@ -1,43 +1,63 @@
'use client' "use client";
import { useEffect, useRef, type ReactNode } from 'react' import { useEffect, useRef, type ReactNode } from "react";
export default function IssueFlowStep({ export default function IssueFlowStep({
number, title, summary, active, complete, onEdit, children, number,
title,
summary,
active,
complete,
onEdit,
children,
}: { }: {
number: number number: number;
title: string title: string;
summary: string summary: string;
active: boolean active: boolean;
complete: boolean complete: boolean;
onEdit: () => void onEdit: () => void;
children: ReactNode children: ReactNode;
}) { }) {
const heading = useRef<HTMLHeadingElement>(null) const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => { useEffect(() => {
if (!active || number === 1) return if (!active || number === 1) return;
heading.current?.focus({ preventScroll: true }) heading.current?.focus({ preventScroll: true });
heading.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' }) heading.current?.scrollIntoView({ block: "nearest", behavior: "instant" });
}, [active, number]) }, [active, number]);
if (!active && !complete) return null if (!active && !complete) return null;
return ( return (
<section className={`issue-procedure-step ${active ? 'is-current' : 'is-complete'}`} aria-label={title}> <section className={`issue-procedure-step ${active ? "is-current" : "is-complete"}`} aria-label={title}>
{active ? ( {active ? (
<> <>
<div className="issue-flow-heading"> <div className="issue-flow-heading">
<span className="issue-step-number" aria-hidden="true">{String(number).padStart(2, '0')}</span> <span className="issue-step-number" aria-hidden="true">
<h2 ref={heading} tabIndex={-1}>{title}</h2> {String(number).padStart(2, "0")}
</span>
<h2 ref={heading} tabIndex={-1}>
{title}
</h2>
</div> </div>
<div className="issue-procedure-content">{children}</div> <div className="issue-procedure-content">{children}</div>
</> </>
) : ( ) : (
<button type="button" className="issue-step-summary" onClick={onEdit} aria-label={`Change ${title}: ${summary}`}> <button
<span className="issue-step-number" aria-hidden="true"></span> type="button"
<span className="issue-step-summary-copy"><small>{title}</small><strong>{summary}</strong></span> className="issue-step-summary"
onClick={onEdit}
aria-label={`Change ${title}: ${summary}`}
>
<span className="issue-step-number" aria-hidden="true">
</span>
<span className="issue-step-summary-copy">
<small>{title}</small>
<strong>{summary}</strong>
</span>
<span className="issue-step-change">Change</span> <span className="issue-step-change">Change</span>
</button> </button>
)} )}
</section> </section>
) );
} }
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,5 @@
import PortalClient from '../PortalClient' import PortalClient from "../PortalClient";
export default function IssuePortalPage() { export default function IssuePortalPage() {
return <PortalClient workspace="issue" /> return <PortalClient workspace="issue" />;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function PortalIndexPage() { export default function PortalIndexPage() {
redirect('/new-requests') redirect("/new-requests");
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function RequestPortalPage() { export default function RequestPortalPage() {
redirect('/new-requests') redirect("/new-requests");
} }
+206 -66
View File
@@ -1,83 +1,223 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../lib/auth' import { authFetch, getApiBase } from "../lib/auth";
import '../email-recaps/recaps.css' import "../email-recaps/recaps.css";
type Preference = { automatic_monthly: boolean; state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null } type Preference = {
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC` automatic_monthly: boolean;
state: "off" | "pending" | "expired" | "enabled";
email: string | null;
can_subscribe: boolean;
detail: string;
schedule_enabled: boolean;
next_send_at: number | null;
day: number;
hour: number;
resend_after: number | null;
};
const scheduled = (value: number) =>
`${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
export default function MonthlyRecapPreference() { export default function MonthlyRecapPreference() {
const router = useRouter() const router = useRouter();
const [data, setData] = useState<Preference | null>(null) const [data, setData] = useState<Preference | null>(null);
const [automatic, setAutomatic] = useState(false) const [automatic, setAutomatic] = useState(false);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now());
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
setError('') const abort = new AbortController();
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => { setError("");
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return } void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
if (!response.ok) throw new Error('Could not load your email preference. Please try again.') .then(async (response) => {
const result = await response.json() as Preference if (response.status === 401) {
if (!abort.signal.aborted) { setData(result); setAutomatic(result.automatic_monthly) } router.replace("/login?next=%2Fprofile");
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) return;
return () => abort.abort() }
}, [revision, router]) if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
const result = (await response.json()) as Preference;
if (!abort.signal.aborted) {
setData(result);
setAutomatic(result.automatic_monthly);
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision, router]);
useEffect(() => { useEffect(() => {
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000) const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer) return () => window.clearInterval(timer);
}, [data?.resend_after, data?.state]) }, [data?.resend_after, data?.state]);
const save = async (enabled: boolean, monthly = automatic) => { const save = async (enabled: boolean, monthly = automatic) => {
if (busy) return if (busy) return;
setBusy(true); setError(''); setNotice('') setBusy(true);
setError("");
setNotice("");
try { try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, { const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, automatic_monthly: monthly }), method: "PUT",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return } body: JSON.stringify({ enabled, automatic_monthly: monthly }),
const result = await response.json().catch(() => ({})) });
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.') if (response.status === 401) {
setData(result); setAutomatic(result.automatic_monthly); setNow(Date.now()) router.replace("/login?next=%2Fprofile");
setNotice(result.message || (enabled ? 'Personal report emails are enabled.' : 'Personal report emails are off.')) return;
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
setData(result);
setAutomatic(result.automatic_monthly);
setNow(Date.now());
setNotice(
result.message || (enabled ? "Personal report emails are enabled." : "Personal report emails are off."),
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Could not update your email preference.') setError(err instanceof Error ? err.message : "Could not update your email preference.");
// A confirmation may be pending even if SMTP could not confirm delivery. // A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null) const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null);
if (response?.ok) { const fresh = await response.json(); setData(fresh); setAutomatic(fresh.automatic_monthly); setNow(Date.now()) } if (response?.ok) {
} finally { setBusy(false) } const fresh = await response.json();
} setData(fresh);
setAutomatic(fresh.automatic_monthly);
setNow(Date.now());
}
} finally {
setBusy(false);
}
};
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0 const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
return <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title"> return (
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your reports, your choice.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Email confirmed' })[data.state]}</span>}</div> <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
<p>Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and decide whether you also want automatic monthly emails. <a href="/insights/reports">Explore your latest report </a></p> <div className="recap-section-heading">
{!data && !error && <p role="status">Loading your email preference</p>} <div>
{data && <> <span className="recap-eyebrow">A little look back</span>
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {!data.automatic_monthly ? 'On demand only: choose a month in Reports and email it whenever you want.' : data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}</p>} <h2 id="recap-preference-title">Your reports, your choice.</h2>
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>} </div>
{data.state !== 'enabled' && data.can_subscribe && automatic && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>} {data && (
<label className="recap-delivery-choice">Delivery preference<select value={automatic ? 'monthly' : 'manual'} disabled={busy || data.state === 'pending'} onChange={(event) => { <span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
const monthly = event.target.value === 'monthly' {
setAutomatic(monthly) { off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Email confirmed" }[
if (data.state === 'enabled') void save(true, monthly) data.state
}}><option value="manual">On demand only</option><option value="monthly">On demand + automatic monthly emails</option></select></label> ]
<div className="recap-actions"> }
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Confirm my email for reports' : 'Send a new confirmation'}</button>} </span>
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off report emails' : 'Cancel subscription'}</button>} )}
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
</div> </div>
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>} <p>
</>} Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>} decide whether you also want automatic monthly emails.{" "}
{notice && <p className="account-notice is-status" role="status">{notice}</p>} <a href="/insights/reports">Explore your latest report </a>
</section> </p>
{!data && !error && <p role="status">Loading your email preference</p>}
{data && (
<>
{data.state === "enabled" ? (
<p className="recap-delivery-address">
Recaps will go to <strong>{data.email}</strong>.{" "}
{!data.automatic_monthly
? "On demand only: choose a month in Reports and email it whenever you want."
: data.schedule_enabled && data.next_send_at
? `Next scheduled send: ${scheduled(data.next_send_at)}.`
: "The administrator has paused scheduled delivery."}
</p>
) : (
<p>
{data.state === "pending"
? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.`
: data.state === "expired"
? "Request a new confirmation link to turn on your recaps."
: "Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile."}
</p>
)}
{!data.can_subscribe && data.state !== "enabled" && <p className="recap-muted">{data.detail}</p>}
{data.state !== "enabled" && data.can_subscribe && automatic && !data.schedule_enabled && (
<p className="recap-muted">
You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
</p>
)}
<label className="recap-delivery-choice">
Delivery preference
<select
value={automatic ? "monthly" : "manual"}
disabled={busy || data.state === "pending"}
onChange={(event) => {
const monthly = event.target.value === "monthly";
setAutomatic(monthly);
if (data.state === "enabled") void save(true, monthly);
}}
>
<option value="manual">On demand only</option>
<option value="monthly">On demand + automatic monthly emails</option>
</select>
</label>
<div className="recap-actions">
{data.state !== "enabled" && (
<button
type="button"
className="account-primary"
disabled={busy || !data.can_subscribe || cooldown > 0}
onClick={() => void save(true)}
>
{busy
? "Sending confirmation…"
: data.state === "off"
? "Confirm my email for reports"
: "Send a new confirmation"}
</button>
)}
{data.state !== "off" && (
<button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>
{busy ? "Updating…" : data.state === "enabled" ? "Turn off report emails" : "Cancel subscription"}
</button>
)}
<button
type="button"
className="account-secondary"
disabled={busy}
onClick={() => {
setNotice("");
setRevision((value) => value + 1);
}}
>
Refresh preference
</button>
</div>
{cooldown > 0 && data.state !== "enabled" && (
<p className="recap-muted">
Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
{Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
</p>
)}
</>
)}
{error && (
<p className="account-notice is-error" role="alert">
{error}
{!data && (
<button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
)}
</p>
)}
{notice && (
<p className="account-notice is-status" role="status">
{notice}
</p>
)}
</section>
);
} }
+180 -60
View File
@@ -1,77 +1,197 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../lib/auth' import { authFetch, getApiBase } from "../lib/auth";
import '../email-recaps/recaps.css' import "../email-recaps/recaps.css";
type Preference = { state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; weekday: number; hour: number; resend_after: number | null } type Preference = {
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC` state: "off" | "pending" | "expired" | "enabled";
email: string | null;
can_subscribe: boolean;
detail: string;
schedule_enabled: boolean;
next_send_at: number | null;
weekday: number;
hour: number;
resend_after: number | null;
};
const scheduled = (value: number) =>
`${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
export default function NewsletterPreference() { export default function NewsletterPreference() {
const router = useRouter() const router = useRouter();
const [data, setData] = useState<Preference | null>(null) const [data, setData] = useState<Preference | null>(null);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now());
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
setError('') const abort = new AbortController();
void authFetch(`${getApiBase()}/profile/newsletters`, { signal: abort.signal }).then(async (response) => { setError("");
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return } void authFetch(`${getApiBase()}/profile/newsletters`, { signal: abort.signal })
if (!response.ok) throw new Error('Could not load your email preference. Please try again.') .then(async (response) => {
const result = await response.json() as Preference if (response.status === 401) {
if (!abort.signal.aborted) setData(result) router.replace("/login?next=%2Fprofile");
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) return;
return () => abort.abort() }
}, [revision, router]) if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
const result = (await response.json()) as Preference;
if (!abort.signal.aborted) setData(result);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision, router]);
useEffect(() => { useEffect(() => {
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000) const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer) return () => window.clearInterval(timer);
}, [data?.resend_after, data?.state]) }, [data?.resend_after, data?.state]);
const save = async (enabled: boolean) => { const save = async (enabled: boolean) => {
if (busy) return if (busy) return;
setBusy(true); setError(''); setNotice('') setBusy(true);
setError("");
setNotice("");
try { try {
const response = await authFetch(`${getApiBase()}/profile/newsletters`, { const response = await authFetch(`${getApiBase()}/profile/newsletters`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), method: "PUT",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return } body: JSON.stringify({ enabled }),
const result = await response.json().catch(() => ({})) });
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.') if (response.status === 401) {
setData(result); setNow(Date.now()) router.replace("/login?next=%2Fprofile");
setNotice(result.message || (enabled ? 'Newsletters are on.' : 'Newsletters are off.')) return;
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
setData(result);
setNow(Date.now());
setNotice(result.message || (enabled ? "Newsletters are on." : "Newsletters are off."));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Could not update your email preference.') setError(err instanceof Error ? err.message : "Could not update your email preference.");
// A confirmation may be pending even if SMTP could not confirm delivery. // A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/newsletters`).catch(() => null) const response = await authFetch(`${getApiBase()}/profile/newsletters`).catch(() => null);
if (response?.ok) { setData(await response.json()); setNow(Date.now()) } if (response?.ok) {
} finally { setBusy(false) } setData(await response.json());
} setNow(Date.now());
}
} finally {
setBusy(false);
}
};
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0 const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
return <section className="recap-preference" id="newsletters" aria-labelledby="newsletter-preference-title"> return (
<div className="recap-section-heading"><div><span className="recap-eyebrow">Your next watch</span><h2 id="newsletter-preference-title">New on Grizzlyflix.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]}</span>}</div> <section className="recap-preference" id="newsletters" aria-labelledby="newsletter-preference-title">
<p>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report </a></p> <div className="recap-section-heading">
{!data && !error && <p role="status">Loading your email preference</p>} <div>
{data && <> <span className="recap-eyebrow">Your next watch</span>
{data.state === 'enabled' ? <p className="recap-delivery-address">Newsletters will go to <strong>{data.email}</strong>. {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'Weekly sending is paused. You may still receive editions scheduled by your administrator.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. Your newsletter subscription starts after you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on newsletters.' : 'Subscribe with your profile email. If you already confirmed it for monthly recaps, you can turn this on straight away. Otherwise, we?ll send a confirmation link.'}</p>} <h2 id="newsletter-preference-title">New on Grizzlyflix.</h2>
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>} </div>
{data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && <p className="recap-muted">You can subscribe now, ready for the next edition your administrator sends.</p>} {data && (
<div className="recap-actions"> <span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me new arrivals' : 'Resend newsletter confirmation'}</button>} {
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off newsletters' : 'Cancel newsletter subscription'}</button>} { off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Subscribed" }[
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh newsletter preference</button> data.state
]
}
</span>
)}
</div> </div>
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>} <p>
</>} Your minutes, movies, episodes, longest run and requests, in one personal monthly email.{" "}
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>} <a href="/insights/reports">Explore your latest report </a>
{notice && <p className="account-notice is-status" role="status">{notice}</p>} </p>
</section> {!data && !error && <p role="status">Loading your email preference</p>}
{data && (
<>
{data.state === "enabled" ? (
<p className="recap-delivery-address">
Newsletters will go to <strong>{data.email}</strong>.{" "}
{data.schedule_enabled && data.next_send_at
? `Next scheduled send: ${scheduled(data.next_send_at)}.`
: "Weekly sending is paused. You may still receive editions scheduled by your administrator."}
</p>
) : (
<p>
{data.state === "pending"
? `Check ${data.email} for a confirmation link. It expires after 24 hours. Your newsletter subscription starts after you confirm.`
: data.state === "expired"
? "Request a new confirmation link to turn on newsletters."
: "Subscribe with your profile email. If you already confirmed it for monthly recaps, you can turn this on straight away. Otherwise, we?ll send a confirmation link."}
</p>
)}
{!data.can_subscribe && data.state !== "enabled" && <p className="recap-muted">{data.detail}</p>}
{data.state !== "enabled" && data.can_subscribe && !data.schedule_enabled && (
<p className="recap-muted">You can subscribe now, ready for the next edition your administrator sends.</p>
)}
<div className="recap-actions">
{data.state !== "enabled" && (
<button
type="button"
className="account-primary"
disabled={busy || !data.can_subscribe || cooldown > 0}
onClick={() => void save(true)}
>
{busy
? "Sending confirmation…"
: data.state === "off"
? "Email me new arrivals"
: "Resend newsletter confirmation"}
</button>
)}
{data.state !== "off" && (
<button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>
{busy
? "Updating…"
: data.state === "enabled"
? "Turn off newsletters"
: "Cancel newsletter subscription"}
</button>
)}
<button
type="button"
className="account-secondary"
disabled={busy}
onClick={() => {
setNotice("");
setRevision((value) => value + 1);
}}
>
Refresh newsletter preference
</button>
</div>
{cooldown > 0 && data.state !== "enabled" && (
<p className="recap-muted">
Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
{Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
</p>
)}
</>
)}
{error && (
<p className="account-notice is-error" role="alert">
{error}
{!data && (
<button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
)}
</p>
)}
{notice && (
<p className="account-notice is-status" role="status">
{notice}
</p>
)}
</section>
);
} }
+502 -195
View File
@@ -1,243 +1,272 @@
'use client' "use client";
import InviteDeliveryChoice from '../../ui/InviteDeliveryChoice' import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from '../../ui/PageHeading' import PageHeading from "../../ui/PageHeading";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean } type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
type OwnedInvite = { type OwnedInvite = {
id: number; code: string; label?: string | null; description?: string | null id: number;
code_available?: boolean code: string;
recipient_email?: string | null; max_uses?: number | null; use_count: number label?: string | null;
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null description?: string | null;
is_usable?: boolean; created_at?: string | null code_available?: boolean;
} 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 OwnedInvitesResponse = { type OwnedInvitesResponse = {
invites?: OwnedInvite[] invites?: OwnedInvite[];
invite_access?: { enabled?: boolean; managed_by_master?: boolean } 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 master_invite?: {
} id: number;
code: string;
label?: string | null;
max_uses?: number | null;
expires_at?: string | null;
} | null;
};
type InviteForm = { type InviteForm = {
code: string; label: string; description: string; recipient_email: string code: string;
enabled: boolean; message: string label: string;
} description: string;
type DeliveryMethod = '' | 'manual' | 'email' recipient_email: string;
enabled: boolean;
message: string;
};
type DeliveryMethod = "" | "manual" | "email";
const defaultInviteForm = (): InviteForm => ({ const defaultInviteForm = (): InviteForm => ({
code: '', label: '', description: '', recipient_email: '', enabled: true, message: '', code: "",
}) label: "",
description: "",
recipient_email: "",
enabled: true,
message: "",
});
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
if (!value) return 'Never' if (!value) return "Never";
const date = new Date(value) const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString() return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
} };
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
export default function ProfileInvitesPage() { export default function ProfileInvitesPage() {
const router = useRouter() const router = useRouter();
const [profile, setProfile] = useState<ProfileInfo | null>(null) const [profile, setProfile] = useState<ProfileInfo | null>(null);
const [invites, setInvites] = useState<OwnedInvite[]>([]) const [invites, setInvites] = useState<OwnedInvite[]>([]);
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false) const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false);
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false) const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false);
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse['master_invite']>(null) const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse["master_invite"]>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null);
const [flowStep, setFlowStep] = useState(1) const [flowStep, setFlowStep] = useState(1);
const [useCustomCode, setUseCustomCode] = useState(false) const [useCustomCode, setUseCustomCode] = useState(false);
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>('') const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm()) const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null) const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
const signupBaseUrl = useMemo(() => { const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup' if (typeof window === "undefined") return "/signup";
return `${window.location.origin}/signup` return `${window.location.origin}/signup`;
}, []) }, []);
const loadInvites = async () => { const loadInvites = useCallback(async () => {
const response = await authFetch(`${getApiBase()}/auth/profile/invites`) const response = await authFetch(`${getApiBase()}/auth/profile/invites`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error('Could not load your invite workspace.') throw new Error("Could not load your invite workspace.");
} }
const data = (await response.json()) as OwnedInvitesResponse const data = (await response.json()) as OwnedInvitesResponse;
setInvites(Array.isArray(data.invites) ? data.invites : []) setInvites(Array.isArray(data.invites) ? data.invites : []);
setInviteAccessEnabled(Boolean(data.invite_access?.enabled)) setInviteAccessEnabled(Boolean(data.invite_access?.enabled));
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master)) setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master));
setMasterInvite(data.master_invite ?? null) setMasterInvite(data.master_invite ?? null);
} }, [router]);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
const load = async () => { const load = async () => {
try { try {
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`) const profileResponse = await authFetch(`${getApiBase()}/auth/profile`);
if (!profileResponse.ok) { if (!profileResponse.ok) {
if (profileResponse.status === 401) { if (profileResponse.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error('Could not load your profile.') throw new Error("Could not load your profile.");
} }
const profileData = await profileResponse.json() const profileData = await profileResponse.json();
setProfile(profileData?.user ?? null) setProfile(profileData?.user ?? null);
await loadInvites() await loadInvites();
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Could not load your invite workspace.') setError(err instanceof Error ? err.message : "Could not load your invite workspace.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
void load() void load();
}, [router]) }, [loadInvites, router]);
const resetFlow = () => { const resetFlow = () => {
setEditingId(null) setEditingId(null);
setFlowStep(1) setFlowStep(1);
setUseCustomCode(false) setUseCustomCode(false);
setDeliveryMethod('') setDeliveryMethod("");
setInviteForm(defaultInviteForm()) setInviteForm(defaultInviteForm());
} };
const editInvite = (invite: OwnedInvite) => { const editInvite = (invite: OwnedInvite) => {
setEditingId(invite.id) setEditingId(invite.id);
setCreatedInvite(null) setCreatedInvite(null);
setFlowStep(4) setFlowStep(4);
setUseCustomCode(true) setUseCustomCode(true);
setDeliveryMethod(invite.recipient_email ? 'email' : 'manual') setDeliveryMethod(invite.recipient_email ? "email" : "manual");
setInviteForm({ setInviteForm({
code: invite.code, code: invite.code,
label: invite.label ?? '', label: invite.label ?? "",
description: invite.description ?? '', description: invite.description ?? "",
recipient_email: invite.recipient_email ?? '', recipient_email: invite.recipient_email ?? "",
enabled: invite.enabled !== false, enabled: invite.enabled !== false,
message: '', message: "",
}) });
setError(null) setError(null);
setStatus(null) setStatus(null);
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: "smooth" });
} };
const saveInvite = async (event: React.FormEvent) => { const saveInvite = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
const inviteName = inviteForm.label.trim() const inviteName = inviteForm.label.trim();
const recipientEmail = inviteForm.recipient_email.trim() const recipientEmail = inviteForm.recipient_email.trim();
if (!inviteName) { if (!inviteName) {
setError('Give this invite a name so you can recognise it later.') setError("Give this invite a name so you can recognise it later.");
return return;
} }
if (!deliveryMethod) { if (!deliveryMethod) {
setError('Choose how you want to deliver the invite.') setError("Choose how you want to deliver the invite.");
return return;
} }
if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) { if (deliveryMethod === "email" && !isValidEmail(recipientEmail)) {
setError('Enter a valid recipient email address.') setError("Enter a valid recipient email address.");
return return;
} }
setSaving(true) setSaving(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const response = await authFetch( const response = await authFetch(
editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`, editingId == null
? `${getApiBase()}/auth/profile/invites`
: `${getApiBase()}/auth/profile/invites/${editingId}`,
{ {
method: editingId == null ? 'POST' : 'PUT', method: editingId == null ? "POST" : "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
code: useCustomCode ? inviteForm.code || null : null, code: useCustomCode ? inviteForm.code || null : null,
label: inviteName, label: inviteName,
description: inviteForm.description || null, description: inviteForm.description || null,
recipient_email: deliveryMethod === 'email' ? recipientEmail : null, recipient_email: deliveryMethod === "email" ? recipientEmail : null,
enabled: inviteForm.enabled, enabled: inviteForm.enabled,
send_email: editingId == null && deliveryMethod === 'email', send_email: editingId == null && deliveryMethod === "email",
message: inviteForm.message || null, message: inviteForm.message || null,
}), }),
} },
) );
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
throw new Error((await response.text()) || 'Could not save the invite.') throw new Error((await response.text()) || "Could not save the invite.");
} }
const data = await response.json() const data = await response.json();
const savedInvite = data?.invite as OwnedInvite | undefined const savedInvite = data?.invite as OwnedInvite | undefined;
setStatus( setStatus(
data?.email?.status === 'ok' data?.email?.status === "ok"
? `Invite created and emailed to ${data.email.recipient_email}.` ? `Invite created and emailed to ${data.email.recipient_email}.`
: data?.email?.status === 'error' : data?.email?.status === "error"
? `Invite created, but the email could not be sent: ${data.email.detail}` ? `Invite created, but the email could not be sent: ${data.email.detail}`
: editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.' : editingId == null
) ? "Invite link created and ready to share."
resetFlow() : "Invite updated.",
if (editingId == null && savedInvite) setCreatedInvite(savedInvite) );
await loadInvites() resetFlow();
if (editingId == null && savedInvite) setCreatedInvite(savedInvite);
await loadInvites();
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Could not save the invite.') setError(err instanceof Error ? err.message : "Could not save the invite.");
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
const deleteInvite = async (invite: OwnedInvite) => { const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
setError(null) setError(null);
try { try {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' }) 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 (!response.ok) throw new Error((await response.text()) || "Could not delete the invite.");
if (editingId === invite.id) resetFlow() if (editingId === invite.id) resetFlow();
setStatus(`Deleted ${invite.label || invite.code}.`) setStatus(`Deleted ${invite.label || invite.code}.`);
await loadInvites() await loadInvites();
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Could not delete the invite.') setError(err instanceof Error ? err.message : "Could not delete the invite.");
} }
} };
const copyInviteLink = async (invite: OwnedInvite) => { const copyInviteLink = async (invite: OwnedInvite) => {
try { try {
let usableInvite = invite let usableInvite = invite;
if (!invite.code_available) { if (!invite.code_available) {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, { const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
method: 'POST', method: "POST",
}) });
if (!response.ok) throw new Error((await response.text()) || 'Could not generate a replacement link.') if (!response.ok) throw new Error((await response.text()) || "Could not generate a replacement link.");
const data = await response.json() const data = await response.json();
usableInvite = data.invite as OwnedInvite usableInvite = data.invite as OwnedInvite;
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item)) setInvites((current) => current.map((item) => (item.id === invite.id ? usableInvite : item)));
} }
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}` const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`;
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url);
setStatus(`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`) setStatus(
`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`,
);
} catch { } catch {
setError('Could not generate or copy the invite link.') setError("Could not generate or copy the invite link.");
} }
} };
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '') const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6)) const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled const canManageInvites = profile?.role === "admin" || inviteAccessEnabled;
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : '' 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 workspace</main>;
return ( return (
<main className="card invites-page"> <main className="card invites-page">
@@ -253,65 +282,343 @@ export default function ProfileInvitesPage() {
) : ( ) : (
<section className="profile-section profile-invites-section profile-tab-panel"> <section className="profile-section profile-invites-section profile-tab-panel">
<div className="invite-flow-heading"> <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> <div>
{editingId != null && <button type="button" className="ghost-button" onClick={resetFlow}>Cancel edit</button>} <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> </div>
{createdInvite && editingId == null ? ( {createdInvite && editingId == null ? (
<div className="invite-created-card" role="status"> <div className="invite-created-card" role="status">
<span className="eyebrow">Invite ready</span><h3>{createdInvite.label || 'Your invite'}</h3> <span className="eyebrow">Invite ready</span>
<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> <h3>{createdInvite.label || "Your invite"}</h3>
<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> <p>
<button type="button" className="ghost-button" onClick={() => { setCreatedInvite(null); resetFlow() }}>Create another invite</button> {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> </div>
) : ( ) : (
<form onSubmit={saveInvite} className="invite-flow-form"> <form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress"> <ol className="invite-flow-route" aria-label="Invite creation progress">
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => { {["Identity", "Description", "Access", "Delivery"].map((label, index) => {
const step = index + 1 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> 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> </ol>
<section className={`invite-flow-step ${flowStep > 1 ? 'is-complete' : 'is-active'}`}> <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> <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-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>
<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> <span>Invite name</span>
{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>} <input
{flowStep === 1 && <div className="invite-flow-actions"><button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>Continue to description</button></div>} 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>
)}
</div> </div>
</section> </section>
{flowStep >= 2 && <section className={`invite-flow-step ${flowStep > 2 ? 'is-complete' : 'is-active'}`}> {flowStep >= 2 && (
<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> <section className={`invite-flow-step ${flowStep > 2 ? "is-complete" : "is-active"}`}>
<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> <header>
</section>} <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'}`}> {flowStep >= 3 && (
<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> <section className={`invite-flow-step ${flowStep > 3 ? "is-complete" : "is-active"}`}>
<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> <header>
</section>} <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"> {flowStep >= 4 && (
<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> <section className="invite-flow-step is-active">
<div className="invite-flow-fields"> <header>
<InviteDeliveryChoice value={deliveryMethod} onChange={(method) => { setDeliveryMethod(method); if (method === 'manual') setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }} /> <span className="invite-flow-number">04</span>
{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>} <div>
{deliveryMethod === 'email' && <div className="invite-flow-field-grid invite-delivery-fields"><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>} <span className="eyebrow">Delivery</span>
{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>} <h3>How will they receive it?</h3>
<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> <p>Copy the link yourself, or let Magent email it directly.</p>
</div> </div>
</section>} </header>
<div className="invite-flow-fields">
<InviteDeliveryChoice
value={deliveryMethod}
onChange={(method) => {
setDeliveryMethod(method);
if (method === "manual")
setInviteForm((current) => ({ ...current, recipient_email: "", message: "" }));
}}
/>
{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 invite-delivery-fields">
<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>
</section>
)}
</form> </form>
)} )}
<div className="profile-invites-list"> <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> <div className="invite-flow-heading">
{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)}>{invite.code_available ? 'Copy link' : 'Generate replacement 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>} <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)}>
{invite.code_available ? "Copy link" : "Generate replacement 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>
)}
</div> </div>
</section> </section>
)} )}
</main> </main>
) );
} }
+437 -195
View File
@@ -1,240 +1,482 @@
'use client' "use client";
import { canAccess, type FeatureAccess } from '../lib/features' import { canAccess, type FeatureAccess } from "../lib/features";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import MonthlyRecapPreference from './MonthlyRecapPreference' import MonthlyRecapPreference from "./MonthlyRecapPreference";
import NewsletterPreference from './NewsletterPreference' import NewsletterPreference from "./NewsletterPreference";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react' import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type ProfileInfo = { type ProfileInfo = {
features?: FeatureAccess features?: FeatureAccess;
username: string username: string;
email?: string | null email?: string | null;
role: string role: string;
auth_provider: string auth_provider: string;
password_change_supported?: boolean password_change_supported?: boolean;
password_provider?: 'local' | 'jellyfin' | null password_provider?: "local" | "jellyfin" | null;
} };
type ActivityEntry = { type ActivityEntry = {
ip: string ip: string;
user_agent: string user_agent: string;
first_seen_at: string first_seen_at: string;
last_seen_at: string last_seen_at: string;
} };
type ProfileResponse = { type ProfileResponse = {
user: ProfileInfo user: ProfileInfo;
stats?: { total: number; ready: number; in_progress: number } stats?: { total: number; ready: number; in_progress: number };
activity?: { recent: ActivityEntry[] } activity?: { recent: ActivityEntry[] };
} };
type Notice = { tone: 'status' | 'error'; message: string } | null type Notice = { tone: "status" | "error"; message: string } | null;
type ProfileTab = 'overview' | 'security' | 'activity' type ProfileTab = "overview" | "security" | "activity";
const TABS: { key: ProfileTab; label: string }[] = [ const TABS: { key: ProfileTab; label: string }[] = [
{ key: 'overview', label: 'Account' }, { key: "overview", label: "Account" },
{ key: 'security', label: 'Security' }, { key: "security", label: "Security" },
{ key: 'activity', label: 'Activity' }, { key: "activity", label: "Activity" },
] ];
const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview' const normalizeTab = (value: string | null): ProfileTab =>
value === "security" || value === "activity" ? value : "overview";
const formatDate = (value?: string) => { const formatDate = (value?: string) => {
if (!value) return 'Not recorded' if (!value) return "Not recorded";
const date = new Date(value) const date = new Date(value);
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) return Number.isNaN(date.valueOf())
} ? "Not recorded"
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
};
const deviceName = (agent: string) => { const deviceName = (agent: string) => {
const value = (agent || '').toLowerCase() const value = (agent || "").toLowerCase();
const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox' const browser = value.includes("edg/")
: value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser' ? "Edge"
const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android' : value.includes("firefox/") || value.includes("fxios/")
: value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : '' ? "Firefox"
return device ? `${browser} on ${device}` : browser : 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 responseMessage = async (response: Response, fallback: string) => {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null);
return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback return typeof data?.detail === "string" && data.detail.trim() ? data.detail : fallback;
} };
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter() const router = useRouter();
const [data, setData] = useState<ProfileResponse | null>(null) const [data, setData] = useState<ProfileResponse | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState("");
const [activeTab, setActiveTab] = useState<ProfileTab>('overview') const [activeTab, setActiveTab] = useState<ProfileTab>("overview");
const [email, setEmail] = useState('') const [email, setEmail] = useState("");
const [emailSaving, setEmailSaving] = useState(false) const [emailSaving, setEmailSaving] = useState(false);
const [emailNotice, setEmailNotice] = useState<Notice>(null) const [emailNotice, setEmailNotice] = useState<Notice>(null);
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordNotice, setPasswordNotice] = useState<Notice>(null) const [passwordNotice, setPasswordNotice] = useState<Notice>(null);
const [showAllActivity, setShowAllActivity] = useState(false) const [showAllActivity, setShowAllActivity] = useState(false);
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
if (!getToken()) { router.replace('/login?next=%2Fprofile'); return } if (!getToken()) {
setLoading(true) router.replace("/login?next=%2Fprofile");
setLoadError('') return;
try {
const response = await authFetch(`${getApiBase()}/auth/profile`)
if (response.status === 401) { clearToken(); router.replace('/login?next=%2Fprofile'); 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]) setLoading(true);
setLoadError("");
try {
const response = await authFetch(`${getApiBase()}/auth/profile`);
if (response.status === 401) {
clearToken();
router.replace("/login?next=%2Fprofile");
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]);
useEffect(() => { void loadProfile() }, [loadProfile])
useEffect(() => { useEffect(() => {
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get('tab'))) void loadProfile();
syncTab() }, [loadProfile]);
window.addEventListener('popstate', syncTab) useEffect(() => {
return () => window.removeEventListener('popstate', syncTab) const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get("tab")));
}, []) syncTab();
window.addEventListener("popstate", syncTab);
return () => window.removeEventListener("popstate", syncTab);
}, []);
const selectTab = (tab: ProfileTab) => { const selectTab = (tab: ProfileTab) => {
setActiveTab(tab) setActiveTab(tab);
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false }) router.replace(tab === "overview" ? "/profile" : `/profile?tab=${tab}`, { scroll: false });
} };
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index let next = index;
if (event.key === 'ArrowRight') next = (index + 1) % TABS.length 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 === "ArrowLeft") next = (index + TABS.length - 1) % TABS.length;
else if (event.key === 'Home') next = 0 else if (event.key === "Home") next = 0;
else if (event.key === 'End') next = TABS.length - 1 else if (event.key === "End") next = TABS.length - 1;
else return else return;
event.preventDefault() event.preventDefault();
selectTab(TABS[next].key) selectTab(TABS[next].key);
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus() document.getElementById(`profile-tab-${TABS[next].key}`)?.focus();
} };
const saveEmail = async (event: FormEvent<HTMLFormElement>) => { const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault();
if (emailSaving) return if (emailSaving) return;
setEmailSaving(true) setEmailSaving(true);
setEmailNotice(null) setEmailNotice(null);
try { try {
const response = await authFetch(`${getApiBase()}/auth/profile/email`, { const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() || null }), body: JSON.stringify({ email: email.trim() || null }),
}) });
if (response.status === 401) { clearToken(); router.replace('/login?next=%2Fprofile'); return } if (response.status === 401) {
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.')) clearToken();
const result = await response.json() router.replace("/login?next=%2Fprofile");
const saved = typeof result.email === 'string' ? result.email : '' return;
setData((current) => current ? { ...current, user: { ...current.user, email: saved || null } } : current) }
setEmail(saved) if (!response.ok)
setEmailNotice({ tone: 'status', message: saved ? 'Email saved.' : 'Email removed.' }) 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) { } catch (error) {
setEmailNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not save your email.' }) setEmailNotice({ tone: "error", message: error instanceof Error ? error.message : "Could not save your email." });
} finally { setEmailSaving(false) } } finally {
} setEmailSaving(false);
}
};
const savePassword = async (event: FormEvent<HTMLFormElement>) => { const savePassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault();
if (passwordSaving) return if (passwordSaving) return;
setPasswordNotice(null) setPasswordNotice(null);
if (newPassword.trim().length < 8) { if (newPassword.trim().length < 8) {
setPasswordNotice({ tone: 'error', message: 'Use at least 8 characters for your new password.' }) setPasswordNotice({ tone: "error", message: "Use at least 8 characters for your new password." });
return return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' }) setPasswordNotice({ tone: "error", message: "The new passwords do not match." });
return return;
} }
setPasswordSaving(true) setPasswordSaving(true);
try { try {
const response = await authFetch(`${getApiBase()}/auth/password`, { const response = await authFetch(`${getApiBase()}/auth/password`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), 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.')) if (!response.ok)
const result = await response.json() throw new Error(await responseMessage(response, "Could not change your password. Please try again."));
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') const result = await response.json();
setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin' setCurrentPassword("");
? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.' setNewPassword("");
: 'Password updated.' }) setConfirmPassword("");
setPasswordNotice({
tone: "status",
message:
result.provider === "jellyfin"
? "Password updated for Grizzlyflix and Magent. Seerr uses the same password."
: "Password updated.",
});
} catch (error) { } catch (error) {
setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' }) setPasswordNotice({
} finally { setPasswordSaving(false) } tone: "error",
} message: error instanceof Error ? error.message : "Could not change your password.",
});
} finally {
setPasswordSaving(false);
}
};
const user = data?.user const user = data?.user;
const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local') const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '') const canChangePassword =
const emailChanged = email.trim() !== (user?.email ?? '') user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
const recent = data?.activity?.recent ?? [] const emailChanged = email.trim() !== (user?.email ?? "");
const notice = (value: Notice) => value && <p className={`account-notice is-${value.tone}`} role={value.tone === 'error' ? 'alert' : 'status'}>{value.message}</p> 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>
);
return ( return (
<main className="account-page"> <main className="account-page">
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={ <PageHeading
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> title="My profile"
} /> description="Your contact details, security, and activity."
actions={
{loading ? <p className="account-empty" role="status">Loading your profile</p> : loadError ? ( user && (
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div> <div className="account-identity">
) : user && <> <span className="account-avatar" aria-hidden="true">
<div className="account-tabs" role="tablist" aria-label="Profile sections"> {user.username.slice(0, 1).toUpperCase()}
{TABS.map((tab, index) => <button key={tab.key} id={`profile-tab-${tab.key}`} type="button" role="tab" </span>
aria-selected={activeTab === tab.key} aria-controls={`profile-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1} <div>
onKeyDown={(event) => tabKeyDown(event, index)} onClick={() => selectTab(tab.key)}>{tab.label}</button>)} <strong>{user.username}</strong>
</div> <span>{user.role === "admin" ? "Administrator" : "Member"}</span>
</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>}
</div> </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> }
{canAccess(user, 'stats') && <MonthlyRecapPreference key={user.email || 'no-email'} />} />
<NewsletterPreference key={`newsletter-${user.email || 'no-email'}`} />
</section>
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}> {loading ? (
<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> <p className="account-empty" role="status">
{canChangePassword ? <form className="account-form" onSubmit={savePassword}> Loading your profile
<fieldset disabled={passwordSaving}> </p>
<label htmlFor="profile-current-password">Current password</label> ) : loadError ? (
<input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required /> <div className="account-empty">
<label htmlFor="profile-new-password">New password</label> <p role="alert">{loadError}</p>
<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 /> <button type="button" className="account-secondary" onClick={() => void loadProfile()}>
<p id="password-length" className="account-hint">At least 8 characters.</p> Try again
<label htmlFor="profile-confirm-password">Confirm new password</label> </button>
<input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} minLength={8} required /> </div>
</fieldset> ) : (
{notice(passwordNotice)} user && (
<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>} <div className="account-tabs" role="tablist" aria-label="Profile sections">
</section> {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>
))}
</div>
<section className="account-panel" id="profile-panel-activity" role="tabpanel" aria-labelledby="profile-tab-activity" hidden={activeTab !== 'activity'}> <section
<div className="account-section-intro"><h2>Your activity</h2><p>Your requests and recent account access.</p></div> className="account-panel"
{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>} id="profile-panel-overview"
<h3 className="account-list-heading">Recent account access</h3> role="tabpanel"
{recent.length ? <ul className="account-access-list"> aria-labelledby="profile-tab-overview"
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => <li key={`${entry.ip}-${entry.last_seen_at}-${index}`}> hidden={activeTab !== "overview"}
<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> <div className="account-section-intro">
</li>)} <h2>Contact email</h2>
</ul> : <p className="account-empty">No recent activity yet.</p>} <p>For password recovery and updates on your reported issues.</p>
{recent.length > 5 && <button className="account-secondary" type="button" onClick={() => setShowAllActivity(!showAllActivity)}>{showAllActivity ? 'Show less' : 'Show all activity'}</button>} </div>
</section> <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>
)}
</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>
{canAccess(user, "stats") && <MonthlyRecapPreference key={user.email || "no-email"} />}
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
</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> </main>
) );
} }
+149 -56
View File
@@ -1,80 +1,173 @@
'use client' "use client";
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from "react";
import './latest-activity.css' import "./latest-activity.css";
import { lockBodyScroll } from '../../lib/scrollLock' import { lockBodyScroll } from "../../lib/scrollLock";
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string } type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string };
type Operation = { summary?: { title: string; message: string; next: string; action?: string }; id: string; label: string; status: string; events: Event[] } type Operation = {
summary?: { title: string; message: string; next: string; action?: string };
id: string;
label: string;
status: string;
events: Event[];
};
export default function LatestActivity({ operation, besideDownload, onDismiss }: { export default function LatestActivity({
operation: Operation; besideDownload: boolean; onDismiss: () => void operation,
besideDownload,
onDismiss,
}: {
operation: Operation;
besideDownload: boolean;
onDismiss: () => void;
}) { }) {
const dialog = useRef<HTMLDialogElement>(null) const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null) const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true);
useEffect(() => { setOpen(true) }, [operation.id]) useEffect(() => {
void operation.id;
setOpen(true);
}, [operation.id]);
// Events are appended in order. Client result events have no timestamp. // Events are appended in order. Client result events have no timestamp.
const latest = operation.events.at(-1) const latest = operation.events.at(-1);
const working = operation.status === 'running' || operation.status === 'searching' const working = operation.status === "running" || operation.status === "searching";
const message = latest?.message ?? '' const message = latest?.message ?? "";
const choosing = /[1-9]\d* releases? (found|shown)/i.test(message) const choosing = /[1-9]\d* releases? (found|shown)/i.test(message);
const sent = operation.status === 'complete' && /sent|accepted.*release/i.test(message) && /download|release|Sonarr|Radarr/i.test(message) const sent =
const interrupted = latest?.id === 'connection-error' operation.status === "complete" &&
const status = operation.summary?.title ?? (working ? 'Working on it' : choosing ? 'Choose a download' : operation.status === 'complete' ? 'Done' : 'Needs your attention') /sent|accepted.*release/i.test(message) &&
const currentStep = operation.summary?.message ?? (working /download|release|Sonarr|Radarr/i.test(message);
? /send release/i.test(operation.label) ? 'Sending your download...' const interrupted = latest?.id === "connection-error";
: /search/i.test(operation.label) ? 'Looking for a download...' const status =
: latest?.service === 'Jellyfin' ? 'Checking if it is ready to watch...' operation.summary?.title ??
: latest?.service === 'qBittorrent' ? 'Checking your download...' (working
: 'Checking your request...' ? "Working on it"
: interrupted ? 'The connection was lost.' : choosing
: choosing ? 'The search is finished. Choose a version to download.' ? "Choose a download"
: sent ? 'Your download has been sent.' : operation.status === "complete"
: operation.status === 'error' ? 'We could not finish this step.' ? "Done"
: 'This check is finished.') : "Needs your attention");
const nextStep = operation.summary?.next ?? (working ? 'Please wait. You can close this box while we work.' const currentStep =
: interrupted ? 'Close this box and check the request before trying again.' operation.summary?.message ??
: choosing ? 'Close this box to see the available downloads.' (working
: sent ? 'You can close this box. The request will update when the download starts.' ? /send release/i.test(operation.label)
: operation.status === 'error' ? 'Close this box to review the request and its available options.' ? "Sending your download..."
: 'Close this box to see the updated request status.') : /search/i.test(operation.label)
const progress = <div className={`activity-process is-${working ? 'working' : operation.status}`} role="progressbar" aria-label={currentStep} aria-valuemin={0} aria-valuemax={100} aria-valuenow={working || operation.status === 'error' ? undefined : 100}><span /></div> ? "Looking for a download..."
: latest?.service === "Jellyfin"
? "Checking if it is ready to watch..."
: latest?.service === "qBittorrent"
? "Checking your download..."
: "Checking your request..."
: interrupted
? "The connection was lost."
: choosing
? "The search is finished. Choose a version to download."
: sent
? "Your download has been sent."
: operation.status === "error"
? "We could not finish this step."
: "This check is finished.");
const nextStep =
operation.summary?.next ??
(working
? "Please wait. You can close this box while we work."
: interrupted
? "Close this box and check the request before trying again."
: choosing
? "Close this box to see the available downloads."
: sent
? "You can close this box. The request will update when the download starts."
: operation.status === "error"
? "Close this box to review the request and its available options."
: "Close this box to see the updated request status.");
const progress = (
<div
className={`activity-process is-${working ? "working" : operation.status}`}
role="progressbar"
aria-label={currentStep}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={working || operation.status === "error" ? undefined : 100}
>
<span />
</div>
);
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return;
const element = dialog.current const element = dialog.current;
element?.showModal() element?.showModal();
const unlock = lockBodyScroll() const unlock = lockBodyScroll();
return () => { return () => {
element?.close() element?.close();
unlock() unlock();
trigger.current?.focus() trigger.current?.focus();
} };
}, [open]) }, [open]);
return ( return (
<div className={`request-overview-block latest-activity ${besideDownload ? 'beside-download' : 'full-row'}`}> <div className={`request-overview-block latest-activity ${besideDownload ? "beside-download" : "full-row"}`}>
<button ref={trigger} type="button" className="latest-activity-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}> <button
<span className="latest-activity-heading"><span className="request-overview-label">Latest activity</span><span className={`latest-activity-badge is-${operation.status}`}>{status}</span></span> ref={trigger}
<span className="latest-activity-message" role="status">{working && <span className="activity-spinner" aria-hidden="true" />}{currentStep}</span> type="button"
className="latest-activity-trigger"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="latest-activity-heading">
<span className="request-overview-label">Latest activity</span>
<span className={`latest-activity-badge is-${operation.status}`}>{status}</span>
</span>
<span className="latest-activity-message" role="status">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</span>
{progress} {progress}
<span className="latest-activity-more">View progress</span> <span className="latest-activity-more">View progress</span>
</button> </button>
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)}> <dialog
ref={dialog}
className="activity-dialog"
aria-labelledby="activity-dialog-title"
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
>
<div className="activity-dialog-content"> <div className="activity-dialog-content">
<header> <header>
<div><span className="request-overview-label">Request progress</span><h2 id="activity-dialog-title">{status}</h2></div> <div>
<button type="button" onClick={() => setOpen(false)}>Close</button> <span className="request-overview-label">Request progress</span>
<h2 id="activity-dialog-title">{status}</h2>
</div>
<button type="button" onClick={() => setOpen(false)}>
Close
</button>
</header> </header>
<div className="activity-current" role="status" aria-live="polite" aria-atomic="true"> <div className="activity-current" role="status" aria-live="polite" aria-atomic="true">
<p className="activity-current-step">{working && <span className="activity-spinner" aria-hidden="true" />}{currentStep}</p> <p className="activity-current-step">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</p>
{progress} {progress}
<p className="activity-next-step">{nextStep}</p> <p className="activity-next-step">{nextStep}</p>
</div> </div>
{!working && <footer><button type="button" onClick={() => { setOpen(false); onDismiss() }}>{operation.summary?.action ?? 'Dismiss activity'}</button></footer>} {!working && (
<footer>
<button
type="button"
onClick={() => {
setOpen(false);
onDismiss();
}}
>
{operation.summary?.action ?? "Dismiss activity"}
</button>
</footer>
)}
</div> </div>
</dialog> </dialog>
</div> </div>
) );
} }
+73 -29
View File
@@ -1,36 +1,80 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
type AudioChoice = { language: { code: string } | null; originalEnabled?: boolean; canChange?: boolean; profileLanguage?: string } type AudioChoice = {
language: { code: string } | null;
originalEnabled?: boolean;
canChange?: boolean;
profileLanguage?: string;
};
export default function RequestLanguage({ requestId, disabled, onApply }: { export default function RequestLanguage({
requestId: string; disabled: boolean; onApply: (code: string) => Promise<void> requestId,
disabled,
onApply,
}: {
requestId: string;
disabled: boolean;
onApply: (code: string) => Promise<void>;
}) { }) {
const [choice, setChoice] = useState<AudioChoice | null>(null) const [choice, setChoice] = useState<AudioChoice | null>(null);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
useEffect(() => { useEffect(() => {
const controller = new AbortController() void revision;
const controller = new AbortController();
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal }) void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
.then(async response => { if (!response.ok) throw new Error('Could not check the audio settings. Reload the request to try again.'); return response.json() }) .then(async (response) => {
.then(data => { if (!controller.signal.aborted) setChoice(data) }) if (!response.ok) throw new Error("Could not check the audio settings. Reload the request to try again.");
.catch(e => { if (!controller.signal.aborted) setError(e.message) }) return response.json();
return () => controller.abort() })
}, [requestId, revision]) .then((data) => {
if (error && !choice) return <p role="alert">{error}</p> if (!controller.signal.aborted) setChoice(data);
if (!choice?.language) return null })
const code = choice.language.code .catch((e) => {
const name = new Intl.DisplayNames(['en'], { type: 'language' }).of(code) || code if (!controller.signal.aborted) setError(e.message);
return <section className="request-language-notice" aria-label="Audio language"> });
<h2>{name} audio {choice.originalEnabled ? 'enabled' : 'may need your approval'}</h2> return () => controller.abort();
<p>This movie was originally made in {name}. An English dub may not exist. {choice.originalEnabled ? 'Radarr is set to accept its original audio.' : `The current audio requirement is ${choice.profileLanguage || 'set by the library'}. This can leave the request waiting even when an original-language release exists.`}</p> }, [requestId, revision]);
<p>Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not guaranteed by title metadata.</p> if (error && !choice) return <p role="alert">{error}</p>;
{choice.canChange && !choice.originalEnabled && <button type="button" disabled={disabled} onClick={async () => { if (!choice?.language) return null;
setError(null) const code = choice.language.code;
try { await onApply(code); setRevision(v => v + 1) } catch (e) { setError(e instanceof Error ? e.message : 'The audio choice could not be saved.') } const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code;
}}>Use {name} audio &amp; search</button>} return (
{error && <p role="alert">{error}</p>} <section className="request-language-notice" aria-label="Audio language">
</section> <h2>
{name} audio {choice.originalEnabled ? "enabled" : "may need your approval"}
</h2>
<p>
This movie was originally made in {name}. An English dub may not exist.{" "}
{choice.originalEnabled
? "Radarr is set to accept its original audio."
: `The current audio requirement is ${choice.profileLanguage || "set by the library"}. This can leave the request waiting even when an original-language release exists.`}
</p>
<p>
Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not
guaranteed by title metadata.
</p>
{choice.canChange && !choice.originalEnabled && (
<button
type="button"
disabled={disabled}
onClick={async () => {
setError(null);
try {
await onApply(code);
setRevision((v) => v + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "The audio choice could not be saved.");
}
}}
>
Use {name} audio &amp; search
</button>
)}
{error && <p role="alert">{error}</p>}
</section>
);
} }
File diff suppressed because it is too large Load Diff
+86 -75
View File
@@ -1,103 +1,100 @@
'use client' "use client";
import { Suspense, useEffect, useState } from 'react' import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from "next/navigation";
import AuthLayout from '../ui/AuthLayout' import AuthLayout from "../ui/AuthLayout";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
type ResetVerification = { type ResetVerification = {
status: string status: string;
recipient_hint?: string recipient_hint?: string;
auth_provider?: string auth_provider?: string;
expires_at?: string expires_at?: string;
} };
function ResetPasswordPageContent() { function ResetPasswordPageContent() {
const router = useRouter() const router = useRouter();
const searchParams = useSearchParams() const searchParams = useSearchParams();
const token = searchParams.get('token') ?? '' const token = searchParams.get("token") ?? "";
const [verification, setVerification] = useState<ResetVerification | null>(null) const [verification, setVerification] = useState<ResetVerification | null>(null);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(true) const [verifying, setVerifying] = useState(true);
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const verifyToken = async () => { const verifyToken = async () => {
if (!token) { if (!token) {
setError('Password reset link is invalid or missing.') setError("Password reset link is invalid or missing.");
setVerifying(false) setVerifying(false);
return return;
} }
setVerifying(true) setVerifying(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch( const response = await fetch(`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`);
`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`, const data = await response.json().catch(() => null);
)
const data = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.') throw new Error(typeof data?.detail === "string" ? data.detail : "Password reset link is invalid.");
} }
setVerification(data) setVerification(data);
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setVerification(null) setVerification(null);
setError(err instanceof Error ? err.message : 'Password reset link is invalid.') setError(err instanceof Error ? err.message : "Password reset link is invalid.");
} finally { } finally {
setVerifying(false) setVerifying(false);
} }
} };
void verifyToken() void verifyToken();
}, [token]) }, [token]);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
if (!token) { if (!token) {
setError('Password reset link is invalid or missing.') setError("Password reset link is invalid or missing.");
return return;
} }
if (password.trim().length < 8) { if (password.trim().length < 8) {
setError('Password must be at least 8 characters.') setError("Password must be at least 8 characters.");
return return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match.') setError("Passwords do not match.");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/reset`, { const response = await fetch(`${baseUrl}/auth/password/reset`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, new_password: password }), body: JSON.stringify({ token, new_password: password }),
}) });
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.') throw new Error(typeof data?.detail === "string" ? data.detail : "Unable to reset password.");
} }
setStatus('Password updated. You can now sign in with the new password.') setStatus("Password updated. You can now sign in with the new password.");
setPassword('') setPassword("");
setConfirmPassword('') setConfirmPassword("");
window.setTimeout(() => router.push('/login'), 1200) window.setTimeout(() => router.push("/login"), 1200);
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Unable to reset password.') setError(err instanceof Error ? err.message : "Unable to reset password.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
const providerLabel = const providerLabel = verification?.auth_provider === "jellyfin" ? "Jellyfin, Seerr, and Magent" : "Magent";
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
return ( return (
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters."> <AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
@@ -105,8 +102,8 @@ function ResetPasswordPageContent() {
{verifying && <div className="status-banner">Checking password reset link</div>} {verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && ( {!verifying && verification && (
<div className="status-banner"> <div className="status-banner">
This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password This reset link was sent to {verification.recipient_hint || "your email"} and will update the password used
used for {providerLabel}. for {providerLabel}.
</div> </div>
)} )}
<label> <label>
@@ -129,25 +126,39 @@ function ResetPasswordPageContent() {
disabled={!verification || loading} disabled={!verification || loading}
/> />
</label> </label>
{error && <div className="account-notice is-error" role="alert">{error}</div>} {error && (
{status && <div className="account-notice is-status" role="status">{status}</div>} <div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions"> <div className="auth-actions">
<button type="submit" className="account-primary" disabled={loading || verifying || !verification}> <button type="submit" className="account-primary" disabled={loading || verifying || !verification}>
{loading ? 'Updating password…' : 'Reset password'} {loading ? "Updating password…" : "Reset password"}
</button> </button>
</div> </div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}> <button type="button" className="ghost-button" onClick={() => router.push("/login")} disabled={loading}>
Back to sign in Back to sign in
</button> </button>
</form> </form>
</AuthLayout> </AuthLayout>
) );
} }
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<Suspense fallback={<AuthLayout title="Reset password" description="Choose a new password for your account."><p role="status">Checking your reset link</p></AuthLayout>}> <Suspense
fallback={
<AuthLayout title="Reset password" description="Choose a new password for your account.">
<p role="status">Checking your reset link</p>
</AuthLayout>
}
>
<ResetPasswordPageContent /> <ResetPasswordPageContent />
</Suspense> </Suspense>
) );
} }
+154 -117
View File
@@ -1,139 +1,146 @@
'use client' "use client";
import { Suspense, useEffect, useMemo, useState } from 'react' import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from "next/navigation";
import AuthLayout from '../ui/AuthLayout' import AuthLayout from "../ui/AuthLayout";
import { clearToken, getApiBase, setToken } from '../lib/auth' import { clearToken, getApiBase, setToken } from "../lib/auth";
type InviteInfo = { type InviteInfo = {
code: string code: string;
email_bound?: boolean email_bound?: boolean;
label?: string | null label?: string | null;
description?: string | null description?: string | null;
enabled: boolean enabled: boolean;
is_expired?: boolean is_expired?: boolean;
is_usable?: boolean is_usable?: boolean;
expires_at?: string | null expires_at?: string | null;
max_uses?: number | null max_uses?: number | null;
use_count?: number | null use_count?: number | null;
remaining_uses?: number | null remaining_uses?: number | null;
profile?: { profile?: {
id: number id: number;
name: string name: string;
description?: string | null description?: string | null;
} | null } | null;
} };
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
if (!value) return 'Never' if (!value) return "Never";
const date = new Date(value) const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString() return date.toLocaleString();
} };
function SignupPageContent() { function SignupPageContent() {
const router = useRouter() const router = useRouter();
const searchParams = useSearchParams() const searchParams = useSearchParams();
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '') const [inviteCode, setInviteCode] = useState(searchParams.get("code") ?? "");
const [invite, setInvite] = useState<InviteInfo | null>(null) const [invite, setInvite] = useState<InviteInfo | null>(null);
const [inviteLoading, setInviteLoading] = useState(false) const [inviteLoading, setInviteLoading] = useState(false);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [email, setEmail] = useState('') const [email, setEmail] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading) return Boolean(
}, [invite, email, username, password, loading, inviteLoading]) invite?.is_usable &&
(invite.email_bound || email.trim()) &&
username.trim() &&
password &&
!loading &&
!inviteLoading,
);
}, [invite, email, username, password, loading, inviteLoading]);
const lookupInvite = async (code: string) => { const lookupInvite = useCallback(async (code: string) => {
const trimmed = code.trim() const trimmed = code.trim();
if (!trimmed) { if (!trimmed) {
setInvite(null) setInvite(null);
return return;
} }
setInviteLoading(true) setInviteLoading(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`) const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`);
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || 'Invite not found') throw new Error(text || "Invite not found");
} }
const data = await response.json() const data = await response.json();
setInvite(data?.invite ?? null) setInvite(data?.invite ?? null);
setStatus('Invite loaded.') setStatus("Invite loaded.");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setInvite(null) setInvite(null);
setError('Invite code not found or unavailable.') setError("Invite code not found or unavailable.");
} finally { } finally {
setInviteLoading(false) setInviteLoading(false);
} }
} }, []);
useEffect(() => { useEffect(() => {
const initialCode = searchParams.get('code') ?? '' const initialCode = searchParams.get("code") ?? "";
if (initialCode) { if (initialCode) {
setInviteCode(initialCode) setInviteCode(initialCode);
void lookupInvite(initialCode) void lookupInvite(initialCode);
} }
}, [searchParams]) }, [lookupInvite, searchParams]);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match.') setError("Passwords do not match.");
return return;
} }
if (!inviteCode.trim()) { if (!inviteCode.trim()) {
setError('Invite code is required.') setError("Invite code is required.");
return return;
} }
if (!invite?.is_usable) { if (!invite?.is_usable) {
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.') setError("Invite is not usable. Refresh invite details or ask an admin for a new code.");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
clearToken() clearToken();
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/signup`, { const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
invite_code: inviteCode, invite_code: inviteCode,
username: username.trim(), username: username.trim(),
...(!invite.email_bound ? { email: email.trim() } : {}), ...(!invite.email_bound ? { email: email.trim() } : {}),
password, password,
}), }),
}) });
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || 'Sign-up failed') throw new Error(text || "Sign-up failed");
} }
const data = await response.json() const data = await response.json();
if (data?.authenticated) { if (data?.authenticated) {
setToken('cookie') setToken("cookie");
window.location.href = '/welcome' window.location.href = "/welcome";
return return;
} }
throw new Error('Sign-up did not complete') throw new Error("Sign-up did not complete");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Unable to create account.') setError(err instanceof Error ? err.message : "Unable to create account.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
return ( return (
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."> <AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
@@ -143,7 +150,11 @@ function SignupPageContent() {
<div className="invite-lookup-row"> <div className="invite-lookup-row">
<input <input
value={inviteCode} value={inviteCode}
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }} onChange={(e) => {
setInviteCode(e.target.value);
setInvite(null);
setEmail("");
}}
placeholder="Paste your invite code" placeholder="Paste your invite code"
autoCapitalize="characters" autoCapitalize="characters"
/> />
@@ -153,38 +164,50 @@ function SignupPageContent() {
disabled={inviteLoading} disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)} onClick={() => void lookupInvite(inviteCode)}
> >
{inviteLoading ? 'Checking…' : 'Check invite'} {inviteLoading ? "Checking…" : "Check invite"}
</button> </button>
</div> </div>
</label> </label>
{invite && ( {invite && (
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}> <div className={`invite-summary ${invite.is_usable ? "" : "is-disabled"}`}>
<div className="invite-summary-row"> <div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong> <strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}> <span className={`small-pill ${invite.is_usable ? "" : "is-muted"}`}>
{invite.is_usable ? 'Ready' : 'Unavailable'} {invite.is_usable ? "Ready" : "Unavailable"}
</span> </span>
</div> </div>
{invite.description && <p>{invite.description}</p>} {invite.description && <p>{invite.description}</p>}
<details className="auth-invite-details"><summary>Invite details</summary><div className="admin-meta-row"> <details className="auth-invite-details">
<span>Code: {invite.code}</span> <summary>Invite details</summary>
<span>Expires: {formatDate(invite.expires_at)}</span> <div className="admin-meta-row">
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span> <span>Code: {invite.code}</span>
<span>Profile: {invite.profile?.name || 'None'}</span> <span>Expires: {formatDate(invite.expires_at)}</span>
</div></details> <span>Remaining uses: {invite.remaining_uses ?? "Unlimited"}</span>
<span>Profile: {invite.profile?.name || "None"}</span>
</div>
</details>
</div> </div>
)} )}
{invite?.email_bound ? <p className="account-hint">Your account will use the email address this invitation was sent to. This invitation can be used once.</p> : <label> {invite?.email_bound ? (
Email address <p className="account-hint">
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" /> Your account will use the email address this invitation was sent to. This invitation can be used once.
</label>} </p>
) : (
<label>
Email address
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="you@example.com"
/>
</label>
)}
<label> <label>
Username Username
<input <input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label> </label>
<label> <label>
Password Password
@@ -204,25 +227,39 @@ function SignupPageContent() {
autoComplete="new-password" autoComplete="new-password"
/> />
</label> </label>
{error && <div className="account-notice is-error" role="alert">{error}</div>} {error && (
{status && <div className="account-notice is-status" role="status">{status}</div>} <div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions"> <div className="auth-actions">
<button type="submit" className="account-primary" disabled={!canSubmit}> <button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'} {loading ? "Creating account…" : "Create account"}
</button> </button>
</div> </div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}> <button type="button" className="ghost-button" disabled={loading} onClick={() => router.push("/login")}>
Back to sign in Back to sign in
</button> </button>
</form> </form>
</AuthLayout> </AuthLayout>
) );
} }
export default function SignupPage() { export default function SignupPage() {
return ( return (
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up</p></AuthLayout>}> <Suspense
fallback={
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<p role="status">Loading sign-up</p>
</AuthLayout>
}
>
<SignupPageContent /> <SignupPageContent />
</Suspense> </Suspense>
) );
} }
+48
View File
@@ -0,0 +1,48 @@
:root,
[data-theme='dark'],
[data-theme='light'] {
color-scheme: dark;
--ops-bg: #131315;
--ops-bg-2: #0e0e10;
--ops-panel: #1c1b1d;
--ops-panel-2: #201f21;
--ops-panel-3: #2a2a2c;
--ops-line: #46464d;
--ops-line-soft: rgba(145, 144, 152, 0.24);
--ops-text: #e5e1e4;
--ops-muted: #c7c5ce;
--ops-faint: #919098;
--ops-primary: #090d25;
--ops-primary-2: #c2c4e5;
--ops-cyan: #22d3ee;
--ops-cyan-2: #3b82f6;
--ops-coral: #ffb5a0;
--ops-green: #14b8a6;
--ops-red: #ef4444;
--ops-warn: #f59e0b;
--ops-radius-sm: 4px;
--ops-radius: 8px;
--ops-radius-lg: 12px;
--workspace-width: 1440px;
--workspace-gutter: 32px;
--workspace-gap: 24px;
--ink: var(--ops-text);
--ink-muted: var(--ops-muted);
--paper: var(--ops-bg);
--paper-strong: var(--ops-panel);
--accent: var(--ops-coral);
--accent-2: var(--ops-primary);
--accent-3: var(--ops-cyan);
--border: var(--ops-line-soft);
--shadow: transparent;
--glow: 0 0 0 1px rgba(126, 215, 255, 0.16);
--input-bg: #0e0e10;
--input-ink: var(--ops-text);
--line: var(--ops-line-soft);
--panel: var(--ops-panel);
--panel-soft: rgba(255, 255, 255, 0.035);
--text: var(--ops-text);
--muted: var(--ops-muted);
--error-bg: rgba(122, 36, 53, 0.44);
--error-ink: #ffd6d6;
}
+252 -243
View File
@@ -1,137 +1,135 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type DiagnosticCatalogItem = { type DiagnosticCatalogItem = {
key: string key: string;
label: string label: string;
category: string category: string;
description: string description: string;
live_safe: boolean live_safe: boolean;
target: string | null target: string | null;
configured: boolean configured: boolean;
config_status: string config_status: string;
config_detail: string config_detail: string;
} };
type DiagnosticResult = { type DiagnosticResult = {
key: string key: string;
label: string label: string;
category: string category: string;
description: string description: string;
target: string | null target: string | null;
live_safe: boolean live_safe: boolean;
configured: boolean configured: boolean;
status: string status: string;
message: string message: string;
detail?: unknown detail?: unknown;
checked_at?: string checked_at?: string;
duration_ms?: number duration_ms?: number;
} };
type DiagnosticsResponse = { type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[] checks: DiagnosticCatalogItem[];
categories: string[] categories: string[];
generated_at: string generated_at: string;
} };
type RunDiagnosticsResponse = { type RunDiagnosticsResponse = {
results: DiagnosticResult[] results: DiagnosticResult[];
summary: { summary: {
total: number total: number;
up: number up: number;
down: number down: number;
degraded: number degraded: number;
not_configured: number not_configured: number;
disabled: number disabled: number;
} };
checked_at: string checked_at: string;
} };
type RunMode = 'safe' | 'all' | 'single' type RunMode = "safe" | "all" | "single";
type AdminDiagnosticsPanelProps = { type AdminDiagnosticsPanelProps = {
embedded?: boolean embedded?: boolean;
} };
type DatabaseDiagnosticDetail = { type DatabaseDiagnosticDetail = {
integrity_check?: string integrity_check?: string;
database_path?: string database_path?: string;
database_size_bytes?: number database_size_bytes?: number;
wal_size_bytes?: number wal_size_bytes?: number;
shm_size_bytes?: number shm_size_bytes?: number;
page_size_bytes?: number page_size_bytes?: number;
page_count?: number page_count?: number;
freelist_pages?: number freelist_pages?: number;
allocated_bytes?: number allocated_bytes?: number;
free_bytes?: number free_bytes?: number;
row_counts?: Record<string, number> row_counts?: Record<string, number>;
timings_ms?: Record<string, number> timings_ms?: Record<string, number>;
} };
const REFRESH_INTERVAL_MS = 30000 const REFRESH_INTERVAL_MS = 30000;
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
idle: 'Ready', idle: "Ready",
up: 'Up', up: "Up",
down: 'Down', down: "Down",
degraded: 'Degraded', degraded: "Degraded",
disabled: 'Disabled', disabled: "Disabled",
not_configured: 'Not configured', not_configured: "Not configured",
} };
function formatCheckedAt(value?: string) { function formatCheckedAt(value?: string) {
if (!value) return 'Not yet run' if (!value) return "Not yet run";
const parsed = new Date(value) const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString() return parsed.toLocaleString();
} }
function formatDuration(value?: number) { function formatDuration(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) { if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return 'Pending' return "Pending";
} }
return `${value.toFixed(1)} ms` return `${value.toFixed(1)} ms`;
} }
function statusLabel(status: string) { function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status return STATUS_LABELS[status] ?? status;
} }
function formatBytes(value?: number) { function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) { if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return '0 B' return "0 B";
} }
if (value >= 1024 * 1024 * 1024) { if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB` return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
if (value >= 1024 * 1024) { if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB` return `${(value / (1024 * 1024)).toFixed(2)} MB`;
} }
if (value >= 1024) { if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB` return `${(value / 1024).toFixed(1)} KB`;
} }
return `${value} B` return `${value} B`;
} }
function formatDetailLabel(value: string) { function formatDetailLabel(value: string) {
return value return value.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
.replace(/_/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
} }
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null { function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) { if (!detail || typeof detail !== "object" || Array.isArray(detail)) {
return null return null;
} }
return detail as DatabaseDiagnosticDetail return detail as DatabaseDiagnosticDetail;
} }
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) { function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) { if (values.length === 0) {
return null return null;
} }
return ( return (
<div className="diagnostic-detail-group"> <div className="diagnostic-detail-group">
@@ -145,153 +143,157 @@ function renderDatabaseMetricGroup(title: string, values: Array<[string, string]
))} ))}
</div> </div>
</div> </div>
) );
} }
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) { export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter() const router = useRouter();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false) const [authorized, setAuthorized] = useState(false);
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]) const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]);
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({}) const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({});
const [runningKeys, setRunningKeys] = useState<string[]>([]) const [runningKeys, setRunningKeys] = useState<string[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true) const [autoRefresh, setAutoRefresh] = useState(true);
const [pageError, setPageError] = useState('') const [pageError, setPageError] = useState("");
const [lastRunAt, setLastRunAt] = useState<string | null>(null) const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null) const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null);
const [emailRecipient, setEmailRecipient] = useState('') const [emailRecipient, setEmailRecipient] = useState("");
const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key) const liveSafeKeys = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
async function runDiagnostics(keys?: string[], mode: RunMode = 'single') { const runDiagnostics = useCallback(
const baseUrl = getApiBase() async (keys?: string[], mode: RunMode = "single") => {
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key) const baseUrl = getApiBase();
if (effectiveKeys.length === 0) { const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key);
return if (effectiveKeys.length === 0) {
} return;
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
setPageError('')
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
} }
if (!response.ok) { setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])));
const text = await response.text() setPageError("");
throw new Error(text || `Diagnostics run failed: ${response.status}`) try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
});
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Diagnostics run failed: ${response.status}`);
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse;
const nextResults: Record<string, DiagnosticResult> = {};
for (const result of data.results ?? []) {
nextResults[result.key] = result;
}
setResultsByKey((current) => ({ ...current, ...nextResults }));
setLastRunAt(data.checked_at ?? new Date().toISOString());
setLastRunMode(mode);
} catch (error) {
console.error(error);
setPageError(error instanceof Error ? error.message : "Diagnostics run failed.");
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)));
} }
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse },
const nextResults: Record<string, DiagnosticResult> = {} [checks, emailRecipient, router],
for (const result of data.results ?? []) { );
nextResults[result.key] = result
}
setResultsByKey((current) => ({ ...current, ...nextResults }))
setLastRunAt(data.checked_at ?? new Date().toISOString())
setLastRunMode(mode)
} catch (error) {
console.error(error)
setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
useEffect(() => { useEffect(() => {
let active = true let active = true;
const loadPage = async () => { const loadPage = async () => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const authResponse = await authFetch(`${baseUrl}/auth/me`) const authResponse = await authFetch(`${baseUrl}/auth/me`);
if (!authResponse.ok) { if (!authResponse.ok) {
if (authResponse.status === 401) { if (authResponse.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
router.push('/') router.push("/");
return return;
} }
const me = await authResponse.json() const me = await authResponse.json();
if (!active) return if (!active) return;
if (me?.role !== 'admin') { if (me?.role !== "admin") {
router.push('/') router.push("/");
return return;
} }
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`) const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`);
if (!diagnosticsResponse.ok) { if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text() const text = await diagnosticsResponse.text();
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`) throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`);
} }
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse;
if (!active) return if (!active) return;
setChecks(data.checks ?? []) setChecks(data.checks ?? []);
setAuthorized(true) setAuthorized(true);
setLoading(false) setLoading(false);
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key) const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key);
if (safeKeys.length > 0) { if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, 'safe') void runDiagnostics(safeKeys, "safe");
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error);
if (!active) return if (!active) return;
setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.') setPageError(error instanceof Error ? error.message : "Unable to load diagnostics.");
setLoading(false) setLoading(false);
} }
} };
void loadPage() void loadPage();
return () => { return () => {
active = false active = false;
} };
}, [router]) }, [router]);
useEffect(() => { useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) { if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return return;
} }
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, 'safe') void runDiagnostics(liveSafeKeys, "safe");
}, REFRESH_INTERVAL_MS) }, REFRESH_INTERVAL_MS);
return () => { return () => {
window.clearInterval(interval) window.clearInterval(interval);
} };
}, [authorized, autoRefresh, liveSafeKeys.join('|')]) }, [authorized, autoRefresh, liveSafeKeys, runDiagnostics]);
if (loading) { if (loading) {
return <div className="admin-panel">Loading diagnostics...</div> return <div className="admin-panel">Loading diagnostics...</div>;
} }
if (!authorized) { if (!authorized) {
return null return null;
} }
const orderedCategories: string[] = [] const orderedCategories: string[] = [];
for (const check of checks) { for (const check of checks) {
if (!orderedCategories.includes(check.category)) { if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category) orderedCategories.push(check.category);
} }
} }
const mergedResults = checks.map((check) => { const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key] const result = resultsByKey[check.key];
if (result) { if (result) {
return result return result;
} }
return { return {
key: check.key, key: check.key,
@@ -301,12 +303,12 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
target: check.target, target: check.target,
live_safe: check.live_safe, live_safe: check.live_safe,
configured: check.configured, configured: check.configured,
status: check.configured ? 'idle' : check.config_status, status: check.configured ? "idle" : check.config_status,
message: check.configured ? 'Ready to test.' : check.config_detail, message: check.configured ? "Ready to test." : check.config_detail,
checked_at: undefined, checked_at: undefined,
duration_ms: undefined, duration_ms: undefined,
} satisfies DiagnosticResult } satisfies DiagnosticResult;
}) });
const summary = { const summary = {
total: mergedResults.length, total: mergedResults.length,
@@ -316,47 +318,46 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
disabled: 0, disabled: 0,
not_configured: 0, not_configured: 0,
idle: 0, idle: 0,
} };
for (const result of mergedResults) { for (const result of mergedResults) {
const key = result.status as keyof typeof summary const key = result.status as keyof typeof summary;
if (key in summary) { if (key in summary) {
summary[key] += 1 summary[key] += 1;
} }
} }
return ( return (
<div className={`diagnostics-page${embedded ? ' diagnostics-page-embedded' : ''}`}> <div className={`diagnostics-page${embedded ? " diagnostics-page-embedded" : ""}`}>
<div className="admin-panel diagnostics-control-panel"> <div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy"> <div className="diagnostics-control-copy">
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2> <h2>{embedded ? "Connectivity diagnostics" : "Control center"}</h2>
<p className="lede"> <p className="lede">
Check Magent and your connected services. Automatic refresh runs health checks only. Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
Test messages are managed in Notifications below. managed in Notifications below.
</p> </p>
</div> </div>
<div className="diagnostics-control-actions"> <div className="diagnostics-control-actions">
<button <button
type="button" type="button"
className={autoRefresh ? 'is-active' : ''} className={autoRefresh ? "is-active" : ""}
onClick={() => setAutoRefresh((current) => !current)} onClick={() => setAutoRefresh((current) => !current)}
> >
{autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'} {autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
void runDiagnostics(liveSafeKeys, 'safe') void runDiagnostics(liveSafeKeys, "safe");
}} }}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0} disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
> >
Run live checks Run live checks
</button> </button>
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}> <span className={`small-pill ${autoRefresh ? "is-positive" : ""}`}>
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'} {autoRefresh ? "Auto refresh on" : "Auto refresh off"}
</span> </span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}</span> <span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : "No run yet"}</span>
</div> </div>
</div> </div>
@@ -385,39 +386,47 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<span>Not configured</span> <span>Not configured</span>
<strong>{summary.not_configured}</strong> <strong>{summary.not_configured}</strong>
</div> </div>
<div className="diagnostics-inline-last-run"> <div className="diagnostics-inline-last-run">Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}</div>
Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
</div>
</div> </div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null} {pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => { {orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category) const categoryChecks = mergedResults.filter((check) => check.category === category);
return ( return (
<div key={category} className="admin-panel diagnostics-category-panel"> <div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header"> <div className="diagnostics-category-header">
<div> <div>
<h2>{category}</h2> <h2>{category}</h2>
<p>{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}</p> <p>
{category === "Notifications" ? "These tests can emit real messages." : "Safe live health checks."}
</p>
</div> </div>
<span className="small-pill">{categoryChecks.length} checks</span> <span className="small-pill">{categoryChecks.length} checks</span>
</div> </div>
{category === 'Notifications' && ( {category === "Notifications" && (
<div className="diagnostics-notification-controls"> <div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient"> <label className="diagnostics-email-recipient">
<span>Test email recipient</span> <span>Test email recipient</span>
<input <input
type="email" type="email"
placeholder="Leave blank to use configured sender" placeholder="Leave blank to use configured sender"
value={emailRecipient} value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)} onChange={(event) => setEmailRecipient(event.target.value)}
/> />
</label> </label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p> <p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button type="button" disabled={runningKeys.length > 0 || categoryChecks.length === 0} <button
onClick={() => void runDiagnostics(categoryChecks.map(check => check.key), 'all')}> type="button"
disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() =>
void runDiagnostics(
categoryChecks.map((check) => check.key),
"all",
)
}
>
Test all notification channels Test all notification channels
</button> </button>
</div> </div>
@@ -425,7 +434,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<div className="diagnostics-grid"> <div className="diagnostics-grid">
{categoryChecks.map((check) => { {categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key) const isRunning = runningKeys.includes(check.key);
return ( return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}> <article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top"> <div className="diagnostic-card-top">
@@ -440,18 +449,18 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
type="button" type="button"
className="system-test" className="system-test"
onClick={() => { onClick={() => {
void runDiagnostics([check.key], 'single') void runDiagnostics([check.key], "single");
}} }}
disabled={isRunning} disabled={isRunning}
> >
{check.live_safe ? 'Ping' : 'Send test'} {check.live_safe ? "Ping" : "Send test"}
</button> </button>
</div> </div>
<div className="diagnostic-meta-grid"> <div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item"> <div className="diagnostic-meta-item">
<span>Target</span> <span>Target</span>
<strong>{check.target || 'Not set'}</strong> <strong>{check.target || "Not set"}</strong>
</div> </div>
<div className="diagnostic-meta-item"> <div className="diagnostic-meta-item">
<span>Latency</span> <span>Latency</span>
@@ -459,7 +468,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
</div> </div>
<div className="diagnostic-meta-item"> <div className="diagnostic-meta-item">
<span>Mode</span> <span>Mode</span>
<strong>{check.live_safe ? 'Live safe' : 'Manual only'}</strong> <strong>{check.live_safe ? "Live safe" : "Manual only"}</strong>
</div> </div>
<div className="diagnostic-meta-item"> <div className="diagnostic-meta-item">
<span>Last checked</span> <span>Last checked</span>
@@ -469,53 +478,53 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<div className={`diagnostic-message diagnostic-message-${check.status}`}> <div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" /> <span className="system-dot" />
<span>{isRunning ? 'Running diagnostic...' : check.message}</span> <span>{isRunning ? "Running diagnostic..." : check.message}</span>
</div> </div>
{check.key === 'database' {check.key === "database"
? (() => { ? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail) const detail = asDatabaseDiagnosticDetail(check.detail);
if (!detail) { if (!detail) {
return null return null;
} }
return ( return (
<details className="diagnostic-detail-panel"> <details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary> <summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup('Storage', [ {renderDatabaseMetricGroup("Storage", [
['Database file', formatBytes(detail.database_size_bytes)], ["Database file", formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)], ["WAL file", formatBytes(detail.wal_size_bytes)],
['Shared memory', formatBytes(detail.shm_size_bytes)], ["Shared memory", formatBytes(detail.shm_size_bytes)],
['Allocated bytes', formatBytes(detail.allocated_bytes)], ["Allocated bytes", formatBytes(detail.allocated_bytes)],
['Free bytes', formatBytes(detail.free_bytes)], ["Free bytes", formatBytes(detail.free_bytes)],
['Page size', formatBytes(detail.page_size_bytes)], ["Page size", formatBytes(detail.page_size_bytes)],
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`], ["Page count", `${detail.page_count?.toLocaleString() ?? 0}`],
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`], ["Freelist pages", `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])} ])}
{renderDatabaseMetricGroup( {renderDatabaseMetricGroup(
'Tables', "Tables",
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [ Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key), formatDetailLabel(key),
value.toLocaleString(), value.toLocaleString(),
]), ]),
)} )}
{renderDatabaseMetricGroup( {renderDatabaseMetricGroup(
'Timings', "Timings",
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [ Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key), formatDetailLabel(key),
`${value.toFixed(1)} ms`, `${value.toFixed(1)} ms`,
]), ]),
)} )}
</details> </details>
) );
})() })()
: null} : null}
</article> </article>
) );
})} })}
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
) );
} }
+17 -12
View File
@@ -1,16 +1,16 @@
'use client' "use client";
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import SettingsNavigation from './SettingsNavigation' import SettingsNavigation from "./SettingsNavigation";
import PageHeading from './PageHeading' import PageHeading from "./PageHeading";
type AdminShellProps = { type AdminShellProps = {
title: string title: string;
subtitle?: string subtitle?: string;
actions?: ReactNode actions?: ReactNode;
rail?: ReactNode rail?: ReactNode;
children: ReactNode children: ReactNode;
} };
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) { export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return ( return (
@@ -19,8 +19,13 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<main className="card admin-card"> <main className="card admin-card">
<PageHeading title={title} description={subtitle} actions={actions} /> <PageHeading title={title} description={subtitle} actions={actions} />
{children} {children}
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>} {rail && (
<details className="admin-supplemental">
<summary>Additional information</summary>
{rail}
</details>
)}
</main> </main>
</div> </div>
) );
} }
+51 -21
View File
@@ -1,25 +1,55 @@
'use client' "use client";
import { usePathname } from 'next/navigation' import { usePathname } from "next/navigation";
import BrandingLogo from './BrandingLogo' import BrandingLogo from "./BrandingLogo";
import HeaderActions from './HeaderActions' import HeaderActions from "./HeaderActions";
import HeaderIdentity from './HeaderIdentity' import HeaderIdentity from "./HeaderIdentity";
import GlobalSearch from './GlobalSearch' import GlobalSearch from "./GlobalSearch";
import SiteStatus from './SiteStatus' import SiteStatus from "./SiteStatus";
import UserViewBanner from './UserViewBanner' import UserViewBanner from "./UserViewBanner";
import WorkspaceNavigation from './WorkspaceNavigation' import WorkspaceNavigation from "./WorkspaceNavigation";
export default function ApplicationChrome() { export default function ApplicationChrome() {
const pathname = usePathname() const pathname = usePathname();
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup', '/email-recaps', '/newsletter-subscription'].includes(pathname)) return null if (
return <> [
<header className="header"> "/welcome",
<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> "/coming-soon",
<div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div> "/login",
<div className="header-nav"><GlobalSearch /><HeaderActions /></div> "/forgot-password",
</header> "/reset-password",
<WorkspaceNavigation /> "/signup",
<UserViewBanner /> "/email-recaps",
<SiteStatus /> "/newsletter-subscription",
</> ].includes(pathname)
)
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">
<GlobalSearch />
<HeaderActions />
</div>
</header>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
);
} }
+24 -10
View File
@@ -1,21 +1,35 @@
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import MagentMark from './MagentMark' import MagentMark from "./MagentMark";
export default function AuthLayout({ title, description, children, footer }: { export default function AuthLayout({
title: string title,
description: string description,
children: ReactNode children,
footer?: ReactNode footer,
}: {
title: string;
description: string;
children: ReactNode;
footer?: ReactNode;
}) { }) {
return ( return (
<main className="login-page"> <main className="login-page">
<section className="login-card" aria-labelledby="login-title"> <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> <div className="login-brand">
<header><h1 id="login-title">{title}</h1><p>{description}</p></header> <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">{title}</h1>
<p>{description}</p>
</header>
{children} {children}
{footer && <footer>{footer}</footer>} {footer && <footer>{footer}</footer>}
</section> </section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p> <p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main> </main>
) );
} }
+10 -10
View File
@@ -1,18 +1,18 @@
'use client' "use client";
import { useEffect } from 'react' import { useEffect } from "react";
export default function BrandingFavicon() { export default function BrandingFavicon() {
useEffect(() => { useEffect(() => {
const href = '/api/branding/favicon.ico' const href = "/api/branding/favicon.ico";
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null;
if (!link) { if (!link) {
link = document.createElement('link') link = document.createElement("link");
link.rel = 'icon' link.rel = "icon";
document.head.appendChild(link) document.head.appendChild(link);
} }
link.href = href link.href = href;
}, []) }, []);
return null return null;
} }
+12 -15
View File
@@ -1,21 +1,21 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
type BrandingLogoProps = { type BrandingLogoProps = {
className?: string className?: string;
alt?: string alt?: string;
} };
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) { export default function BrandingLogo({ className, alt = "Magent logo" }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false);
return ( return (
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}> <span className={`${className ?? ""} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? ( {!failed ? (
<img <img
className={loaded ? 'is-loaded' : undefined} className={loaded ? "is-loaded" : undefined}
src="/api/branding/logo.png" src="/api/branding/logo.png"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
@@ -33,12 +33,9 @@ export default function BrandingLogo({ className, alt = 'Magent logo' }: Brandin
</defs> </defs>
<rect width="64" height="64" rx="12" fill="#0b1328" /> <rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" /> <rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path <path d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z" fill="url(#magentLogoGlow)" />
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
fill="url(#magentLogoGlow)"
/>
</svg> </svg>
) : null} ) : null}
</span> </span>
) );
} }

Some files were not shown because too many files have changed in this diff Show More