Compare commits

...

5 Commits

28 changed files with 2384 additions and 849 deletions

View File

@@ -1 +1 @@
0103262231
0203261953

View File

@@ -4,8 +4,8 @@ from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity
from .security import safe_decode_token, TokenError
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .security import safe_decode_token, TokenError, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
@@ -38,6 +38,42 @@ def _extract_client_ip(request: Request) -> str:
return "unknown"
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if provider != "local":
return provider
password_hash = user.get("password_hash")
if isinstance(password_hash, str) and password_hash:
if verify_password("jellyfin-user", password_hash):
return "jellyfin"
if verify_password("jellyseerr-user", password_hash):
return "jellyseerr"
return provider
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(user, dict):
return {}
resolved_provider = resolve_user_auth_provider(user)
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if resolved_provider != stored_provider:
username = str(user.get("username") or "").strip()
if username:
set_user_auth_provider(username, resolved_provider)
refreshed_user = get_user_by_username(username)
if refreshed_user:
user = refreshed_user
normalized = dict(user)
normalized["auth_provider"] = resolved_provider
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
normalized["password_provider"] = (
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
)
return normalized
def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
@@ -63,6 +99,8 @@ def _load_current_user_from_token(
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user)
if request is not None:
ip = _extract_client_ip(request)
user_agent = request.headers.get("user-agent", "unknown")
@@ -78,6 +116,8 @@ def _load_current_user_from_token(
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"),
}

View File

@@ -1,7 +1,2 @@
BUILD_NUMBER = "0103262231"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
BUILD_NUMBER = "0203261953"
CHANGELOG = '2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit'

View File

