From 9c69d9fd17f051b4fcbf75c6da56ae6468f5fcc5 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Mon, 2 Mar 2026 19:54:14 +1300 Subject: [PATCH] Process 1 build 0203261953 --- .build_number | 2 +- backend/app/auth.py | 44 +++- backend/app/build_info.py | 10 +- backend/app/db.py | 18 ++ backend/app/routers/admin.py | 19 +- backend/app/routers/auth.py | 24 ++- backend/app/routers/site.py | 5 +- backend/app/services/diagnostics.py | 12 +- backend/app/services/invite_email.py | 23 +- frontend/app/admin/SettingsPage.tsx | 13 +- frontend/app/admin/system/page.tsx | 111 +++++++++- frontend/app/changelog/page.tsx | 62 ++++-- frontend/app/globals.css | 102 ++++++++- frontend/app/how-it-works/page.tsx | 307 ++++++++++++--------------- frontend/app/profile/page.tsx | 101 ++++++--- frontend/app/ui/AdminSidebar.tsx | 2 +- frontend/app/ui/HeaderActions.tsx | 15 +- frontend/app/ui/HeaderIdentity.tsx | 8 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- scripts/process1.ps1 | 22 +- scripts/render_git_changelog.py | 45 ++++ 22 files changed, 672 insertions(+), 279 deletions(-) create mode 100644 scripts/render_git_changelog.py diff --git a/.build_number b/.build_number index 5478a63..333751a 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0203261610 +0203261953 diff --git a/backend/app/auth.py b/backend/app/auth.py index 3e8f203..8b87a74 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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"), } diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 1c96a5f..9b0ceb7 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,8 +1,2 @@ -BUILD_NUMBER = "0203261610" -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' diff --git a/backend/app/db.py b/backend/app/db.py index 44d980e..5ff4668 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1398,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 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3fc5b0a..cb43d9c 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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()), } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 13c7c39..ef07ba3 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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( diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py index fa99220..7d0cca8 100644 --- a/backend/app/routers/site.py +++ b/backend/app/routers/site.py @@ -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 diff --git a/backend/app/services/diagnostics.py b/backend/app/services/diagnostics.py index 170cc22..5a07113 100644 --- a/backend/app/services/diagnostics.py +++ b/backend/app/services/diagnostics.py @@ -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), ), diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index a436dc7..9fe13ae 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -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 diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 067c176..5e5e10a 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -104,7 +104,7 @@ const SECTION_DESCRIPTIONS: Record = { 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 = { @@ -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 diff --git a/frontend/app/admin/system/page.tsx b/frontend/app/admin/system/page.tsx index 9fb17f4..7a74425 100644 --- a/frontend/app/admin/system/page.tsx +++ b/frontend/app/admin/system/page.tsx @@ -106,9 +106,9 @@ export default function AdminSystemGuidePage() { const rail = (
- Guide map -

Quick path

-

Identity → Intake → Queue → Download → Import → Playback.

+ How it works +

Admin flow map

+

Identity → Request intake → Queue orchestration → Download → Import → Playback.