@@ -18,6 +18,17 @@ class ApiClient:
def headers(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key} if self.api_key else {}
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
if response is None:
return None
try:
payload = sanitize_value(response.json())
except ValueError:
payload = sanitize_value(response.text)
if isinstance(payload, str) and len(payload) > 500:
return f"{payload[:500]}..."
return payload
async def _request(
self,
method: str,
@@ -60,6 +71,20 @@ class ApiClient:
if not response.content:
return None
return response.json()
except httpx.HTTPStatusError as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response = exc.response
status = response.status_code if response is not None else "unknown"
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
log_fn(
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
method,
url,
status,
duration_ms,
self._response_summary(response),
)
raise
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.exception(

View File

@@ -11,6 +11,11 @@ from .security import hash_password, verify_password
logger = logging.getLogger(__name__)
SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS = 6
SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS = 24
SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS = 30
SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
def _db_path() -> str:
path = settings.sqlite_path or "data/magent.db"
@@ -271,6 +276,22 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS seerr_media_failures (
media_type TEXT NOT NULL,
tmdb_id INTEGER NOT NULL,
status_code INTEGER,
error_message TEXT,
failure_count INTEGER NOT NULL DEFAULT 1,
first_failed_at TEXT NOT NULL,
last_failed_at TEXT NOT NULL,
suppress_until TEXT NOT NULL,
is_persistent INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (media_type, tmdb_id)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -289,6 +310,12 @@ def init_db() -> None:
ON artwork_cache_status (updated_at)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_seerr_media_failures_suppress_until
ON seerr_media_failures (suppress_until)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_activity (
@@ -1371,6 +1398,24 @@ def set_user_password(username: str, password: str) -> None:
)
def sync_jellyfin_password_state(username: str, password: str) -> None:
if not username or not password:
return
password_hash = hash_password(password)
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE users
SET password_hash = ?,
jellyfin_password_hash = ?,
last_jellyfin_auth_at = ?
WHERE username = ? COLLATE NOCASE
""",
(password_hash, password_hash, timestamp, username),
)
def set_jellyfin_auth_cache(username: str, password: str) -> None:
if not username or not password:
return
@@ -2226,6 +2271,154 @@ def get_settings_overrides() -> Dict[str, str]:
return overrides
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_type or not tmdb_id:
return None
normalized_media_type = str(media_type).strip().lower()
try:
normalized_tmdb_id = int(tmdb_id)
except (TypeError, ValueError):
return None
with _connect() as conn:
row = conn.execute(
"""
SELECT media_type, tmdb_id, status_code, error_message, failure_count,
first_failed_at, last_failed_at, suppress_until, is_persistent
FROM seerr_media_failures
WHERE media_type = ? AND tmdb_id = ?
""",
(normalized_media_type, normalized_tmdb_id),
).fetchone()
if not row:
return None
return {
"media_type": row[0],
"tmdb_id": row[1],
"status_code": row[2],
"error_message": row[3],
"failure_count": row[4],
"first_failed_at": row[5],
"last_failed_at": row[6],
"suppress_until": row[7],
"is_persistent": bool(row[8]),
}
def is_seerr_media_failure_suppressed(media_type: Optional[str], tmdb_id: Optional[int]) -> bool:
record = get_seerr_media_failure(media_type, tmdb_id)
if not record:
return False
suppress_until = _parse_datetime_value(record.get("suppress_until"))
if suppress_until and suppress_until > datetime.now(timezone.utc):
return True
clear_seerr_media_failure(media_type, tmdb_id)
return False
def record_seerr_media_failure(
media_type: Optional[str],
tmdb_id: Optional[int],
*,
status_code: Optional[int] = None,
error_message: Optional[str] = None,
) -> Dict[str, Any]:
if not media_type or not tmdb_id:
return {}
normalized_media_type = str(media_type).strip().lower()
normalized_tmdb_id = int(tmdb_id)
now = datetime.now(timezone.utc)
existing = get_seerr_media_failure(normalized_media_type, normalized_tmdb_id)
failure_count = int(existing.get("failure_count", 0)) + 1 if existing else 1
is_persistent = failure_count >= SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD
if is_persistent:
suppress_until = now + timedelta(days=SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS)
elif failure_count >= 2:
suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS)
else:
suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS)
payload = {
"media_type": normalized_media_type,
"tmdb_id": normalized_tmdb_id,
"status_code": status_code,
"error_message": error_message,
"failure_count": failure_count,
"first_failed_at": existing.get("first_failed_at") if existing else now.isoformat(),
"last_failed_at": now.isoformat(),
"suppress_until": suppress_until.isoformat(),
"is_persistent": is_persistent,
}
with _connect() as conn:
conn.execute(
"""
INSERT INTO seerr_media_failures (
media_type,
tmdb_id,
status_code,
error_message,
failure_count,
first_failed_at,
last_failed_at,
suppress_until,
is_persistent
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(media_type, tmdb_id) DO UPDATE SET
status_code = excluded.status_code,
error_message = excluded.error_message,
failure_count = excluded.failure_count,
first_failed_at = excluded.first_failed_at,
last_failed_at = excluded.last_failed_at,
suppress_until = excluded.suppress_until,
is_persistent = excluded.is_persistent
""",
(
payload["media_type"],
payload["tmdb_id"],
payload["status_code"],
payload["error_message"],
payload["failure_count"],
payload["first_failed_at"],
payload["last_failed_at"],
payload["suppress_until"],
1 if payload["is_persistent"] else 0,
),
)
logger.warning(
"seerr_media_failure upsert: media_type=%s tmdb_id=%s status=%s failure_count=%s suppress_until=%s persistent=%s",
payload["media_type"],
payload["tmdb_id"],
payload["status_code"],
payload["failure_count"],
payload["suppress_until"],
payload["is_persistent"],
)
return payload
def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> None:
if not media_type or not tmdb_id:
return
normalized_media_type = str(media_type).strip().lower()
try:
normalized_tmdb_id = int(tmdb_id)
except (TypeError, ValueError):
return
with _connect() as conn:
deleted = conn.execute(
"""
DELETE FROM seerr_media_failures
WHERE media_type = ? AND tmdb_id = ?
""",
(normalized_media_type, normalized_tmdb_id),
).rowcount
if deleted:
logger.info(
"seerr_media_failure cleared: media_type=%s tmdb_id=%s",
normalized_media_type,
normalized_tmdb_id,
)
def run_integrity_check() -> str:
with _connect() as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()

View File

@@ -12,7 +12,13 @@ from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from ..auth import require_admin, get_current_user, require_admin_event_stream
from ..auth import (
require_admin,
get_current_user,
require_admin_event_stream,
normalize_user_auth_provider,
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..db import (
delete_setting,
@@ -40,7 +46,7 @@ from ..db import (
set_user_profile_id,
set_user_expires_at,
set_user_password,
set_jellyfin_auth_cache,
sync_jellyfin_password_state,
set_user_role,
run_integrity_check,
vacuum_db,
@@ -85,6 +91,7 @@ from ..services.invite_email import (
reset_invite_email_template,
save_invite_email_template,
send_test_email,
smtp_email_delivery_warning,
send_templated_email,
smtp_email_config_ready,
)
@@ -1451,7 +1458,8 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_password_clean = new_password.strip()
auth_provider = str(user.get("auth_provider") or "local").lower()
user = normalize_user_auth_provider(user)
auth_provider = resolve_user_auth_provider(user)
if auth_provider == "local":
set_user_password(username, new_password_clean)
return {"status": "ok", "username": username, "provider": "local"}
@@ -1468,7 +1476,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
await client.set_user_password(user_id, new_password_clean)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
set_jellyfin_auth_cache(username, new_password_clean)
sync_jellyfin_password_state(username, new_password_clean)
return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException(
status_code=400,
@@ -1691,11 +1699,12 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
@router.get("/invites/email/templates")
async def get_invite_email_template_settings() -> Dict[str, Any]:
ready, detail = smtp_email_config_ready()
warning = smtp_email_delivery_warning()
return {
"status": "ok",
"email": {
"configured": ready,
"detail": detail,
"detail": warning or detail,
},
"templates": list(get_invite_email_templates().values()),
}

View File

@@ -18,7 +18,6 @@ from ..db import (
get_user_by_username,
get_users_by_username_ci,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
set_user_auth_provider,
get_signup_invite_by_code,
@@ -35,13 +34,14 @@ from ..db import (
get_global_request_leader,
get_global_request_total,
get_setting,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import create_stream_token
from ..auth import get_current_user
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
from ..config import settings
from ..services.user_cache import (
build_jellyseerr_candidate_map,
@@ -599,7 +599,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
save_jellyfin_users_cache(users)
except Exception:
pass
set_jellyfin_auth_cache(canonical_username, password)
sync_jellyfin_password_state(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None:
@@ -781,7 +781,7 @@ async def signup(payload: dict) -> dict:
if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
local_password_value = password_value
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
@@ -838,7 +838,7 @@ async def signup(payload: dict) -> dict:
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
set_jellyfin_auth_cache(username, password_value)
sync_jellyfin_password_state(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
@@ -1129,16 +1129,20 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
username = str(current_user.get("username") or "").strip()
auth_provider = str(current_user.get("auth_provider") or "local").lower()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
new_password_clean = new_password.strip()
stored_user = normalize_user_auth_provider(get_user_by_username(username))
auth_provider = resolve_user_auth_provider(stored_user or current_user)
logger.info("password change requested username=%s provider=%s", username, auth_provider)
if auth_provider == "local":
user = verify_user_password(username, current_password)
if not user:
logger.warning("password change rejected username=%s provider=local reason=invalid-current-password", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(username, new_password_clean)
logger.info("password change completed username=%s provider=local", username)
return {"status": "ok", "provider": "local"}
if auth_provider == "jellyfin":
@@ -1152,6 +1156,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
try:
auth_result = await client.authenticate_by_name(username, current_password)
if not isinstance(auth_result, dict) or not auth_result.get("User"):
logger.warning("password change rejected username=%s provider=jellyfin reason=invalid-current-password", username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
)
@@ -1159,6 +1164,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
raise
except Exception as exc:
detail = _extract_http_error_detail(exc)
logger.warning("password change validation failed username=%s provider=jellyfin detail=%s", username, detail)
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
@@ -1176,13 +1182,15 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
await client.set_user_password(user_id, new_password_clean)
except Exception as exc:
detail = _extract_http_error_detail(exc)
logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin password update failed: {detail}",
) from exc
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
set_jellyfin_auth_cache(username, new_password_clean)
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean)
logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"}
raise HTTPException(

View File

@@ -42,6 +42,9 @@ from ..db import (
set_setting,
update_artwork_cache_stats,
cleanup_history,
is_seerr_media_failure_suppressed,
record_seerr_media_failure,
clear_seerr_media_failure,
)
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import build_snapshot
@@ -50,6 +53,8 @@ router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
FAILED_DETAIL_CACHE_TTL_SECONDS = 3600
_failed_detail_cache: Dict[str, float] = {}
REQUEST_CACHE_TTL_SECONDS = 600
logger = logging.getLogger(__name__)
_sync_state: Dict[str, Any] = {
@@ -100,6 +105,45 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]:
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
_failed_detail_cache.pop(key, None)
def _failure_cache_has(key: str) -> bool:
expires_at = _failed_detail_cache.get(key)
if not expires_at:
return False
if expires_at < time.time():
_failed_detail_cache.pop(key, None)
return False
return True
def _failure_cache_set(key: str, ttl_seconds: int = FAILED_DETAIL_CACHE_TTL_SECONDS) -> None:
_failed_detail_cache[key] = time.time() + ttl_seconds
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
response = exc.response
if response is None:
return None
try:
payload = response.json()
except ValueError:
payload = response.text
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error")
return str(message).strip() if message else json.dumps(payload, ensure_ascii=True)
if isinstance(payload, str):
trimmed = payload.strip()
return trimmed or None
return str(payload)
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
response = exc.response
if response is None:
return False
return response.status_code == 404 or response.status_code >= 500
def _status_label(value: Any) -> str:
@@ -383,9 +427,12 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
cached = _cache_get(cache_key)
if isinstance(cached, dict):
return cached
if _failure_cache_has(cache_key):
return None
try:
fetched = await client.get_request(str(request_id))
except httpx.HTTPStatusError:
_failure_cache_set(cache_key)
return None
if isinstance(fetched, dict):
_cache_set(cache_key, fetched)
@@ -393,54 +440,80 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
return None
async def _get_media_details(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> Optional[Dict[str, Any]]:
if not tmdb_id or not media_type:
return None
normalized_media_type = str(media_type).strip().lower()
if normalized_media_type not in {"movie", "tv"}:
return None
cache_key = f"media:{normalized_media_type}:{int(tmdb_id)}"
cached = _cache_get(cache_key)
if isinstance(cached, dict):
return cached
if is_seerr_media_failure_suppressed(normalized_media_type, int(tmdb_id)):
logger.debug(
"Seerr media hydration suppressed from db: media_type=%s tmdb_id=%s",
normalized_media_type,
tmdb_id,
)
_failure_cache_set(cache_key, ttl_seconds=FAILED_DETAIL_CACHE_TTL_SECONDS)
return None
if _failure_cache_has(cache_key):
return None
try:
if normalized_media_type == "movie":
fetched = await client.get_movie(int(tmdb_id))
else:
fetched = await client.get_tv(int(tmdb_id))
except httpx.HTTPStatusError as exc:
_failure_cache_set(cache_key)
if _should_persist_seerr_media_failure(exc):
record_seerr_media_failure(
normalized_media_type,
int(tmdb_id),
status_code=exc.response.status_code if exc.response is not None else None,
error_message=_extract_http_error_message(exc),
)
return None
if isinstance(fetched, dict):
clear_seerr_media_failure(normalized_media_type, int(tmdb_id))
_cache_set(cache_key, fetched)
return fetched
return None
async def _hydrate_title_from_tmdb(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> tuple[Optional[str], Optional[int]]:
if not tmdb_id or not media_type:
details = await _get_media_details(client, media_type, tmdb_id)
if not isinstance(details, dict):
return None, None
try:
if media_type == "movie":
details = await client.get_movie(int(tmdb_id))
if isinstance(details, dict):
normalized_media_type = str(media_type).strip().lower() if media_type else None
if normalized_media_type == "movie":
title = details.get("title")
release_date = details.get("releaseDate")
year = int(release_date[:4]) if release_date else None
return title, year
if media_type == "tv":
details = await client.get_tv(int(tmdb_id))
if isinstance(details, dict):
if normalized_media_type == "tv":
title = details.get("name") or details.get("title")
first_air = details.get("firstAirDate")
year = int(first_air[:4]) if first_air else None
return title, year
except httpx.HTTPStatusError:
return None, None
return None, None
async def _hydrate_artwork_from_tmdb(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> tuple[Optional[str], Optional[str]]:
if not tmdb_id or not media_type:
details = await _get_media_details(client, media_type, tmdb_id)
if not isinstance(details, dict):
return None, None
try:
if media_type == "movie":
details = await client.get_movie(int(tmdb_id))
if isinstance(details, dict):
return (
details.get("posterPath") or details.get("poster_path"),
details.get("backdropPath") or details.get("backdrop_path"),
)
if media_type == "tv":
details = await client.get_tv(int(tmdb_id))
if isinstance(details, dict):
return (
details.get("posterPath") or details.get("poster_path"),
details.get("backdropPath") or details.get("backdrop_path"),
)
except httpx.HTTPStatusError:
return None, None
return None, None
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:

View File

@@ -3,6 +3,7 @@ from typing import Any, Dict
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
@@ -17,7 +18,7 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or "").strip(),
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
@@ -25,7 +26,7 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
info["changelog"] = (CHANGELOG or "").strip()
return info

View File

@@ -18,7 +18,7 @@ from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import run_integrity_check
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
@@ -302,6 +302,13 @@ async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email)
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
warning = _clean_text(result.get("warning"))
if warning:
return {
"status": "degraded",
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
"detail": result,
}
return {"message": f"Test email sent to {recipient}", "detail": result}
@@ -425,6 +432,7 @@ def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[Diag
push_target = _url_target(runtime.magent_notify_push_base_url)
email_ready, email_detail = smtp_email_config_ready()
email_warning = smtp_email_delivery_warning()
discord_ready, discord_detail = _discord_config_ready(runtime)
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
push_ready, push_detail = _push_config_ready(runtime)
@@ -539,7 +547,7 @@ def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[Diag
description="Sends a live test email using the configured SMTP provider.",
live_safe=False,
configured=email_ready,
config_detail=email_detail,
config_detail=email_warning or email_detail,
target=smtp_target,
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
),

View File

@@ -382,6 +382,20 @@ def smtp_email_config_ready() -> tuple[bool, str]:
return True, "ok"
def smtp_email_delivery_warning() -> Optional[str]:
runtime = get_runtime_settings()
host = _normalize_display_text(runtime.magent_notify_email_smtp_host).lower()
username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
if host.endswith(".mail.protection.outlook.com") and not (username and password):
return (
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
"confirm mailbox delivery. For reliable delivery, use smtp.office365.com:587 with "
"SMTP credentials or configure a verified Exchange relay connector."
)
return None
def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None:
runtime = get_runtime_settings()
host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
@@ -392,6 +406,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name)
use_tls = bool(runtime.magent_notify_email_use_tls)
use_ssl = bool(runtime.magent_notify_email_use_ssl)
delivery_warning = smtp_email_delivery_warning()
if not host or not from_address:
raise RuntimeError("SMTP email settings are incomplete.")
logger.info(
@@ -405,6 +420,8 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
bool(username and password),
subject,
)
if delivery_warning:
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
message = EmailMessage()
message["Subject"] = subject
@@ -515,4 +532,8 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
body_html=body_html,
)
logger.info("SMTP test email sent: recipient=%s", resolved_email)
return {"recipient_email": resolved_email, "subject": subject}
result = {"recipient_email": resolved_email, "subject": subject}
warning = smtp_email_delivery_warning()
if warning:
result["warning"] = warning
return result

View File

@@ -3,6 +3,7 @@ import asyncio
import logging
from datetime import datetime, timezone
from urllib.parse import quote
import httpx
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellyfin import JellyfinClient
@@ -18,6 +19,9 @@ from ..db import (
get_recent_snapshots,
get_setting,
set_setting,
is_seerr_media_failure_suppressed,
record_seerr_media_failure,
clear_seerr_media_failure,
)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
@@ -53,6 +57,59 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
response = exc.response
if response is None:
return None
try:
payload = response.json()
except ValueError:
payload = response.text
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error")
return str(message).strip() if message else str(payload)
if isinstance(payload, str):
trimmed = payload.strip()
return trimmed or None
return str(payload)
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
response = exc.response
if response is None:
return False
return response.status_code == 404 or response.status_code >= 500
async def _get_seerr_media_details(
jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int
) -> Optional[Dict[str, Any]]:
media_type = request_type.value
if media_type not in {"movie", "tv"}:
return None
if is_seerr_media_failure_suppressed(media_type, tmdb_id):
logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id)
return None
try:
if request_type == RequestType.movie:
details = await jellyseerr.get_movie(int(tmdb_id))
else:
details = await jellyseerr.get_tv(int(tmdb_id))
except httpx.HTTPStatusError as exc:
if _should_persist_seerr_media_failure(exc):
record_seerr_media_failure(
media_type,
int(tmdb_id),
status_code=exc.response.status_code if exc.response is not None else None,
error_message=_extract_http_error_message(exc),
)
return None
if isinstance(details, dict):
clear_seerr_media_failure(media_type, int(tmdb_id))
return details
return None
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
return
@@ -300,22 +357,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
if snapshot.title in {None, "", "Unknown"} and allow_remote:
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:
if snapshot.request_type == RequestType.movie:
details = await jellyseerr.get_movie(int(tmdb_id))
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
if isinstance(details, dict):
if snapshot.request_type == RequestType.movie:
snapshot.title = details.get("title") or snapshot.title
release_date = details.get("releaseDate")
snapshot.year = int(release_date[:4]) if release_date else snapshot.year
poster_path = poster_path or details.get("posterPath") or details.get("poster_path")
backdrop_path = (
backdrop_path
or details.get("backdropPath")
or details.get("backdrop_path")
)
elif snapshot.request_type == RequestType.tv:
details = await jellyseerr.get_tv(int(tmdb_id))
if isinstance(details, dict):
snapshot.title = details.get("name") or details.get("title") or snapshot.title
first_air = details.get("firstAirDate")
snapshot.year = int(first_air[:4]) if first_air else snapshot.year
@@ -325,8 +373,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
or details.get("backdropPath")
or details.get("backdrop_path")
)
except Exception:
pass
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
snapshot.artwork = {

View File

@@ -104,7 +104,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.',
requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
site: 'Sitewide banner and version details. The changelog is generated from git history during release builds.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -555,7 +555,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
const generatedSettingKeys = new Set(['site_changelog'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const requestSettingOrder = [
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
@@ -608,7 +609,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
@@ -940,8 +941,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setSectionFeedback((current) => ({
...current,
[sectionGroup.key]: {
tone: 'status',
message: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
tone: data?.warning ? 'error' : 'status',
message: data?.warning
? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
},
}))
return
@@ -1613,11 +1616,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
>
{status && <div className="error-banner">{status}</div>}
{settingsSections.length > 0 ? (
<div className="admin-form">
<div className="admin-form admin-zone-stack">
{settingsSections
.filter(shouldRenderSection)
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section">
<section key={sectionGroup.key} className="admin-section admin-zone">
<div className="section-header">
<h2>
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
@@ -2228,6 +2231,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) : null}
<button
type="button"
className="settings-action-button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
@@ -2236,7 +2240,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
{getSectionTestLabel(sectionGroup.key) ? (
<button
type="button"
className="ghost-button"
className="ghost-button settings-action-button"
onClick={() => void testSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
@@ -2257,7 +2261,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
)}
{showLogs && (
<section className="admin-section" id="logs">
<section className="admin-section admin-zone" id="logs">
<div className="section-header">
<h2>Activity log</h2>
<div className="log-actions">
@@ -2283,7 +2287,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</section>
)}
{showCacheExtras && (
<section className="admin-section" id="cache">
<section className="admin-section admin-zone" id="cache">
<div className="section-header">
<h2>Saved requests (cache)</h2>
</div>
@@ -2312,7 +2316,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</section>
)}
{showMaintenance && (
<section className="admin-section" id="maintenance">
<section className="admin-section admin-zone" id="maintenance">
<div className="section-header">
<h2>Maintenance</h2>
</div>
@@ -2379,7 +2383,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</section>
)}
{showRequestsExtras && (
<section className="admin-section" id="schedules">
<section className="admin-section admin-zone" id="schedules">
<div className="section-header">
<h2>Scheduled tasks</h2>
</div>

View File

@@ -106,9 +106,9 @@ export default function AdminSystemGuidePage() {
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Guide map</span>
<h2>Quick path</h2>
<p>Identity Intake Queue Download Import Playback.</p>
<span className="admin-rail-eyebrow">How it works</span>
<h2>Admin flow map</h2>
<p>Identity Request intake Queue orchestration Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
@@ -116,8 +116,8 @@ export default function AdminSystemGuidePage() {
return (
<AdminShell
title="System guide"
subtitle="Admin-only architecture and operational flow for Magent."
title="How it works"
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
@@ -129,7 +129,8 @@ export default function AdminSystemGuidePage() {
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the exact runtime path for request processing and availability in the current build.
This is the runtime path the platform follows from authentication through to playback
availability.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
@@ -155,6 +156,51 @@ export default function AdminSystemGuidePage() {
</div>
</div>
<div className="admin-panel">
<h2>What each service is responsible for</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Magent</h3>
<p>
Handles authentication, request pages, live event updates, invite workflows,
diagnostics, notifications, and admin operations.
</p>
</article>
<article className="system-guide-card">
<h3>Seerr</h3>
<p>
Stores the request itself and remains the request-state source for approval and
media request metadata.
</p>
</article>
<article className="system-guide-card">
<h3>Jellyfin</h3>
<p>
Provides user sign-in identity and the final playback destination once content is
available.
</p>
</article>
<article className="system-guide-card">
<h3>Sonarr / Radarr</h3>
<p>
Control queue placement, quality-profile decisions, import handling, and release
monitoring.
</p>
</article>
<article className="system-guide-card">
<h3>Prowlarr</h3>
<p>Provides search/indexer coverage for Arr-side release searches.</p>
</article>
<article className="system-guide-card">
<h3>qBittorrent</h3>
<p>
Executes the download and exposes live progress, paused states, and queue
visibility.
</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
@@ -172,19 +218,48 @@ export default function AdminSystemGuidePage() {
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
<p>
Master template, profile assignment, invite access policy, invite emails, and trace
map lineage.
</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Live request page</h3>
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
<h3>Maintenance + diagnostics</h3>
<p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
nuclear flush/resync operations.
</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>User and invite model</h2>
<ol className="system-decision-list">
<li>
Jellyfin is used for sign-in identity and user presence across the platform.
</li>
<li>
Seerr provides request ownership and request-state data for Magent request pages.
</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>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
@@ -205,6 +280,24 @@ export default function AdminSystemGuidePage() {
</li>
</ol>
</div>
<div className="admin-panel">
<h2>Live update surfaces</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent requests and service summaries refresh live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
</article>
</div>
</div>
</section>
</AdminShell>
)

View File

@@ -8,15 +8,42 @@ type SiteInfo = {
changelog?: string
}
const parseChangelog = (raw: string) =>
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
type ChangelogGroup = {
date: string
entries: string[]
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const parseChangelog = (raw: string): ChangelogGroup[] => {
const groups: ChangelogGroup[] = []
for (const rawLine of raw.split('\n')) {
const line = rawLine.trim()
if (!line) continue
const [candidateDate, ...messageParts] = line.split('|')
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
const message = messageParts.join('|').trim()
if (!message) continue
const currentGroup = groups[groups.length - 1]
if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message)
} else {
groups.push({ date: candidateDate, entries: [message] })
}
continue
}
if (groups.length === 0) {
groups.push({ date: 'Updates', entries: [line] })
} else {
groups[groups.length - 1].entries.push(line)
}
}
return groups
}
export default function ChangelogPage() {
const router = useRouter()
const [entries, setEntries] = useState<string[]>([])
const [groups, setGroups] = useState<ChangelogGroup[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -40,11 +67,11 @@ export default function ChangelogPage() {
}
const data: SiteInfo = await response.json()
if (!active) return
setEntries(parseChangelog(data?.changelog ?? ''))
setGroups(parseChangelog(data?.changelog ?? ''))
} catch (err) {
console.error(err)
if (!active) return
setEntries([])
setGroups([])
} finally {
if (active) setLoading(false)
}
@@ -59,17 +86,24 @@ export default function ChangelogPage() {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
}
if (entries.length === 0) {
if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div>
}
return (
<div className="changelog-groups">
{groups.map((group) => (
<section key={group.date} className="changelog-group">
<h2>{group.date}</h2>
<ul className="changelog-list">
{entries.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
{group.entries.map((entry, index) => (
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
))}
</ul>
</section>
))}
</div>
)
}, [entries, loading])
}, [groups, loading])
return (
<div className="page">

View File

@@ -1545,6 +1545,13 @@ button span {
align-items: end;
}
.settings-section-actions .settings-action-button {
width: 190px;
min-width: 190px;
flex: 0 0 190px;
justify-content: center;
}
.settings-inline-field {
display: grid;
gap: 6px;
@@ -2362,6 +2369,31 @@ button span {
font-size: 15px;
}
.changelog-groups {
display: grid;
gap: 18px;
}
.changelog-group {
display: grid;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.changelog-group:first-child {
padding-top: 0;
border-top: 0;
}
.changelog-group h2 {
margin: 0;
font-size: 16px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink);
}
/* -------------------------------------------------------------------------- */
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
/* -------------------------------------------------------------------------- */
@@ -3662,16 +3694,28 @@ button:disabled {
.error-banner,
.status-banner {
border-radius: 12px;
}
.error-banner {
border: 1px solid rgba(244, 114, 114, 0.2);
background: rgba(244, 114, 114, 0.1);
color: var(--error-ink);
}
[data-theme='dark'] .error-banner,
[data-theme='dark'] .status-banner {
.status-banner {
border: 1px solid rgba(74, 222, 128, 0.24);
background: rgba(74, 222, 128, 0.12);
color: #166534;
}
[data-theme='dark'] .error-banner {
color: #ffd9d9;
}
[data-theme='dark'] .status-banner {
color: #dcfce7;
}
.auth-card {
max-width: 520px;
margin-inline: auto;
@@ -5221,6 +5265,26 @@ textarea {
margin-top: 10px;
}
.profile-quick-link-card {
display: flex;
justify-content: space-between;
align-items: start;
gap: 14px;
padding: 12px;
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.018);
border-radius: 6px;
}
.profile-quick-link-card h2 {
margin: 0 0 4px;
}
.profile-quick-link-card .lede {
margin: 0;
}
.profile-invites-section {
display: grid;
gap: 12px;
@@ -5391,6 +5455,10 @@ textarea {
}
@media (max-width: 980px) {
.profile-quick-link-card {
display: grid;
}
.profile-invites-layout {
grid-template-columns: 1fr;
}
@@ -6070,3 +6138,370 @@ textarea {
grid-template-columns: 1fr;
}
}
/* Final responsive admin shell stabilization */
.admin-shell,
.admin-shell-nav,
.admin-card,
.admin-shell-rail,
.admin-sidebar,
.admin-panel {
min-width: 0;
}
@media (max-width: 1280px) {
.admin-shell {
grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
grid-template-areas:
"nav main"
"nav rail";
align-items: start;
}
.admin-shell-nav {
grid-area: nav;
}
.admin-card {
grid-area: main;
grid-column: auto;
}
.admin-shell-rail {
grid-area: rail;
grid-column: auto;
position: static;
top: auto;
width: 100%;
}
}
@media (max-width: 1080px) {
.page {
width: min(100%, calc(100vw - 12px));
max-width: none;
padding-inline: 6px;
}
.card,
.admin-card {
padding: 20px;
}
.admin-shell {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"nav"
"main"
"rail";
gap: 16px;
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
grid-column: auto;
width: 100%;
}
.admin-shell-nav {
position: static;
top: auto;
}
.admin-sidebar,
.admin-rail-stack,
.admin-rail-card,
.maintenance-layout,
.maintenance-tools-panel,
.cache-table {
width: 100%;
}
.admin-grid,
.users-page-toolbar-grid,
.users-summary-grid,
.users-page-overview-grid,
.maintenance-action-grid,
.schedule-grid,
.diagnostics-inline-summary,
.diagnostics-grid {
grid-template-columns: 1fr;
}
.settings-nav,
.settings-links {
display: grid;
grid-template-columns: 1fr;
}
.settings-group {
min-width: 0;
}
.settings-links a {
justify-content: flex-start;
}
.settings-section-actions,
.diagnostics-control-panel,
.diagnostics-control-actions,
.log-actions {
display: grid;
width: 100%;
justify-content: stretch;
}
.settings-section-actions > *,
.diagnostics-control-actions > *,
.log-actions > * {
width: 100%;
}
.settings-section-actions .settings-action-button {
width: 100%;
min-width: 0;
flex-basis: auto;
}
.sync-meta,
.diagnostic-card-top,
.diagnostics-category-header,
.users-summary-row {
flex-direction: column;
align-items: stretch;
}
.cache-row {
grid-template-columns: 1fr;
}
.cache-row span {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
}
}
/* Final admin shell + settings section cleanup */
.admin-shell,
.admin-shell-nav,
.admin-card,
.admin-shell-rail,
.admin-sidebar,
.admin-panel {
min-width: 0;
}
.admin-shell {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
grid-template-areas: "nav main";
gap: 22px;
align-items: start;
}
.admin-shell.admin-shell--with-rail {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
grid-template-areas: "nav main rail";
}
.admin-shell-nav {
grid-area: nav;
}
.admin-card {
grid-area: main;
}
.admin-shell-rail {
grid-area: rail;
position: sticky;
top: 20px;
align-self: start;
display: grid;
gap: 10px;
}
.admin-zone-stack {
gap: 18px;
}
.admin-zone {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.07);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015)),
rgba(255, 255, 255, 0.012);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
[data-theme='light'] .admin-zone {
border-color: rgba(17, 19, 24, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.72)),
rgba(17, 19, 24, 0.018);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.admin-zone .section-header {
align-items: flex-start;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
[data-theme='light'] .admin-zone .section-header {
border-bottom-color: rgba(17, 19, 24, 0.08);
}
.admin-zone .section-header h2 {
position: relative;
display: inline-block;
padding-bottom: 8px;
}
.admin-zone .section-header h2::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
}
.admin-zone .section-subtitle {
margin-top: -4px;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 1280px) {
.admin-shell {
grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
grid-template-areas: "nav main";
}
.admin-shell.admin-shell--with-rail {
grid-template-areas:
"nav main"
"nav rail";
}
.admin-shell-rail {
position: static;
top: auto;
width: 100%;
}
}
@media (max-width: 1080px) {
.admin-shell,
.admin-shell.admin-shell--with-rail {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.admin-shell {
grid-template-areas:
"nav"
"main";
}
.admin-shell.admin-shell--with-rail {
grid-template-areas:
"nav"
"main"
"rail";
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
width: 100%;
}
.admin-shell-nav {
position: static;
top: auto;
}
.admin-grid,
.users-page-toolbar-grid,
.users-summary-grid,
.users-page-overview-grid,
.maintenance-action-grid,
.schedule-grid,
.diagnostics-inline-summary,
.diagnostics-grid {
grid-template-columns: 1fr;
}
.admin-zone {
padding: 16px;
}
}
/* Final header action layout */
.header-actions {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
width: 100%;
}
.header-actions .header-cta--left {
grid-column: 1;
justify-self: start;
margin-right: 0;
}
.header-actions-center {
grid-column: 2;
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-actions-right {
grid-column: 3;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
justify-self: end;
}
@media (max-width: 760px) {
.header-actions {
grid-template-columns: 1fr;
gap: 10px;
}
.header-actions .header-cta--left {
grid-column: 1;
width: 100%;
}
.header-actions-center,
.header-actions-right {
display: grid;
grid-template-columns: 1fr;
width: 100%;
justify-self: stretch;
}
.header-actions-center {
grid-column: 1;
}
.header-actions-right {
grid-column: 1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -4,220 +4,181 @@ export default function HowItWorksPage() {
return (
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How this works</p>
<h1>How Magent works now</h1>
<p className="eyebrow">How it works</p>
<h1>How Magent works for users</h1>
<p className="lede">
End-to-end request flow, live status updates, and the exact tools available to users and
admins.
Use Magent to find a request, watch it move through the pipeline, and know when it is
ready without constantly refreshing the page.
</p>
</header>
<section className="how-grid">
<article className="how-card">
<h2>Seerr</h2>
<p className="how-title">The request box</p>
<p>
This is where you ask for a movie or show. It keeps the request and whether it is
approved.
</p>
</article>
<article className="how-card">
<h2>Sonarr / Radarr</h2>
<p className="how-title">The library manager</p>
<p>
These add the request to the library list and decide what quality to look for.
</p>
</article>
<article className="how-card">
<h2>Prowlarr</h2>
<p className="how-title">The search helper</p>
<p>
This checks your search sources and reports back what it finds.
</p>
</article>
<article className="how-card">
<h2>qBittorrent</h2>
<p className="how-title">The downloader</p>
<p>
This downloads the file. Magent can tell if it is downloading, paused, or finished.
</p>
</article>
<article className="how-card">
<h2>Jellyfin</h2>
<p className="how-title">The place you watch</p>
<p>
When the file is ready, Jellyfin shows it in your library so you can watch it.
</p>
</article>
</section>
<section className="how-flow">
<h2>The pipeline (request to ready)</h2>
<ol className="how-steps">
<li>
<strong>Request created</strong> in Seerr.
</li>
<li>
<strong>Approved</strong> and sent to Sonarr/Radarr.
</li>
<li>
<strong>Search runs</strong> against indexers via Prowlarr.
</li>
<li>
<strong>Grabbed</strong> and downloaded by qBittorrent.
</li>
<li>
<strong>Imported</strong> by Sonarr/Radarr.
</li>
<li>
<strong>Available</strong> in Jellyfin.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Live updates (no refresh needed)</h2>
<div className="how-step-grid">
<article className="how-step-card step-arr">
<div className="step-badge">1</div>
<h3>Request page updates in real time</h3>
<p className="step-note">
Status, timeline hops, and action history update automatically while you are viewing
the request.
<h2>What Magent is for</h2>
<div className="how-grid">
<article className="how-card">
<h3>Track requests</h3>
<p>
Search by title, year, or request number to open the request page and see where an
item is up to.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Download progress updates live</h3>
<p className="step-note">
Torrent progress, queue state, and downloader details refresh automatically so users
do not need to hard refresh.
<article className="how-card">
<h3>See live progress</h3>
<p>
Request status, timeline events, and download progress update live while you are
viewing the page.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as import finishes</h3>
<p className="step-note">
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
shows it as ready.
<article className="how-card">
<h3>Know when it is ready</h3>
<p>
When the request is fully imported and available, Magent shows it as ready and links
you through to Jellyfin.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<h2>The request pipeline</h2>
<ol className="how-steps">
<li>
<strong>You request a movie or show</strong> through Seerr.
</li>
<li>
<strong>Magent picks up the request</strong> and shows its current state.
</li>
<li>
<strong>The automation stack searches and downloads it</strong> if it can find a valid
release.
</li>
<li>
<strong>The file is imported into the library</strong>.
</li>
<li>
<strong>Jellyfin serves it</strong> once it is ready to watch.
</li>
</ol>
</section>
<section className="how-flow">
<h2>What the statuses usually mean</h2>
<div className="how-grid">
<article className="how-card">
<h3>Pending</h3>
<p>The request exists, but it is still waiting for approval or the next step.</p>
</article>
<article className="how-card">
<h3>Approved / Processing</h3>
<p>The request has been accepted and the automation tools are working on it.</p>
</article>
<article className="how-card">
<h3>Downloading</h3>
<p>Magent can show live progress while the content is still being downloaded.</p>
</article>
<article className="how-card">
<h3>Ready</h3>
<p>The item has been imported and should now be available in Jellyfin.</p>
</article>
<article className="how-card">
<h3>Partial / Waiting</h3>
<p>
Part of the workflow completed, but the request is still waiting on another service or
on content becoming available.
</p>
</article>
<article className="how-card">
<h3>Declined</h3>
<p>The request was rejected or cannot proceed in its current form.</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Live updates you can expect</h2>
<div className="how-step-grid">
<article className="how-step-card step-seerr">
<div className="step-badge">1</div>
<h3>Re-add to Arr</h3>
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Missing NEEDS_ADD / ADDED state transitions</li>
<li>Queue repair after Arr-side cleanup</li>
</ul>
<h3>Recent requests refresh automatically</h3>
<p className="step-note">
Your request list and landing-page activity update automatically while you are signed
in.
</p>
</article>
<article className="how-step-card step-arr">
<div className="step-badge">2</div>
<h3>Search releases</h3>
<p className="step-note">Runs a search and shows concrete release options.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Manual selection of a specific release/indexer</li>
<li>Checking whether results currently exist</li>
</ul>
</article>
<article className="how-step-card step-prowlarr">
<div className="step-badge">3</div>
<h3>Search + auto-download</h3>
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Fast recovery when users have auto-search access</li>
<li>Hands-off retry of stalled requests</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">4</div>
<h3>Resume download</h3>
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Paused queue entries</li>
<li>Downloader restarts</li>
</ul>
<div className="step-badge">2</div>
<h3>Request pages update in real time</h3>
<p className="step-note">
State changes, timeline steps, and downloader progress are pushed to the page live.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">5</div>
<h3>Open in Jellyfin</h3>
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Immediate playback confirmation</li>
<li>User handoff from request tracking to watching</li>
</ul>
<div className="step-badge">3</div>
<h3>Ready state appears as soon as the import completes</h3>
<p className="step-note">
Once the content is actually available, Magent updates the request page without a hard
refresh.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invite and account flow</h2>
<h2>User actions you may see</h2>
<div className="how-grid">
<article className="how-card">
<h3>Open request</h3>
<p>Jump into the full request page to inspect the current state and activity.</p>
</article>
<article className="how-card">
<h3>Open in Jellyfin</h3>
<p>Appears when the request is ready and Magent can link you through for playback.</p>
</article>
<article className="how-card">
<h3>Search + auto-download</h3>
<p>
Only appears for accounts that have been granted self-service download access by the
admin team.
</p>
</article>
<article className="how-card">
<h3>My invites</h3>
<p>
If your account is allowed to invite others, you can create and manage invite links
from your profile.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invites and signup</h2>
<ol className="how-steps">
<li>
<strong>Invite created</strong> by admin or eligible user.
<strong>You receive an invite link</strong> by email or directly from the person who
invited you.
</li>
<li>
<strong>User signs up</strong> and Magent creates/links the account.
<strong>You sign up through Magent</strong> and your account is linked into the media
stack.
</li>
<li>
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
<strong>Your account defaults apply</strong> based on the invite or your assigned
profile.
</li>
<li>
<strong>Admin trace map</strong> can show inviter invited lineage.
<strong>You sign in and track requests</strong> from the landing page and your request
pages.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Admin controls available</h2>
<div className="how-grid">
<article className="how-card">
<h3>General</h3>
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
</article>
<article className="how-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
</article>
<article className="how-card">
<h3>Users</h3>
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
</article>
<article className="how-card">
<h3>Invite management</h3>
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
</article>
<article className="how-card">
<h3>Request sync + cache</h3>
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
</article>
<article className="how-card">
<h3>Maintenance + logs</h3>
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
</article>
</div>
</section>
<section className="how-callout">
<h2>Why a request can still wait</h2>
<h2>If a request looks stuck</h2>
<p>
If indexers do not return a valid release yet, Magent will show waiting/search states.
That usually means content availability is the blocker, not a broken pipeline.
A waiting request usually means no usable release has been found yet, the download is
still in progress, or the import has not completed. Magent will keep updating as the
underlying services move forward.
</p>
</section>
</main>

View File

@@ -0,0 +1,624 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileInfo = {
username: string
role: string
auth_provider: string
invite_management_enabled?: boolean
}
type ProfileResponse = {
user: ProfileInfo
}
type OwnedInvite = {
id: number
code: string
label?: string | null
description?: string | null
recipient_email?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
}
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
}
type OwnedInviteForm = {
code: string
label: string
description: string
recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
send_email: boolean
message: string
}
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
send_email: false,
message: '',
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function ProfileInvitesPage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
const loadPage = async () => {
const baseUrl = getApiBase()
const [profileResponse, invitesResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
if (profileResponse.status === 401 || invitesResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Could not load invite tools.')
}
const [profileData, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const user = profileData?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
})
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
await loadPage()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
send_email: false,
message: '',
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
const data = await response.json().catch(() => ({}))
if (data?.email?.status === 'ok') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
resetInviteEditor()
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
if (loading) {
return <main className="card">Loading invite tools...</main>
}
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My invites</h1>
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
Back to profile
</button>
</div>
</div>
{profile ? (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}).
</div>
) : null}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=activity')}
>
Activity
</button>
<button type="button" role="tab" aria-selected className="is-active">
My invites
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=security')}
>
Security
</button>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
{!canManageInvites ? (
<section className="profile-section profile-tab-panel">
<h2>Invite access is disabled</h2>
<p className="lede">
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
</p>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push('/profile')}>
Return to profile
</button>
</div>
</section>
) : (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite workspace</h2>
<p className="lede">
{inviteManagedByMaster
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
</p>
</div>
</div>
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You have not created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
<span className="small-pill is-muted">
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
</span>
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => copyInviteLink(invite)}
>
Copy link
</button>
<button
type="button"
className="ghost-button"
onClick={() => editInvite(invite)}
>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
)}
</main>
)
}

View File

@@ -9,6 +9,8 @@ type ProfileInfo = {
role: string
auth_provider: string
invite_management_enabled?: boolean
password_change_supported?: boolean
password_provider?: 'local' | 'jellyfin' | null
}
type ProfileStats = {
@@ -48,68 +50,15 @@ type ProfileResponse = {
activity: ProfileActivity
}
type OwnedInvite = {
id: number
code: string
label?: string | null
description?: string | null
recipient_email?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
}
type ProfileTab = 'overview' | 'activity' | 'security'
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
const normalizeProfileTab = (value?: string | null): ProfileTab => {
if (value === 'activity' || value === 'security') {
return value
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
return 'overview'
}
type OwnedInviteForm = {
code: string
label: string
description: string
recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
send_email: boolean
message: string
}
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
send_email: false,
message: '',
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
@@ -134,24 +83,29 @@ export default function ProfilePage() {
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [confirmPassword, setConfirmPassword] = useState('')
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
const inviteLink = useMemo(() => '/profile/invites', [])
useEffect(() => {
if (typeof window === 'undefined') return
const syncTabFromLocation = () => {
const params = new URLSearchParams(window.location.search)
setActiveTab(normalizeProfileTab(params.get('tab')))
}
syncTabFromLocation()
window.addEventListener('popstate', syncTabFromLocation)
return () => window.removeEventListener('popstate', syncTabFromLocation)
}, [])
const selectTab = (tab: ProfileTab) => {
setActiveTab(tab)
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -160,35 +114,30 @@ export default function ProfilePage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const [profileResponse, invitesResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
if (!profileResponse.ok) {
clearToken()
router.push('/login')
return
}
const [data, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const data = (await profileResponse.json()) as ProfileResponse
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
password_change_supported: Boolean(user?.password_change_supported ?? false),
password_provider:
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
? user.password_provider
: null,
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
setStatus({ tone: 'error', message: 'Could not load your profile.' })
} finally {
setLoading(false)
}
@@ -200,7 +149,11 @@ export default function ProfilePage() {
event.preventDefault()
setStatus(null)
if (!currentPassword || !newPassword) {
setStatus('Enter your current password and a new password.')
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
return
}
if (newPassword !== confirmPassword) {
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
return
}
try {
@@ -229,192 +182,69 @@ export default function ProfilePage() {
const data = await response.json().catch(() => ({}))
setCurrentPassword('')
setNewPassword('')
setStatus(
setConfirmPassword('')
setStatus({
tone: 'status',
message:
data?.provider === 'jellyfin'
? 'Password updated in Jellyfin (and Magent cache).'
: 'Password updated.'
)
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
: 'Password updated.',
})
} catch (err) {
console.error(err)
if (err instanceof Error && err.message) {
setStatus(`Could not update password. ${err.message}`)
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
} else {
setStatus('Could not update password. Check your current password.')
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
}
}
}
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
send_email: false,
message: '',
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
const data = await response.json().catch(() => ({}))
if (data?.email?.status === 'ok') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
resetInviteEditor()
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const authProvider = profile?.auth_provider ?? 'local'
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
const securityHelpText =
authProvider === 'jellyfin'
? 'Changing your password here updates your Jellyfin account and refreshes Magents cached sign-in.'
: authProvider === 'local'
passwordProvider === 'jellyfin'
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
: passwordProvider === 'local'
? 'Change your Magent account password.'
: 'Password changes are not available for this sign-in provider.'
useEffect(() => {
if (activeTab === 'invites' && !canManageInvites) {
setActiveTab('overview')
}
}, [activeTab, canManageInvites])
if (loading) {
return <main className="card">Loading profile...</main>
}
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My profile</h1>
<p className="lede">Review your account, activity, and security settings.</p>
</div>
{canManageInvites || canChangePassword ? (
<div className="admin-inline-actions">
{canManageInvites ? (
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
Open invite page
</button>
) : null}
{canChangePassword ? (
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
) : null}
</div>
) : null}
</div>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
)}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button
@@ -422,7 +252,7 @@ export default function ProfilePage() {
role="tab"
aria-selected={activeTab === 'overview'}
className={activeTab === 'overview' ? 'is-active' : ''}
onClick={() => setActiveTab('overview')}
onClick={() => selectTab('overview')}
>
Overview
</button>
@@ -431,18 +261,12 @@ export default function ProfilePage() {
role="tab"
aria-selected={activeTab === 'activity'}
className={activeTab === 'activity' ? 'is-active' : ''}
onClick={() => setActiveTab('activity')}
onClick={() => selectTab('activity')}
>
Activity
</button>
{canManageInvites ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
My invites
</button>
) : null}
@@ -451,15 +275,47 @@ export default function ProfilePage() {
role="tab"
aria-selected={activeTab === 'security'}
className={activeTab === 'security' ? 'is-active' : ''}
onClick={() => setActiveTab('security')}
onClick={() => selectTab('security')}
>
Security
Password
</button>
</div>
</div>
{activeTab === 'overview' && (
<section className="profile-section profile-tab-panel">
{canManageInvites ? (
<div className="profile-quick-link-card">
<div>
<h2>Invite tools</h2>
<p className="lede">
Create invite links, send them by email, and track who you have invited from a dedicated page.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => router.push(inviteLink)}>
Go to invites
</button>
</div>
</div>
) : null}
{canChangePassword ? (
<div className="profile-quick-link-card">
<div>
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
<p className="lede">
{passwordProvider === 'jellyfin'
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
: 'Update your Magent account password.'}
</p>
</div>
<div className="admin-inline-actions">
<button type="button" onClick={() => selectTab('security')}>
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
</button>
</div>
</div>
) : null}
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
@@ -503,9 +359,7 @@ export default function ProfilePage() {
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total
? `${Math.round((stats.share || 0) * 1000) / 10}%`
: '0%'}
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
</div>
</div>
<div className="stat-card">
@@ -551,274 +405,14 @@ export default function ProfilePage() {
</section>
)}
{activeTab === 'invites' && (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="user-directory-panel-header">
<div>
<h2>My invites</h2>
<p className="lede">
{inviteManagedByMaster
? 'Create and manage invite links youve issued. New invites use the admin master invite rule.'
: 'Create and manage invite links youve issued. New invites use your account defaults.'}
</p>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Share the generated signup link with the person you want to invite.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You havent created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
<span className="small-pill is-muted">
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
</span>
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => copyInviteLink(invite)}
>
Copy link
</button>
<button
type="button"
className="ghost-button"
onClick={() => editInvite(invite)}
>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
)}
{activeTab === 'security' && (
<section className="profile-section profile-tab-panel">
<h2>Security</h2>
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
<div className="status-banner">{securityHelpText}</div>
{canChangePassword ? (
<form onSubmit={submit} className="auth-form profile-security-form">
<label>
Current password
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
<input
type="password"
value={currentPassword}
@@ -827,7 +421,7 @@ export default function ProfilePage() {
/>
</label>
<label>
New password
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
<input
type="password"
value={newPassword}
@@ -835,10 +429,23 @@ export default function ProfilePage() {
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status ? (
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
{status.message}
</div>
) : null}
<div className="auth-actions">
<button type="submit">
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
</button>
</div>
</form>

View File

@@ -12,8 +12,10 @@ type AdminShellProps = {
}
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
const hasRail = Boolean(rail)
return (
<div className="admin-shell">
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
@@ -27,16 +29,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
</div>
{children}
</main>
<aside className="admin-shell-rail">
{rail ?? (
<div className="admin-rail-card admin-rail-card--placeholder">
<span className="admin-rail-eyebrow">Insights</span>
<h2>Stats rail</h2>
<p>Use this column for counters, live status, and quick metrics for this page.</p>
<span className="small-pill">{title}</span>
</div>
)}
</aside>
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
</div>
)
}

View File

@@ -27,7 +27,7 @@ const NAV_GROUPS = [
title: 'Admin',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'System guide' },
{ href: '/admin/system', label: 'How it works' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },

View File

@@ -5,7 +5,6 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
useEffect(() => {
const token = getToken()
@@ -20,11 +19,9 @@ export default function HeaderActions() {
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
await response.json()
} catch (err) {
console.error(err)
}
@@ -39,9 +36,13 @@ export default function HeaderActions() {
return (
<div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a>
<div className="header-actions-center">
<a href="/how-it-works">How it works</a>
{role === 'admin' && <a href="/admin">Settings</a>}
</div>
<div className="header-actions-right">
<a href="/">Requests</a>
<a href="/profile/invites">Invites</a>
</div>
</div>
)
}

View File

@@ -75,6 +75,11 @@ export default function HeaderIdentity() {
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === 'admin' ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
) : null}
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>

View File

@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
"version": "0103262231",
"version": "0203261953",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0103262231",
"version": "0203261953",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",
@@ -977,3 +977,4 @@

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0103262231",
"version": "0203261953",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -23,3 +23,4 @@

View File

@@ -4,7 +4,7 @@ $repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("M"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
Write-Host "Build number: $buildNumber"

312
scripts/process1.ps1 Normal file
View File

@@ -0,0 +1,312 @@
param(
[string]$CommitMessage,
[switch]$SkipCommit,
[switch]$SkipDiscord
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$script:CurrentStep = "initializing"
function Write-TextFile {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Content
)
$fullPath = Join-Path $repoRoot $Path
$normalized = $Content -replace "`r`n", "`n"
[System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
}
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Read-TextFile {
param([Parameter(Mandatory = $true)][string]$Path)
$fullPath = Join-Path $repoRoot $Path
return [System.IO.File]::ReadAllText($fullPath)
}
function Get-EnvFileValue {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Name
)
if (-not (Test-Path $Path)) {
return $null
}
$match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
if (-not $match) {
return $null
}
return $match.Matches[0].Groups[1].Value.Trim()
}
function Get-DiscordWebhookUrl {
$candidateNames = @(
"PROCESS_DISCORD_WEBHOOK_URL",
"MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
"DISCORD_WEBHOOK_URL"
)
foreach ($name in $candidateNames) {
$value = [System.Environment]::GetEnvironmentVariable($name)
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
foreach ($name in $candidateNames) {
$value = Get-EnvFileValue -Path ".env" -Name $name
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
$configPath = Join-Path $repoRoot "backend/app/config.py"
if (Test-Path $configPath) {
$configContent = Read-TextFile -Path "backend/app/config.py"
$match = [regex]::Match(
$configContent,
'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
function Send-DiscordUpdate {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
if ($SkipDiscord) {
Write-Host "Skipping Discord notification."
return
}
$webhookUrl = Get-DiscordWebhookUrl
if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
Write-Warning "Discord webhook not configured for Process 1."
return
}
$content = "**$Title**`n$Body"
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
}
function Get-BuildNumber {
$current = ""
if (Test-Path ".build_number") {
$current = (Get-Content ".build_number" -Raw).Trim()
}
$candidate = Get-Date
$buildNumber = $candidate.ToString("ddMMyyHHmm")
if ($buildNumber -eq $current) {
$buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
}
return $buildNumber
}
function Wait-ForHttp {
param(
[Parameter(Mandatory = $true)][string]$Url,
[int]$Attempts = 30,
[int]$DelaySeconds = 2
)
$lastError = $null
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
return Invoke-RestMethod -Uri $Url -TimeoutSec 10
} catch {
$lastError = $_
Start-Sleep -Seconds $DelaySeconds
}
}
throw $lastError
}
function Get-GitChangelogLiteral {
$scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py"
$literal = python $scriptPath --python-literal
Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal"
return ($literal | Out-String).Trim()
}
function Update-BuildFiles {
param([Parameter(Mandatory = $true)][string]$BuildNumber)
Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
$changelogLiteral = Get-GitChangelogLiteral
$buildInfoContent = @(
"BUILD_NUMBER = `"$BuildNumber`""
"CHANGELOG = $changelogLiteral"
""
) -join "`n"
Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent
$envPath = Join-Path $repoRoot ".env"
if (Test-Path $envPath) {
$envContent = Read-TextFile -Path ".env"
if ($envContent -match '^BUILD_NUMBER=.*$') {
$updatedEnv = [regex]::Replace(
$envContent,
'^BUILD_NUMBER=.*$',
"BUILD_NUMBER=$BuildNumber",
[System.Text.RegularExpressions.RegexOptions]::Multiline
)
} else {
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
}
Write-TextFile -Path ".env" -Content $updatedEnv
}
$packageJson = Read-TextFile -Path "frontend/package.json"
$packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"')
$updatedPackageJson = $packageJsonRegex.Replace(
$packageJson,
"`"version`": `"$BuildNumber`"",
1
)
Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson
$packageLock = Read-TextFile -Path "frontend/package-lock.json"
$packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"')
$updatedPackageLock = $packageLockVersionRegex.Replace(
$packageLock,
"`"version`": `"$BuildNumber`"",
1
)
$packageLockRootRegex = [regex]::new(
'(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
$updatedPackageLock = $packageLockRootRegex.Replace(
$updatedPackageLock,
'$1"' + $BuildNumber + '"',
1
)
Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock
}
function Get-ChangedFilesSummary {
$files = git diff --cached --name-only
if (-not $files) {
return "No staged files"
}
$count = ($files | Measure-Object).Count
$sample = $files | Select-Object -First 8
$summary = ($sample -join ", ")
if ($count -gt $sample.Count) {
$summary = "$summary, +$($count - $sample.Count) more"
}
return "$count files: $summary"
}
$buildNumber = $null
$branch = $null
$commit = $null
$publicInfo = $null
$changedFiles = "No staged files"
try {
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
$buildNumber = Get-BuildNumber
Write-Host "Process 1 build number: $buildNumber"
$script:CurrentStep = "updating build metadata"
Update-BuildFiles -BuildNumber $buildNumber
$script:CurrentStep = "rebuilding local docker stack"
docker compose up -d --build
Assert-LastExitCode -CommandName "docker compose up -d --build"
$script:CurrentStep = "verifying backend health"
$health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
if ($health.status -ne "ok") {
throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
}
$script:CurrentStep = "verifying public build metadata"
$publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
if ($publicInfo.buildNumber -ne $buildNumber) {
throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
}
$script:CurrentStep = "committing changes"
git add -A
Assert-LastExitCode -CommandName "git add -A"
$changedFiles = Get-ChangedFilesSummary
if ((git status --short).Trim()) {
if (-not $SkipCommit) {
if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
$CommitMessage = "Process 1 build $buildNumber"
}
git commit -m $CommitMessage
Assert-LastExitCode -CommandName "git commit"
}
}
$commit = (git rev-parse --short HEAD).Trim()
$body = @(
"Build: $buildNumber"
"Branch: $branch"
"Commit: $commit"
"Health: ok"
"Public build: $($publicInfo.buildNumber)"
"Changes: $changedFiles"
) -join "`n"
Send-DiscordUpdate -Title "Process 1 complete" -Body $body
Write-Host "Process 1 completed successfully."
} catch {
$failureCommit = ""
try {
$failureCommit = (git rev-parse --short HEAD).Trim()
} catch {
$failureCommit = "unknown"
}
$failureBody = @(
"Build: $buildNumber"
"Branch: $branch"
"Commit: $failureCommit"
"Step: $script:CurrentStep"
"Error: $($_.Exception.Message)"
) -join "`n"
try {
Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
} catch {
Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
}
throw
}

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
def build_git_changelog(repo_root: Path, max_count: int) -> str:
result = subprocess.run(
[
"git",
"log",
f"--max-count={max_count}",
"--date=short",
"--pretty=format:%cs|%s",
"--",
".",
],
cwd=repo_root,
capture_output=True,
text=True,
check=True,
)
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--max-count", type=int, default=200)
parser.add_argument("--python-literal", action="store_true")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
changelog = build_git_changelog(repo_root, max_count=args.max_count)
if args.python_literal:
print(repr(changelog))
else:
sys.stdout.write(changelog)
return 0
if __name__ == "__main__":
raise SystemExit(main())