Admin only
@@ -116,8 +116,8 @@ export default function AdminSystemGuidePage() { return ( router.push('/admin')}> @@ -129,7 +129,8 @@ export default function AdminSystemGuidePage() {

End-to-end system flow

- 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.

{REQUEST_FLOW.map((stage, index) => ( @@ -155,6 +156,51 @@ export default function AdminSystemGuidePage() {
+
+

What each service is responsible for

+
+
+

Magent

+

+ Handles authentication, request pages, live event updates, invite workflows, + diagnostics, notifications, and admin operations. +

+
+
+

Seerr

+

+ Stores the request itself and remains the request-state source for approval and + media request metadata. +

+
+
+

Jellyfin

+

+ Provides user sign-in identity and the final playback destination once content is + available. +

+
+
+

Sonarr / Radarr

+

+ Control queue placement, quality-profile decisions, import handling, and release + monitoring. +

+
+
+

Prowlarr

+

Provides search/indexer coverage for Arr-side release searches.

+
+
+

qBittorrent

+

+ Executes the download and exposes live progress, paused states, and queue + visibility. +

+
+
+
+

Operational controls by area

@@ -172,19 +218,48 @@ export default function AdminSystemGuidePage() {

Invite management

-

Master template, profile assignment, invite access policy, and invite trace map lineage.

+

+ Master template, profile assignment, invite access policy, invite emails, and trace + map lineage. +

Requests + cache

All-requests view, sync controls, cached request records, and maintenance operations.

-

Live request page

-

Event-stream updates for state, action history, and torrent progress without page refresh.

+

Maintenance + diagnostics

+

+ Connectivity checks, live diagnostics, database repair, cleanup, log review, and + nuclear flush/resync operations. +

+
+

User and invite model

+
    +
  1. + Jellyfin is used for sign-in identity and user presence across the platform. +
  2. +
  3. + Seerr provides request ownership and request-state data for Magent request pages. +
  4. +
  5. + Invite links, invite profiles, blanket rules, and invite-access controls are managed + inside Magent. +
  6. +
  7. + If invite tracing is enabled, the lineage view shows who invited whom and how the + chain branches. +
  8. +
  9. + Cross-system removal and ban flows are initiated from Magent admin controls. +
  10. +
+
+

Stall recovery path (decision flow)

    @@ -205,6 +280,24 @@ export default function AdminSystemGuidePage() {
+ +
+

Live update surfaces

+
+
+

Landing page

+

Recent requests and service summaries refresh live for signed-in users.

+
+
+

Request pages

+

Timeline state, queue activity, and torrent progress are pushed live without refresh.

+
+
+

Admin views

+

Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.

+
+
+
) diff --git a/frontend/app/changelog/page.tsx b/frontend/app/changelog/page.tsx index 238488c..8c79508 100644 --- a/frontend/app/changelog/page.tsx +++ b/frontend/app/changelog/page.tsx @@ -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([]) + const [groups, setGroups] = useState([]) 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
Loading changelog...
} - if (entries.length === 0) { + if (groups.length === 0) { return
No updates posted yet.
} return ( -
    - {entries.map((entry, index) => ( -
  • {entry}
  • +
    + {groups.map((group) => ( +
    +

    {group.date}

    +
      + {group.entries.map((entry, index) => ( +
    • {entry}
    • + ))} +
    +
    ))} -
+ ) - }, [entries, loading]) + }, [groups, loading]) return (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 045ab6b..b879582 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2369,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) */ /* -------------------------------------------------------------------------- */ @@ -3669,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; @@ -6332,7 +6369,7 @@ textarea { position: absolute; left: 0; bottom: 0; - width: 52px; + width: 100%; height: 2px; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0)); @@ -6409,3 +6446,62 @@ textarea { 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)); + } +} diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index 5d352ae..fe8bc45 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -4,220 +4,181 @@ export default function HowItWorksPage() { return (
-

How this works

-

How Magent works now

+

How it works

+

How Magent works for users

- 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.

-
-
-

Seerr

-

The request box

-

- This is where you ask for a movie or show. It keeps the request and whether it is - approved. -

-
-
-

Sonarr / Radarr

-

The library manager

-

- These add the request to the library list and decide what quality to look for. -

-
-
-

Prowlarr

-

The search helper

-

- This checks your search sources and reports back what it finds. -

-
-
-

qBittorrent

-

The downloader

-

- This downloads the file. Magent can tell if it is downloading, paused, or finished. -

-
-
-

Jellyfin

-

The place you watch

-

- When the file is ready, Jellyfin shows it in your library so you can watch it. -

-
-
-
-

The pipeline (request to ready)

-
    -
  1. - Request created in Seerr. -
  2. -
  3. - Approved and sent to Sonarr/Radarr. -
  4. -
  5. - Search runs against indexers via Prowlarr. -
  6. -
  7. - Grabbed and downloaded by qBittorrent. -
  8. -
  9. - Imported by Sonarr/Radarr. -
  10. -
  11. - Available in Jellyfin. -
  12. -
-
- -
-

Live updates (no refresh needed)

-
-
-
1
-

Request page updates in real time

-

- Status, timeline hops, and action history update automatically while you are viewing - the request. +

What Magent is for

+
+
+

Track requests

+

+ Search by title, year, or request number to open the request page and see where an + item is up to.

-
-
2
-

Download progress updates live

-

- Torrent progress, queue state, and downloader details refresh automatically so users - do not need to hard refresh. +

+

See live progress

+

+ Request status, timeline events, and download progress update live while you are + viewing the page.

-
-
3
-

Ready state appears as soon as import finishes

-

- As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page - shows it as ready. +

+

Know when it is ready

+

+ When the request is fully imported and available, Magent shows it as ready and links + you through to Jellyfin.

-

Request actions and when to use them

+

The request pipeline

+
    +
  1. + You request a movie or show through Seerr. +
  2. +
  3. + Magent picks up the request and shows its current state. +
  4. +
  5. + The automation stack searches and downloads it if it can find a valid + release. +
  6. +
  7. + The file is imported into the library. +
  8. +
  9. + Jellyfin serves it once it is ready to watch. +
  10. +
+
+ +
+

What the statuses usually mean

+
+
+

Pending

+

The request exists, but it is still waiting for approval or the next step.

+
+
+

Approved / Processing

+

The request has been accepted and the automation tools are working on it.

+
+
+

Downloading

+

Magent can show live progress while the content is still being downloaded.

+
+
+

Ready

+

The item has been imported and should now be available in Jellyfin.

+
+
+

Partial / Waiting

+

+ Part of the workflow completed, but the request is still waiting on another service or + on content becoming available. +

+
+
+

Declined

+

The request was rejected or cannot proceed in its current form.

+
+
+
+ +
+

Live updates you can expect

1
-

Re-add to Arr

-

Use when a request is approved but never entered the Arr queue.

-
Best for
-
    -
  • Missing NEEDS_ADD / ADDED state transitions
  • -
  • Queue repair after Arr-side cleanup
  • -
+

Recent requests refresh automatically

+

+ Your request list and landing-page activity update automatically while you are signed + in. +

- -
-
2
-

Search releases

-

Runs a search and shows concrete release options.

-
Best for
-
    -
  • Manual selection of a specific release/indexer
  • -
  • Checking whether results currently exist
  • -
-
- -
-
3
-

Search + auto-download

-

Runs search and lets Arr pick/grab automatically.

-
Best for
-
    -
  • Fast recovery when users have auto-search access
  • -
  • Hands-off retry of stalled requests
  • -
-
-
-
4
-

Resume download

-

Resumes a paused/stopped torrent in qBittorrent.

-
Best for
-
    -
  • Paused queue entries
  • -
  • Downloader restarts
  • -
+
2
+

Request pages update in real time

+

+ State changes, timeline steps, and downloader progress are pushed to the page live. +

-
-
5
-

Open in Jellyfin

-

Available when the item is imported and linked to Jellyfin.

-
Best for
-
    -
  • Immediate playback confirmation
  • -
  • User handoff from request tracking to watching
  • -
+
3
+

Ready state appears as soon as the import completes

+

+ Once the content is actually available, Magent updates the request page without a hard + refresh. +

-

Invite and account flow

+

User actions you may see

+
+
+

Open request

+

Jump into the full request page to inspect the current state and activity.

+
+
+

Open in Jellyfin

+

Appears when the request is ready and Magent can link you through for playback.

+
+
+

Search + auto-download

+

+ Only appears for accounts that have been granted self-service download access by the + admin team. +

+
+
+

My invites

+

+ If your account is allowed to invite others, you can create and manage invite links + from your profile. +

+
+
+
+ +
+

Invites and signup

  1. - Invite created by admin or eligible user. + You receive an invite link by email or directly from the person who + invited you.
  2. - User signs up and Magent creates/links the account. + You sign up through Magent and your account is linked into the media + stack.
  3. - Profile/defaults apply (role, auto-search, expiry, invite access). + Your account defaults apply based on the invite or your assigned + profile.
  4. - Admin trace map can show inviter → invited lineage. + You sign in and track requests from the landing page and your request + pages.
-
-

Admin controls available

-
-
-

General

-

App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.

-
-
-

Notifications

-

Email, Discord, Telegram, push/mobile, and generic webhook provider settings.

-
-
-

Users

-

Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.

-
-
-

Invite management

-

Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).

-
-
-

Request sync + cache

-

Control refresh/sync behavior, view all requests, and manage cached request records.

-
-
-

Maintenance + logs

-

Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.

-
-
-
-
-

Why a request can still wait

+

If a request looks stuck

- 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.

diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index d883594..064802c 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -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 = { @@ -81,7 +83,8 @@ export default function ProfilePage() { const [activity, setActivity] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') - const [status, setStatus] = useState(null) + const [confirmPassword, setConfirmPassword] = useState('') + const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null) const [activeTab, setActiveTab] = useState('overview') const [loading, setLoading] = useState(true) @@ -124,12 +127,17 @@ export default function ProfilePage() { role: user?.role ?? 'user', auth_provider: user?.auth_provider ?? 'local', invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), + password_change_supported: Boolean(user?.password_change_supported ?? false), + password_provider: + user?.password_provider === 'jellyfin' || user?.password_provider === 'local' + ? user.password_provider + : null, }) setStats(data?.stats ?? null) setActivity(data?.activity ?? null) } catch (err) { console.error(err) - setStatus('Could not load your profile.') + setStatus({ tone: 'error', message: 'Could not load your profile.' }) } finally { setLoading(false) } @@ -141,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 { @@ -170,28 +182,32 @@ export default function ProfilePage() { const data = await response.json().catch(() => ({})) setCurrentPassword('') setNewPassword('') - setStatus( - data?.provider === 'jellyfin' - ? 'Password updated in Jellyfin (and Magent cache).' - : 'Password updated.' - ) + setConfirmPassword('') + setStatus({ + tone: 'status', + message: + data?.provider === 'jellyfin' + ? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.' + : 'Password updated.', + }) } catch (err) { console.error(err) if (err instanceof Error && err.message) { - setStatus(`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 authProvider = profile?.auth_provider ?? 'local' + const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local') const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled) - const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin' + 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 Magent’s 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.' @@ -206,11 +222,18 @@ export default function ProfilePage() {

My profile

Review your account, activity, and security settings.

- {canManageInvites ? ( + {canManageInvites || canChangePassword ? (
- + {canManageInvites ? ( + + ) : null} + {canChangePassword ? ( + + ) : null}
) : null} @@ -254,7 +277,7 @@ export default function ProfilePage() { className={activeTab === 'security' ? 'is-active' : ''} onClick={() => selectTab('security')} > - Security + Password @@ -276,6 +299,23 @@ export default function ProfilePage() { ) : null} + {canChangePassword ? ( +
+
+

{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}

+

+ {passwordProvider === 'jellyfin' + ? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.' + : 'Update your Magent account password.'} +

+
+
+ +
+
+ ) : null}

Account stats

@@ -367,12 +407,12 @@ export default function ProfilePage() { {activeTab === 'security' && (
-

Security

+

{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}

{securityHelpText}
{canChangePassword ? (
- {status &&
{status}
} + + {status ? ( +
+ {status.message} +
+ ) : null}
diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx index 40c9608..aa2907d 100644 --- a/frontend/app/ui/AdminSidebar.tsx +++ b/frontend/app/ui/AdminSidebar.tsx @@ -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' }, diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index 50ba6aa..3b00a67 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -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(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 (
Send feedback - Requests - How it works - {role === 'admin' && Settings} + +
) } diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx index 5e3a8a6..5901a88 100644 --- a/frontend/app/ui/HeaderIdentity.tsx +++ b/frontend/app/ui/HeaderIdentity.tsx @@ -75,9 +75,11 @@ export default function HeaderIdentity() { setOpen(false)}> My profile - setOpen(false)}> - My invites - + {identity.role === 'admin' ? ( + setOpen(false)}> + Settings + + ) : null} setOpen(false)}> Changelog diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d366a3..2f92923 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "magent-frontend", - "version": "0203261610", + "version": "0203261953", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magent-frontend", - "version": "0203261610", + "version": "0203261953", "dependencies": { "next": "16.1.6", "react": "19.2.4", diff --git a/frontend/package.json b/frontend/package.json index 288a136..2908bd1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "0203261610", + "version": "0203261953", "scripts": { "dev": "next dev", "build": "next build", diff --git a/scripts/process1.ps1 b/scripts/process1.ps1 index c0a57e6..37497c4 100644 --- a/scripts/process1.ps1 +++ b/scripts/process1.ps1 @@ -149,19 +149,25 @@ function Wait-ForHttp { 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" - $buildInfo = Read-TextFile -Path "backend/app/build_info.py" - $updatedBuildInfo = [regex]::Replace( - $buildInfo, - '^BUILD_NUMBER = "\d+"$', - "BUILD_NUMBER = `"$BuildNumber`"", - [System.Text.RegularExpressions.RegexOptions]::Multiline - ) - Write-TextFile -Path "backend/app/build_info.py" -Content $updatedBuildInfo + $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) { diff --git a/scripts/render_git_changelog.py b/scripts/render_git_changelog.py new file mode 100644 index 0000000..a452bd6 --- /dev/null +++ b/scripts/render_git_changelog.py @@ -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())