Compare commits

..

19 Commits

Author SHA1 Message Date
d30a2473ce Improve email deliverability headers and SMTP identity 2026-03-04 17:37:51 +13:00
4e64f79e64 Fix admin user email visibility 2026-03-04 13:22:26 +13:00
c6bc31f27e Harden auth flows and add backend quality gate 2026-03-04 12:57:42 +13:00
1ad4823830 Fix email branding with inline logo and reliable MIME transport 2026-03-03 18:42:08 +13:00
caa6aa76d6 Fix email template rendering for Outlook-safe branded content 2026-03-03 17:20:19 +13:00
d80b1e5e4f Update all email templates with uniform branded graphics 2026-03-03 17:02:38 +13:00
1ff54690fc Add branded HTML email templates 2026-03-03 16:30:02 +13:00
4f2b5e0922 Add SMTP receipt logging for Exchange relay tracing 2026-03-03 16:12:13 +13:00
96333c0d85 Fix shared request access and Jellyfin-ready pipeline status 2026-03-03 16:01:36 +13:00
bac96c7db3 Process 1 build 0303261507 2026-03-03 15:07:35 +13:00
dda17a20a5 Improve SQLite batching and diagnostics visibility 2026-03-03 15:03:23 +13:00
e582ff4ef7 Add login page visibility controls 2026-03-03 14:13:39 +13:00
42d4caa474 Hotfix: expand landing-page search to all requests 2026-03-03 13:24:25 +13:00
5f2dc52771 Hotfix: add logged-out password reset flow 2026-03-02 20:44:58 +13:00
9c69d9fd17 Process 1 build 0203261953 2026-03-02 19:54:14 +13:00
b0ef455498 Process 1 build 0203261610 2026-03-02 16:10:40 +13:00
821f518bb3 Process 1 build 0203261608 2026-03-02 16:08:38 +13:00
eeba143b41 Add dedicated profile invites page and fix mobile admin layout 2026-03-02 15:12:38 +13:00
b068a6066e Persist Seerr media failure suppression and reduce sync error noise 2026-03-01 22:53:38 +13:00
50 changed files with 6141 additions and 1324 deletions

View File

@@ -1 +1 @@
0103262231
0403261736

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")
@@ -70,6 +108,7 @@ def _load_current_user_from_token(
return {
"username": user["username"],
"email": user.get("email"),
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
@@ -78,6 +117,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"),
}

File diff suppressed because one or more lines are too long

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

@@ -9,6 +9,9 @@ class Settings(BaseSettings):
app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
)
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
@@ -21,6 +24,15 @@ class Settings(BaseSettings):
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
password_reset_rate_limit_window_seconds: int = Field(
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
)
password_reset_rate_limit_max_attempts_ip: int = Field(
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
password_reset_rate_limit_max_attempts_identifier: int = Field(
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
@@ -71,6 +83,18 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
site_login_show_local_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
)
site_login_show_forgot_password: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
)
site_login_show_signup_link: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(

File diff suppressed because it is too large Load Diff

View File

@@ -163,6 +163,21 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
_background_tasks.append(task)
def _log_security_configuration_warnings() -> None:
if str(settings.jwt_secret or "").strip() == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is still set to the default value"
)
if str(settings.admin_password or "") == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
)
@app.on_event("startup")
async def startup() -> None:
configure_logging(
@@ -174,6 +189,7 @@ async def startup() -> None:
log_background_sync_level=settings.log_background_sync_level,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
init_db()
runtime = get_runtime_settings()
configure_logging(

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,
@@ -35,12 +41,13 @@ from ..db import (
delete_user_activity_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_email,
set_user_invite_management_enabled,
set_invite_management_enabled_for_non_admin_users,
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,
@@ -72,6 +79,8 @@ from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
@@ -79,12 +88,15 @@ from ..services.user_cache import (
save_jellyseerr_users_cache,
clear_user_import_caches,
)
from ..security import validate_password_policy
from ..services.invite_email import (
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
get_invite_email_templates,
normalize_delivery_email,
reset_invite_email_template,
save_invite_email_template,
send_test_email,
smtp_email_delivery_warning,
send_templated_email,
smtp_email_config_ready,
)
@@ -99,6 +111,16 @@ events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=400,
detail="recipient_email is required and must be a valid email address",
)
SENSITIVE_KEYS = {
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
@@ -208,6 +230,10 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
]
@@ -809,8 +835,12 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
if matched_email:
set_user_email(username, matched_email)
updated += 1
else:
skipped += 1
@@ -847,10 +877,12 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
username = _pick_jellyseerr_username(user)
if not username:
continue
email = extract_jellyseerr_user_email(user)
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
email=email,
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
@@ -858,6 +890,8 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
if email:
set_user_email(username, email)
return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync")
@@ -1005,6 +1039,7 @@ async def requests_all(
take: int = 50,
skip: int = 0,
days: Optional[int] = None,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if user.get("role") != "admin":
@@ -1014,8 +1049,9 @@ async def requests_all(
since_iso = None
if days is not None and int(days) > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso)
total = get_cached_requests_count(since_iso=since_iso)
status_codes = requests_router.request_stage_filter_codes(stage)
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso, status_codes=status_codes)
total = get_cached_requests_count(since_iso=since_iso, status_codes=status_codes)
results = []
for row in rows:
status = row.get("status")
@@ -1445,13 +1481,17 @@ async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
@router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
if not isinstance(new_password, str):
raise HTTPException(status_code=400, detail="Invalid payload")
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = get_user_by_username(username)
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 +1508,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 +1731,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()),
}
@@ -1760,7 +1801,7 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
if invite is None:
invite = _resolve_user_invite(user)
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _require_recipient_email(payload.get("recipient_email"))
message = _normalize_optional_text(payload.get("message"))
reason = _normalize_optional_text(payload.get("reason"))
@@ -1810,7 +1851,7 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _require_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email"))
delivery_message = _normalize_optional_text(payload.get("message"))
try:

View File

@@ -18,8 +18,8 @@ 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_email,
set_user_auth_provider,
get_signup_invite_by_code,
get_signup_invite_by_id,
@@ -35,30 +35,64 @@ 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 (
PASSWORD_POLICY_MESSAGE,
create_access_token,
validate_password_policy,
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,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
from ..services.invite_email import send_templated_email
from ..services.invite_email import (
normalize_delivery_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.password_reset import (
PasswordResetUnavailableError,
apply_password_reset,
request_password_reset,
verify_password_reset_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
PASSWORD_RESET_GENERIC_MESSAGE = (
"If an account exists for that username or email, a password reset link has been sent."
)
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="recipient_email is required and must be a valid email address.",
)
def _auth_client_ip(request: Request) -> str:
@@ -77,6 +111,10 @@ def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>"
def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
@@ -162,6 +200,57 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
)
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many password reset attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
@@ -210,6 +299,13 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _extract_jellyseerr_response_email(response: dict) -> str | None:
if not isinstance(response, dict):
return None
user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
return extract_jellyseerr_user_email(user_payload)
def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
@@ -223,6 +319,11 @@ def _extract_http_error_detail(exc: Exception) -> str:
return str(exc)
def _requested_user_agent(request: Request) -> str:
user_agent = request.headers.get("user-agent", "")
return user_agent[:512]
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
try:
users = await client.get_users()
@@ -555,6 +656,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
user = preferred_match or get_user_by_username(username)
matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
@@ -583,7 +686,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
if not preferred_match:
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin")
create_user_if_missing(
canonical_username,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
)
elif (
user
and str(user.get("role") or "user").strip().lower() != "admin"
@@ -591,6 +700,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
):
set_user_auth_provider(canonical_username, "jellyfin")
user = get_user_by_username(canonical_username)
if matched_email:
set_user_email(canonical_username, matched_email)
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
try:
@@ -599,7 +710,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:
@@ -646,6 +757,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
jellyseerr_email = _extract_jellyseerr_response_email(response)
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
@@ -654,13 +766,22 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
canonical_username,
"jellyseerr-user",
role="user",
email=jellyseerr_email,
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
elif (
preferred_match
and str(preferred_match.get("role") or "user").strip().lower() != "admin"
and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
):
set_user_auth_provider(canonical_username, "jellyseerr")
user = get_user_by_username(canonical_username)
_assert_user_can_login(user)
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
@@ -721,11 +842,10 @@ async def signup(payload: dict) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
try:
password_value = validate_password_policy(password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
@@ -772,7 +892,6 @@ async def signup(payload: dict) -> dict:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings()
password_value = password.strip()
auth_provider = "local"
local_password_value = password_value
matched_jellyseerr_user_id: int | None = None
@@ -781,7 +900,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:
@@ -825,6 +944,7 @@ async def signup(payload: dict) -> dict:
username,
local_password_value,
role=role,
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled,
@@ -838,7 +958,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
@@ -880,6 +1000,103 @@ async def signup(payload: dict) -> dict:
}
@router.post("/password/forgot")
async def forgot_password(payload: dict, request: Request) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
if not isinstance(identifier, str) or not identifier.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
_enforce_password_reset_rate_limit(request, identifier)
_record_password_reset_attempt(request, identifier)
ready, detail = smtp_email_config_ready()
if not ready:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Password reset email is unavailable: {detail}",
)
client_ip = _auth_client_ip(request)
safe_identifier = identifier.strip().lower()[:256]
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
try:
reset_result = await request_password_reset(
identifier,
requested_by_ip=client_ip,
requested_user_agent=_requested_user_agent(request),
)
if reset_result.get("issued"):
logger.info(
"password reset issued username=%s provider=%s recipient=%s client=%s",
reset_result.get("username"),
reset_result.get("auth_provider"),
reset_result.get("recipient_email"),
client_ip,
)
else:
logger.info(
"password reset request completed with no eligible account identifier=%s client=%s",
safe_identifier,
client_ip,
)
except Exception as exc:
logger.warning(
"password reset email dispatch failed identifier=%s client=%s detail=%s",
safe_identifier,
client_ip,
str(exc),
)
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
@router.get("/password/reset/verify")
async def password_reset_verify(token: str) -> dict:
if not isinstance(token, str) or not token.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
try:
return verify_password_reset_token(token.strip())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/password/reset")
async def password_reset(payload: dict) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
token = payload.get("token")
new_password = payload.get("new_password")
if not isinstance(token, str) or not token.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
if not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
try:
result = await apply_password_reset(token.strip(), new_password_clean)
except PasswordResetUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc:
detail = _extract_http_error_detail(exc)
logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Password reset failed: {detail}",
) from exc
logger.info(
"password reset completed username=%s provider=%s",
result.get("username"),
result.get("provider"),
)
return result
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""
@@ -957,8 +1174,7 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
recipient_email = _require_recipient_email(recipient_email)
send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None
@@ -1048,8 +1264,7 @@ async def update_profile_invite(
label = str(label).strip() or None
if description is not None:
description = str(description).strip() or None
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
recipient_email = _require_recipient_email(recipient_email)
send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None
@@ -1124,21 +1339,24 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
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 +1370,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 +1378,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 +1396,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

@@ -76,6 +76,7 @@ def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
@@ -103,6 +104,7 @@ async def events_stream(
take=recent_take,
skip=0,
days=recent_days,
stage=recent_stage,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
@@ -110,6 +112,7 @@ async def events_stream(
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
@@ -117,6 +120,7 @@ async def events_stream(
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)

View File

@@ -26,7 +26,7 @@ from ..db import (
get_cached_requests,
get_cached_requests_since,
get_cached_request_by_media_id,
get_request_cache_by_id,
get_request_cache_lookup,
get_request_cache_payload,
get_request_cache_last_updated,
get_request_cache_count,
@@ -35,21 +35,28 @@ from ..db import (
repair_request_cache_titles,
prune_duplicate_requests_cache,
upsert_request_cache,
upsert_request_cache_many,
upsert_artwork_cache_status,
upsert_artwork_cache_status_many,
get_artwork_cache_missing_count,
get_artwork_cache_status_count,
get_setting,
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
from ..services.snapshot import build_snapshot, jellyfin_item_matches_request
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
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] = {
@@ -86,6 +93,17 @@ STATUS_LABELS = {
6: "Partially ready",
}
REQUEST_STAGE_CODES = {
"all": None,
"pending": [1],
"approved": [2],
"declined": [3],
"ready": [4],
"working": [5],
"partial": [6],
"in_progress": [2, 5, 6],
}
def _cache_get(key: str) -> Optional[Dict[str, Any]]:
cached = _detail_cache.get(key)
@@ -102,12 +120,119 @@ def _cache_set(key: str, payload: Dict[str, Any]) -> None:
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
if not jellyfin_available:
return _status_label(current_status)
try:
status_code = int(current_status)
except (TypeError, ValueError):
status_code = None
if status_code == 6:
return STATUS_LABELS[6]
return STATUS_LABELS[4]
async def _request_is_available_in_jellyfin(
jellyfin: JellyfinClient,
title: Optional[str],
year: Optional[int],
media_type: Optional[str],
request_payload: Optional[Dict[str, Any]],
availability_cache: Dict[str, bool],
) -> bool:
if not jellyfin.configured() or not title:
return False
cache_key = f"{media_type or ''}:{title.lower()}:{year or ''}:{request_payload.get('id') if isinstance(request_payload, dict) else ''}"
cached_value = availability_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title, types, limit=50)
except Exception:
availability_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
request_type = RequestType.movie if media_type == "movie" else RequestType.tv
for item in items:
if not isinstance(item, dict):
continue
if jellyfin_item_matches_request(
item,
title=title,
year=year,
request_type=request_type,
request_payload=request_payload,
):
availability_cache[cache_key] = True
return True
availability_cache[cache_key] = False
return False
_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:
if isinstance(value, int):
return STATUS_LABELS.get(value, f"Status {value}")
return "Unknown"
def normalize_request_stage_filter(value: Optional[str]) -> str:
if not isinstance(value, str):
return "all"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
if not normalized:
return "all"
if normalized in {"processing", "inprogress"}:
normalized = "in_progress"
return normalized if normalized in REQUEST_STAGE_CODES else "all"
def request_stage_filter_codes(value: Optional[str]) -> Optional[list[int]]:
normalized = normalize_request_stage_filter(value)
codes = REQUEST_STAGE_CODES.get(normalized)
return list(codes) if codes else None
def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
@@ -339,26 +464,55 @@ def _upsert_artwork_status(
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> None:
record = _build_artwork_status_record(payload, cache_mode, poster_cached, backdrop_cached)
if not record:
return
upsert_artwork_cache_status(**record)
def _build_request_cache_record(payload: Dict[str, Any], request_payload: Dict[str, Any]) -> Dict[str, Any]:
return {
"request_id": payload.get("request_id"),
"media_id": payload.get("media_id"),
"media_type": payload.get("media_type"),
"status": payload.get("status"),
"title": payload.get("title"),
"year": payload.get("year"),
"requested_by": payload.get("requested_by"),
"requested_by_norm": payload.get("requested_by_norm"),
"requested_by_id": payload.get("requested_by_id"),
"created_at": payload.get("created_at"),
"updated_at": payload.get("updated_at"),
"payload_json": json.dumps(request_payload, ensure_ascii=True),
}
def _build_artwork_status_record(
payload: Dict[str, Any],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> Optional[Dict[str, Any]]:
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if not isinstance(request_id, int):
return
return None
tmdb_id, media_type = _extract_tmdb_lookup(payload)
poster_path, backdrop_path = _extract_artwork_paths(payload)
has_tmdb = bool(tmdb_id and media_type)
poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
)
upsert_artwork_cache_status(
request_id=request_id,
tmdb_id=tmdb_id,
media_type=media_type,
poster_path=poster_path,
backdrop_path=backdrop_path,
has_tmdb=has_tmdb,
poster_cached=poster_cached_flag,
backdrop_cached=backdrop_cached_flag,
)
return {
"request_id": request_id,
"tmdb_id": tmdb_id,
"media_type": media_type,
"poster_path": poster_path,
"backdrop_path": backdrop_path,
"has_tmdb": has_tmdb,
"poster_cached": poster_cached_flag,
"backdrop_cached": backdrop_cached_flag,
}
def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
@@ -383,9 +537,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 +550,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:
return None, None
try:
if media_type == "movie":
details = await client.get_movie(int(tmdb_id))
if isinstance(details, dict):
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):
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:
details = await _get_media_details(client, media_type, tmdb_id)
if not isinstance(details, dict):
return None, None
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 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
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
return (
details.get("posterPath") or details.get("poster_path"),
details.get("backdropPath") or details.get("backdrop_path"),
)
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
@@ -530,6 +713,16 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
if not isinstance(items, list) or not items:
logger.info("Seerr sync completed: no more results at skip=%s", skip)
break
page_request_ids = [
payload.get("request_id")
for item in items
if isinstance(item, dict)
for payload in [_parse_request_payload(item)]
if isinstance(payload.get("request_id"), int)
]
cached_by_request_id = get_request_cache_lookup(page_request_ids)
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
@@ -537,10 +730,9 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
request_id = payload.get("request_id")
cached_title = None
if isinstance(request_id, int):
if not payload.get("title"):
cached = get_request_cache_by_id(request_id)
if cached and cached.get("title"):
cached_title = cached.get("title")
cached = cached_by_request_id.get(request_id)
if not payload.get("title") and cached and cached.get("title"):
cached_title = cached.get("title")
needs_details = (
not payload.get("title")
or not payload.get("media_id")
@@ -571,25 +763,17 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int):
continue
payload_json = json.dumps(item, ensure_ascii=True)
upsert_request_cache(
request_id=payload.get("request_id"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
status=payload.get("status"),
title=payload.get("title"),
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
page_cache_records.append(_build_request_cache_record(payload, item))
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
artwork_record = _build_artwork_status_record(item, cache_mode)
if artwork_record:
page_artwork_records.append(artwork_record)
stored += 1
_sync_state["stored"] = stored
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
if len(items) < take:
logger.info("Seerr sync completed: stored=%s", stored)
break
@@ -648,6 +832,16 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if not isinstance(items, list) or not items:
logger.info("Seerr delta sync completed: no more results at skip=%s", skip)
break
page_request_ids = [
payload.get("request_id")
for item in items
if isinstance(item, dict)
for payload in [_parse_request_payload(item)]
if isinstance(payload.get("request_id"), int)
]
cached_by_request_id = get_request_cache_lookup(page_request_ids)
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
page_changed = False
for item in items:
if not isinstance(item, dict):
@@ -655,7 +849,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload = _parse_request_payload(item)
request_id = payload.get("request_id")
if isinstance(request_id, int):
cached = get_request_cache_by_id(request_id)
cached = cached_by_request_id.get(request_id)
incoming_updated = payload.get("updated_at")
cached_title = cached.get("title") if cached else None
if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
@@ -689,26 +883,18 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int):
continue
payload_json = json.dumps(item, ensure_ascii=True)
upsert_request_cache(
request_id=payload.get("request_id"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
status=payload.get("status"),
title=payload.get("title"),
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
page_cache_records.append(_build_request_cache_record(payload, item))
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
artwork_record = _build_artwork_status_record(item, cache_mode)
if artwork_record:
page_artwork_records.append(artwork_record)
stored += 1
page_changed = True
_sync_state["stored"] = stored
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
if not page_changed:
unchanged_pages += 1
else:
@@ -793,6 +979,8 @@ async def _prefetch_artwork_cache(
batch = get_request_cache_payloads(limit=limit, offset=offset)
if not batch:
break
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
for row in batch:
payload = row.get("payload")
if not isinstance(payload, dict):
@@ -820,20 +1008,7 @@ async def _prefetch_artwork_cache(
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if isinstance(request_id, int):
upsert_request_cache(
request_id=request_id,
media_id=parsed.get("media_id"),
media_type=parsed.get("media_type"),
status=parsed.get("status"),
title=parsed.get("title"),
year=parsed.get("year"),
requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"),
requested_by_id=parsed.get("requested_by_id"),
created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True),
)
page_cache_records.append(_build_request_cache_record(parsed, payload))
poster_cached_flag = False
backdrop_cached_flag = False
if poster_path:
@@ -848,17 +1023,23 @@ async def _prefetch_artwork_cache(
backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError:
backdrop_cached_flag = False
_upsert_artwork_status(
artwork_record = _build_artwork_status_record(
payload,
cache_mode,
poster_cached=poster_cached_flag if poster_path else None,
backdrop_cached=backdrop_cached_flag if backdrop_path else None,
)
if artwork_record:
page_artwork_records.append(artwork_record)
processed += 1
if processed % 25 == 0:
_artwork_prefetch_state.update(
{"processed": processed, "message": f"Cached artwork for {processed} requests"}
)
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
offset += limit
total_requests = get_request_cache_count()
@@ -990,6 +1171,7 @@ def _get_recent_from_cache(
limit: int,
offset: int,
since_iso: Optional[str],
status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
results = []
@@ -1005,6 +1187,8 @@ def _get_recent_from_cache(
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
if status_codes and item.get("status") not in status_codes:
continue
results.append(item)
return results[offset : offset + limit]
@@ -1162,23 +1346,9 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str]
) -> None:
if user.get("role") == "admin":
if user.get("role") == "admin" or user.get("username"):
return
runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id)
if mode != "always_js":
if cached is None:
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
raise HTTPException(status_code=404, detail="Request not found in cache")
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")):
return
raise HTTPException(status_code=403, detail="Request not accessible for this user")
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
details = await _get_request_details(client, request_id)
if details is None or not _request_matches_user(details, user.get("username", "")):
raise HTTPException(status_code=403, detail="Request not accessible for this user")
raise HTTPException(status_code=403, detail="Request not accessible for this user")
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
@@ -1448,6 +1618,7 @@ async def recent_requests(
take: int = 6,
skip: int = 0,
days: int = 90,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user),
) -> dict:
runtime = get_runtime_settings()
@@ -1469,44 +1640,22 @@ async def recent_requests(
since_iso = None
if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
status_codes = request_stage_filter_codes(stage)
if _recent_cache_stale():
_refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso)
rows = _get_recent_from_cache(
requested_by,
requested_by_id,
take,
skip,
since_iso,
status_codes=status_codes,
)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False
allow_artwork_hydrate = client.configured()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
async def _jellyfin_available(
title_value: Optional[str], year_value: Optional[int], media_type_value: Optional[str]
) -> bool:
if not jellyfin.configured() or not title_value:
return False
cache_key = f"{media_type_value or ''}:{title_value.lower()}:{year_value or ''}"
cached_value = jellyfin_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type_value == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title_value, types)
except Exception:
jellyfin_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
for item in items:
if not isinstance(item, dict):
continue
name = item.get("Name") or item.get("title")
year = item.get("ProductionYear") or item.get("Year")
if name and name.strip().lower() == title_value.strip().lower():
if year_value and year and int(year) != int(year_value):
continue
jellyfin_cache[cache_key] = True
return True
jellyfin_cache[cache_key] = False
return False
results = []
for row in rows:
status = row.get("status")
@@ -1601,10 +1750,16 @@ async def recent_requests(
payload_json=json.dumps(details, ensure_ascii=True),
)
status_label = _status_label(status)
if status_label == "Working on it":
is_available = await _jellyfin_available(title, year, row.get("media_type"))
if is_available:
status_label = "Available"
if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
is_available = await _request_is_available_in_jellyfin(
jellyfin,
title,
year,
row.get("media_type"),
details if isinstance(details, dict) else None,
jellyfin_cache,
)
status_label = _status_label_with_jellyfin(status, is_available)
results.append(
{
"id": row.get("request_id"),
@@ -1648,6 +1803,8 @@ async def search_requests(
pass
results = []
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
for item in response.get("results", []):
media_type = item.get("mediaType")
title = item.get("title") or item.get("name")
@@ -1660,6 +1817,8 @@ async def search_requests(
request_id = None
status = None
status_label = None
requested_by = None
accessible = False
media_info = item.get("mediaInfo") or {}
media_info_id = media_info.get("id")
requests = media_info.get("requests")
@@ -1668,27 +1827,31 @@ async def search_requests(
status = requests[0].get("status")
status_label = _status_label(status)
elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id(
media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
)
if cached:
request_id = cached.get("request_id")
status = cached.get("status")
status_label = _status_label(status)
if user.get("role") != "admin":
if isinstance(request_id, int):
if isinstance(request_id, int):
details = get_request_cache_payload(request_id)
if not isinstance(details, dict):
details = await _get_request_details(client, request_id)
if not _request_matches_user(details, user.get("username", "")):
continue
else:
continue
if user.get("role") == "admin":
requested_by = _request_display_name(details)
accessible = True
if status is not None:
is_available = await _request_is_available_in_jellyfin(
jellyfin,
title,
year,
media_type,
details if isinstance(details, dict) else None,
jellyfin_cache,
)
status_label = _status_label_with_jellyfin(status, is_available)
results.append(
{
@@ -1699,6 +1862,8 @@ async def search_requests(
"requestId": request_id,
"status": status,
"statusLabel": status_label,
"requestedBy": requested_by,
"accessible": accessible,
}
)

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,15 +18,21 @@ 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,
"tone": tone,
},
"login": {
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
"showSignupLink": bool(runtime.site_login_show_signup_link),
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
info["changelog"] = (CHANGELOG or "").strip()
return info

View File

@@ -4,6 +4,12 @@ from .db import get_settings_overrides
_INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"auth_rate_limit_window_seconds",
"auth_rate_limit_max_attempts_ip",
"auth_rate_limit_max_attempts_user",
"password_reset_rate_limit_window_seconds",
"password_reset_rate_limit_max_attempts_ip",
"password_reset_rate_limit_max_attempts_identifier",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"jwt_exp_minutes",
@@ -29,6 +35,10 @@ _BOOL_FIELDS = {
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr",
"site_banner_enabled",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}

View File

@@ -1,13 +1,16 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
@@ -18,6 +21,13 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
def validate_password_policy(password: str) -> str:
candidate = password.strip()
if len(candidate) < MIN_PASSWORD_LENGTH:
raise ValueError(PASSWORD_POLICY_MESSAGE)
return candidate
def _create_token(
subject: str,
role: str,
@@ -55,5 +65,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
except JWTError as exc:
except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc

View File

@@ -16,9 +16,9 @@ from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import run_integrity_check
from ..db import get_database_diagnostics
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]]]
@@ -205,12 +205,16 @@ async def _run_http_post(
async def _run_database_check() -> Dict[str, Any]:
integrity = await asyncio.to_thread(run_integrity_check)
detail = await asyncio.to_thread(get_database_diagnostics)
integrity = _clean_text(detail.get("integrity_check"), "unknown")
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
status = "up" if integrity == "ok" else "degraded"
return {
"status": status,
"message": f"SQLite integrity_check returned {integrity}",
"detail": integrity,
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
"detail": detail,
}
@@ -302,6 +306,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 +436,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 +551,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),
),

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,15 @@ from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_email,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
@@ -41,10 +44,13 @@ async def sync_jellyfin_users() -> int:
if not name:
continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
created = create_user_if_missing(
name,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
jellyseerr_user_id=matched_id,
)
@@ -60,6 +66,8 @@ async def sync_jellyfin_users() -> int:
set_user_auth_provider(name, "jellyfin")
if matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
if matched_email:
set_user_email(name, matched_email)
return imported

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..db import (
create_password_reset_token,
delete_expired_password_reset_tokens,
get_password_reset_token,
get_user_by_jellyseerr_id,
get_user_by_username,
get_users_by_username_ci,
mark_password_reset_token_used,
set_user_auth_provider,
set_user_password,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from .invite_email import send_password_reset_email
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
logger = logging.getLogger(__name__)
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
class PasswordResetUnavailableError(RuntimeError):
pass
def _normalize_handles(value: object) -> list[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
if not users:
return None
requested = str(requested_identifier or "").strip().lower()
def _rank(user: dict) -> tuple[int, int, int, int]:
provider = str(user.get("auth_provider") or "local").strip().lower()
role = str(user.get("role") or "user").strip().lower()
username = str(user.get("username") or "").strip().lower()
return (
0 if role == "admin" else 1,
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
0 if username == requested else 1,
)
return sorted(users, key=_rank)[0]
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
target_handles = set(_normalize_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email"):
value = user.get(key)
if target_handles.intersection(_normalize_handles(value)):
return user
return None
async def _fetch_all_seerr_users() -> list[dict]:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return []
users: list[dict] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
if isinstance(local_user, dict):
stored_email = str(local_user.get("email") or "").strip()
if "@" in stored_email:
return stored_email
username = str(local_user.get("username") or "").strip()
if "@" in username:
return username
if isinstance(seerr_user, dict):
email = str(seerr_user.get("email") or "").strip()
if "@" in email:
return email
return None
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
normalized_identifier = str(identifier or "").strip()
if not normalized_identifier:
return None
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
)
seerr_users: list[dict] | None = None
seerr_user: dict | None = None
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_users = await _fetch_all_seerr_users()
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not local_user:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
if seerr_user:
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
try:
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
except (TypeError, ValueError):
seerr_user_id = None
if seerr_user_id is not None:
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
if not local_user:
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
if not isinstance(candidate, str) or not candidate.strip():
continue
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
)
if local_user:
break
if not local_user:
return None
auth_provider = resolve_user_auth_provider(local_user)
username = str(local_user.get("username") or "").strip()
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
if isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not seerr_user:
seerr_user = _find_matching_seerr_user(username, seerr_users)
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
return None
if auth_provider == "jellyseerr":
runtime = get_runtime_settings()
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
try:
jellyfin_user = await jellyfin_client.find_user_by_name(username)
except Exception:
jellyfin_user = None
if isinstance(jellyfin_user, dict):
auth_provider = "jellyfin"
if auth_provider not in {"local", "jellyfin"}:
return None
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
}
def _token_record_is_usable(record: Optional[dict]) -> bool:
if not isinstance(record, dict):
return False
if record.get("is_used"):
return False
if record.get("is_expired"):
return False
return True
def _mask_email(email: str) -> str:
candidate = str(email or "").strip()
if "@" not in candidate:
return "valid reset link"
local_part, domain = candidate.split("@", 1)
if not local_part:
return f"***@{domain}"
if len(local_part) == 1:
return f"{local_part}***@{domain}"
return f"{local_part[0]}***{local_part[-1]}@{domain}"
async def request_password_reset(
identifier: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier)
if not target:
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
create_password_reset_token(
token,
target["username"],
target["recipient_email"],
target["auth_provider"],
expires_at,
requested_by_ip=requested_by_ip,
requested_user_agent=requested_user_agent,
)
await send_password_reset_email(
recipient_email=target["recipient_email"],
username=target["username"],
token=token,
expires_at=expires_at,
auth_provider=target["auth_provider"],
)
return {
"status": "ok",
"issued": True,
"username": target["username"],
"recipient_email": target["recipient_email"],
"auth_provider": target["auth_provider"],
"expires_at": expires_at,
}
def verify_password_reset_token(token: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
return {
"status": "ok",
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
"auth_provider": record.get("auth_provider"),
"expires_at": record.get("expires_at"),
}
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
username = str(record.get("username") or "").strip()
if not username:
raise ValueError("Password reset link is invalid or has expired.")
stored_user = normalize_user_auth_provider(get_user_by_username(username))
if not stored_user:
raise ValueError("Password reset link is invalid or has expired.")
auth_provider = resolve_user_auth_provider(stored_user)
if auth_provider == "jellyseerr":
auth_provider = "jellyfin"
if auth_provider == "local":
set_user_password(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
set_user_auth_provider(username, "local")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=local", username)
return {"status": "ok", "provider": "local", "username": username}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
jellyfin_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jellyfin_user)
if not user_id:
raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin", "username": username}
raise ValueError("Password reset is not available for this sign-in provider.")

View File

@@ -1,8 +1,10 @@
from typing import Any, Dict, List, Optional
import asyncio
import logging
import re
from datetime import datetime, timezone
from urllib.parse import quote
import httpx
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellyfin import JellyfinClient
@@ -18,6 +20,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 +58,153 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
def _normalize_media_title(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
return normalized or None
def _canonical_provider_key(value: str) -> str:
normalized = value.strip().lower()
if normalized.endswith("id"):
normalized = normalized[:-2]
return normalized
def extract_request_provider_ids(payload: Any) -> Dict[str, str]:
provider_ids: Dict[str, str] = {}
candidates: List[Any] = []
if isinstance(payload, dict):
candidates.append(payload)
media = payload.get("media")
if isinstance(media, dict):
candidates.append(media)
for candidate in candidates:
if not isinstance(candidate, dict):
continue
embedded = candidate.get("ProviderIds") or candidate.get("providerIds")
if isinstance(embedded, dict):
for key, value in embedded.items():
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(str(key))] = text
for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"):
value = candidate.get(key)
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(key)] = text
return provider_ids
def jellyfin_item_matches_request(
item: Dict[str, Any],
*,
title: Optional[str],
year: Optional[int],
request_type: RequestType,
request_payload: Optional[Dict[str, Any]] = None,
) -> bool:
request_provider_ids = extract_request_provider_ids(request_payload or {})
item_provider_ids = extract_request_provider_ids(item)
provider_priority = ("tmdb", "tvdb", "imdb")
for key in provider_priority:
request_id = request_provider_ids.get(key)
item_id = item_provider_ids.get(key)
if request_id and item_id and request_id == item_id:
return True
request_title = _normalize_media_title(title)
if not request_title:
return False
item_titles = [
_normalize_media_title(item.get("Name")),
_normalize_media_title(item.get("OriginalTitle")),
_normalize_media_title(item.get("SortName")),
_normalize_media_title(item.get("SeriesName")),
_normalize_media_title(item.get("title")),
]
item_titles = [candidate for candidate in item_titles if candidate]
item_year = item.get("ProductionYear") or item.get("Year")
try:
item_year_value = int(item_year) if item_year is not None else None
except (TypeError, ValueError):
item_year_value = None
if year and item_year_value and int(year) != item_year_value:
return False
if request_title in item_titles:
return True
if request_type == RequestType.tv:
for candidate in item_titles:
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
return True
return False
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,33 +452,22 @@ 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:
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
if isinstance(details, dict):
if snapshot.request_type == RequestType.movie:
details = await jellyseerr.get_movie(int(tmdb_id))
if isinstance(details, dict):
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")
)
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
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
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")
)
except Exception:
pass
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
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")
)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
snapshot.artwork = {
@@ -467,7 +608,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
if jellyfin.configured() and snapshot.title:
types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"]
try:
search = await jellyfin.search_items(snapshot.title, types)
search = await jellyfin.search_items(snapshot.title, types, limit=50)
except Exception:
search = None
if isinstance(search, dict):
@@ -475,11 +616,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
for item in items:
if not isinstance(item, dict):
continue
name = item.get("Name") or item.get("title")
year = item.get("ProductionYear") or item.get("Year")
if name and name.strip().lower() == (snapshot.title or "").strip().lower():
if snapshot.year and year and int(year) != int(snapshot.year):
continue
if jellyfin_item_matches_request(
item,
title=snapshot.title,
year=snapshot.year,
request_type=snapshot.request_type,
request_payload=jelly_request,
):
jellyfin_available = True
jellyfin_item = item
break
@@ -600,12 +743,22 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr"
if jellyfin_available and snapshot.state not in {
NormalizedState.downloading,
NormalizedState.importing,
}:
snapshot.state = NormalizedState.completed
snapshot.state_reason = "Ready to watch in Jellyfin."
if jellyfin_available:
missing_episodes = arr_details.get("missingEpisodes")
if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes:
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Partially ready"
else:
snapshot.state = NormalizedState.completed
snapshot.state_reason = "Ready to watch in Jellyfin."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Available"
elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}:
hop.status = "available"
snapshot.timeline = timeline
actions: List[ActionOption] = []

View File

@@ -89,6 +89,33 @@ def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int
return candidate_to_id
def find_matching_jellyseerr_user(
identifier: str, users: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
target_handles = set(_normalized_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email", "displayName", "name"):
if target_handles.intersection(_normalized_handles(user.get(key))):
return user
return None
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(user, dict):
return None
value = user.get("email")
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int]
) -> Optional[int]:

View File

@@ -3,7 +3,7 @@ uvicorn==0.41.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.13.1
python-jose[cryptography]==3.5.0
PyJWT==2.11.0
passlib==1.7.4
python-multipart==0.0.22
Pillow==12.1.1

View File

@@ -0,0 +1,145 @@
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
from backend.app.routers import auth as auth_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/auth/password/forgot",
"raw_path": b"/auth/password/forgot",
"query_string": b"",
"headers": [(b"user-agent", user_agent.encode("utf-8"))],
"client": (ip, 12345),
"server": ("testserver", 8000),
}
async def receive() -> dict:
return {"type": "http.request", "body": b"", "more_body": False}
return Request(scope, receive)
class TempDatabaseMixin:
def setUp(self) -> None:
super_method = getattr(super(), "setUp", None)
if callable(super_method):
super_method()
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self._original_sqlite_path = settings.sqlite_path
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
settings.sqlite_journal_mode = "DELETE"
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
db.init_db()
def tearDown(self) -> None:
settings.sqlite_path = self._original_sqlite_path
settings.sqlite_journal_mode = self._original_journal_mode
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
auth_router._RESET_ATTEMPTS_BY_IP.clear()
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None)
if callable(super_method):
super_method()
class PasswordPolicyTests(unittest.TestCase):
def test_validate_password_policy_rejects_short_passwords(self) -> None:
with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password123 "), "password123")
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(
"MixedCaseUser",
"password123",
email=None,
auth_provider="local",
)
self.assertTrue(created)
updated = db.set_user_email("mixedcaseuser", "mixed@example.com")
self.assertTrue(updated)
stored = db.get_user_by_username("MIXEDCASEUSER")
self.assertIsNotNone(stored)
self.assertEqual(stored.get("email"), "mixed@example.com")
class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_forgot_password_is_rate_limited(self) -> None:
request = _build_request(ip="10.1.2.3")
payload = {"identifier": "resetuser@example.com"}
with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
auth_router,
"request_password_reset",
new=AsyncMock(return_value={"status": "ok", "issued": False}),
):
for _ in range(3):
result = await auth_router.forgot_password(payload, request)
self.assertEqual(result["status"], "ok")
with self.assertRaises(HTTPException) as context:
await auth_router.forgot_password(payload, request)
self.assertEqual(context.exception.status_code, 429)
self.assertEqual(
context.exception.detail,
"Too many password reset attempts. Try again shortly.",
)
async def test_request_password_reset_prefers_local_user_email(self) -> None:
db.create_user_if_missing(
"ResetUser",
"password123",
email="local@example.com",
auth_provider="local",
)
with patch.object(
password_reset,
"send_password_reset_email",
new=AsyncMock(return_value={"status": "ok"}),
) as send_email:
result = await password_reset.request_password_reset("ResetUser")
self.assertTrue(result["issued"])
self.assertEqual(result["recipient_email"], "local@example.com")
send_email.assert_awaited_once()
self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
async def test_profile_invite_requires_recipient_email(self) -> None:
current_user = {
"username": "invite-owner",
"role": "user",
"invite_management_enabled": True,
"profile_id": None,
}
with self.assertRaises(HTTPException) as context:
await auth_router.create_profile_invite({"label": "Missing email"}, current_user)
self.assertEqual(context.exception.status_code, 400)
self.assertEqual(
context.exception.detail,
"recipient_email is required and must be a valid email address.",
)

View File

@@ -40,6 +40,10 @@ const SECTION_LABELS: Record<string, string> = {
const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'site_banner_enabled',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
@@ -104,7 +108,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, login page visibility, and version details. The changelog is generated from git history during release builds.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -239,6 +243,31 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
]),
}
const SITE_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'site-banner',
title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
},
{
key: 'site-login',
title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
],
},
]
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key',
@@ -280,6 +309,10 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link',
log_file_max_bytes: 'Log file max size (bytes)',
log_file_backup_count: 'Rotated log files to keep',
log_http_client_level: 'Service HTTP log level',
@@ -551,11 +584,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const isSiteGroupedSection = section === 'site'
const visibleSections = settingsSection ? [settingsSection] : []
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',
@@ -563,6 +598,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
'requests_cleanup_time',
'requests_cleanup_days',
]
const siteSettingOrder = [
'site_banner_enabled',
'site_banner_message',
'site_banner_tone',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => {
@@ -602,18 +646,37 @@ export default function SettingsPage({ section }: SettingsPageProps) {
})
return groups
})()
: isSiteGroupedSection
? (() => {
const siteItems = groupedSettings.site ?? []
const byKey = new Map(siteItems.map((item) => [item.key, item]))
return SITE_SECTION_GROUPS.map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
})()
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
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') {
return sortByOrder(filtered, requestSettingOrder)
}
if (sectionKey === 'site') {
return sortByOrder(filtered, siteSettingOrder)
}
return filtered
})(),
}))
@@ -747,6 +810,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.',
site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
site_login_show_signup_link: 'Show the invite signup link on the login page.',
site_changelog: 'One update per line for the public changelog.',
}
@@ -940,8 +1007,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 +1682,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}
@@ -1669,7 +1738,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)}
</div>
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
(!settingsSection || isMagentGroupedSection) && (
(!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
@@ -2145,11 +2214,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isPemField =
setting.key === 'magent_ssl_certificate_pem' ||
setting.key === 'magent_ssl_private_key_pem'
const shouldSpanFull = isPemField || setting.key === 'site_banner_message'
return (
<label
key={setting.key}
data-helper={helperText || undefined}
className={isPemField ? 'field-span-full' : undefined}
className={shouldSpanFull ? 'field-span-full' : undefined}
>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
@@ -2226,17 +2296,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
/>
</label>
) : null}
<button
type="button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
</button>
{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]}
>
@@ -2245,6 +2308,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: getSectionTestLabel(sectionGroup.key)}
</button>
) : null}
<button
type="button"
className="settings-action-button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
</button>
</div>
</section>
))}
@@ -2257,7 +2328,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 +2354,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 +2383,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 +2450,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

@@ -156,6 +156,8 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString()
}
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
@@ -349,6 +351,17 @@ export default function AdminInviteManagementPage() {
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setError('Recipient email is required.')
setStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
setError('Recipient email must be valid.')
setStatus(null)
return
}
setInviteSaving(true)
setError(null)
setStatus(null)
@@ -363,7 +376,7 @@ export default function AdminInviteManagementPage() {
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
recipient_email: inviteForm.recipient_email || null,
recipient_email: recipientEmail,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}
@@ -1607,18 +1620,19 @@ export default function AdminInviteManagementPage() {
<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>
<small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<span>Recipient email (required)</span>
<input
type="email"
required
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="person@example.com"
placeholder="Required recipient email"
/>
</label>
<label>

View File

@@ -15,6 +15,17 @@ type RequestRow = {
createdAt?: string | null
}
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting for approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working on it' },
{ value: 'partial', label: 'Partially ready' },
{ value: 'ready', label: 'Ready to watch' },
{ value: 'declined', label: 'Declined' },
]
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
@@ -30,6 +41,7 @@ export default function AdminRequestsAllPage() {
const [error, setError] = useState<string | null>(null)
const [pageSize, setPageSize] = useState(50)
const [page, setPage] = useState(1)
const [stage, setStage] = useState('all')
const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1
@@ -46,8 +58,15 @@ export default function AdminRequestsAllPage() {
try {
const baseUrl = getApiBase()
const skip = (page - 1) * pageSize
const params = new URLSearchParams({
take: String(pageSize),
skip: String(skip),
})
if (stage !== 'all') {
params.set('stage', stage)
}
const response = await authFetch(
`${baseUrl}/admin/requests/all?take=${pageSize}&skip=${skip}`
`${baseUrl}/admin/requests/all?${params.toString()}`
)
if (!response.ok) {
if (response.status === 401) {
@@ -74,7 +93,7 @@ export default function AdminRequestsAllPage() {
useEffect(() => {
void load()
}, [page, pageSize])
}, [page, pageSize, stage])
useEffect(() => {
if (page > pageCount) {
@@ -82,6 +101,10 @@ export default function AdminRequestsAllPage() {
}
}, [pageCount, page])
useEffect(() => {
setPage(1)
}, [stage])
return (
<AdminShell
title="All requests"
@@ -98,6 +121,16 @@ export default function AdminRequestsAllPage() {
<span>{total.toLocaleString()} total</span>
</div>
<div className="admin-toolbar-actions">
<label className="admin-select">
<span>Stage</span>
<select value={stage} onChange={(e) => setStage(e.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-select">
<span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>

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 (
<ul className="changelog-list">
{entries.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
<div className="changelog-groups">
{groups.map((group) => (
<section key={group.date} className="changelog-group">
<h2>{group.date}</h2>
<ul className="changelog-list">
{group.entries.map((entry, index) => (
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
))}
</ul>
</section>
))}
</ul>
</div>
)
}, [entries, loading])
}, [groups, loading])
return (
<div className="page">

View File

@@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { getApiBase } from '../lib/auth'
export default function ForgotPasswordPage() {
const router = useRouter()
const [identifier, setIdentifier] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (!identifier.trim()) {
setError('Enter your username or email.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: identifier.trim() }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.')
}
setStatus(
typeof data?.message === 'string'
? data.message
: 'If an account exists for that username or email, a password reset link has been sent.',
)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to send reset link.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Forgot password</h1>
<p className="lede">
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
will be emailed to you.
</p>
<form className="auth-form" onSubmit={submit}>
<label>
Username or email
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
autoComplete="username"
placeholder="you@example.com"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
Back to sign in
</button>
</form>
</main>
)
}

View File

@@ -1527,6 +1527,13 @@ button span {
color: var(--ink-muted);
}
.recent-filter-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.recent-filter select {
padding: 8px 12px;
font-size: 13px;
@@ -1545,6 +1552,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 +2376,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 +3701,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 +5272,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 +5462,10 @@ textarea {
}
@media (max-width: 980px) {
.profile-quick-link-card {
display: grid;
}
.profile-invites-layout {
grid-template-columns: 1fr;
}
@@ -6000,6 +6075,52 @@ textarea {
background: rgba(255, 255, 255, 0.03);
}
.diagnostic-detail-panel {
display: grid;
gap: 0.9rem;
}
.diagnostic-detail-group {
display: grid;
gap: 0.6rem;
}
.diagnostic-detail-group h4 {
margin: 0;
font-size: 0.86rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
}
.diagnostic-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.7rem;
}
.diagnostic-detail-item {
display: grid;
gap: 0.2rem;
min-width: 0;
padding: 0.75rem;
border-radius: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
}
.diagnostic-detail-item span {
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.diagnostic-detail-item strong {
line-height: 1.35;
overflow-wrap: anywhere;
}
.diagnostics-rail-metrics {
display: grid;
gap: 0.75rem;
@@ -6070,3 +6191,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

@@ -1,19 +1,36 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
const DEFAULT_LOGIN_OPTIONS = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
}
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
? 'jellyfin'
: loginOptions.showLocalLogin
? 'local'
: null
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault()
if (!primaryMode) {
setError('Login is currently disabled. Contact an administrator.')
return
}
setError(null)
setLoading(true)
try {
@@ -48,12 +65,63 @@ export default function LoginPage() {
}
}
useEffect(() => {
let active = true
const loadLoginOptions = async () => {
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/site/public`)
if (!response.ok) {
return
}
const data = await response.json()
const login = data?.login ?? {}
if (!active) return
setLoginOptions({
showJellyfinLogin: login.showJellyfinLogin !== false,
showLocalLogin: login.showLocalLogin !== false,
showForgotPassword: login.showForgotPassword !== false,
showSignupLink: login.showSignupLink !== false,
})
} catch (err) {
console.error(err)
}
}
void loadLoginOptions()
return () => {
active = false
}
}, [])
const loginHelpText = (() => {
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
}
if (loginOptions.showJellyfinLogin) {
return 'Use your Jellyfin account to sign in.'
}
if (loginOptions.showLocalLogin) {
return 'Use your local Magent admin account to sign in.'
}
return 'No sign-in methods are currently available. Contact an administrator.'
})()
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with a local Magent admin account.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<p className="lede">{loginHelpText}</p>
<form
onSubmit={(event) => {
if (!primaryMode) {
event.preventDefault()
setError('Login is currently disabled. Contact an administrator.')
return
}
void submit(event, primaryMode)
}}
className="auth-form"
>
<label>
Username
<input
@@ -73,21 +141,35 @@ export default function LoginPage() {
</label>
{error && <div className="error-banner">{error}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
{loginOptions.showJellyfinLogin ? (
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button>
) : null}
</div>
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
<a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent)
</a>
{loginOptions.showLocalLogin ? (
<button
type="button"
className="ghost-button"
disabled={loading}
onClick={(event) => submit(event, 'local')}
>
Sign in with Magent account
</button>
) : null}
{loginOptions.showForgotPassword ? (
<a className="ghost-button" href="/forgot-password">
Forgot password?
</a>
) : null}
{loginOptions.showSignupLink ? (
<a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent)
</a>
) : null}
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null}
</form>
</main>
)

View File

@@ -22,6 +22,17 @@ const normalizeRecentResults = (items: any[]) =>
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
@@ -38,11 +49,20 @@ export default function HomePage() {
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
@@ -143,9 +163,14 @@ export default function HomePage() {
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const response = await authFetch(
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
)
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -167,7 +192,7 @@ export default function HomePage() {
}
load()
}, [recentDays])
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
@@ -222,7 +247,14 @@ export default function HomePage() {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onopen = () => {
@@ -282,7 +314,7 @@ export default function HomePage() {
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays])
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
@@ -299,14 +331,16 @@ export default function HomePage() {
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
}))
)
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
@@ -403,19 +437,34 @@ export default function HomePage() {
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<div className="recent-filter-group">
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="recent-grid">
@@ -467,9 +516,10 @@ export default function HomePage() {
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Find my request</h1>
<h1>Search all requests</h1>
<p className="lede">
Search by title + year, paste a request number, or pick from your recent requests.
Search any request by title + year or request number and see whether it already
exists in the system.
</p>
</div>
<div className="find-controls">
@@ -518,14 +568,16 @@ export default function HomePage() {
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: ''}
: '- already requested'}
</button>
))
)}

View File

@@ -0,0 +1,638 @@
'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()
}
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
export default function ProfileInvitesPage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [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()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setInviteError('Recipient email is required.')
setInviteStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
setInviteError('Recipient email must be valid.')
setInviteStatus(null)
return
}
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: recipientEmail,
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>Recipient email is required. You can also send the invite immediately after saving.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email (required)</span>
<input
type="email"
required
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Required recipient email"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{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(
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 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">
<h1>My profile</h1>
<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

@@ -368,7 +368,14 @@ export default function RequestTimelinePage() {
const jellyfinLink = snapshot.raw?.jellyfin?.link
const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster =
posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
const hasPartialReadyTimeline = snapshot.timeline.some(
(hop) => hop.service === 'Seerr' && hop.status === 'Partially ready'
)
const currentStatusText =
snapshot.state === 'IMPORTING' && hasPartialReadyTimeline
? 'Partially ready'
: friendlyState(snapshot.state)
return (
<main className="card">
@@ -400,7 +407,7 @@ export default function RequestTimelinePage() {
<section className="status-box">
<div>
<h2>Status</h2>
<p className="status-text">{friendlyState(snapshot.state)}</p>
<p className="status-text">{currentStatusText}</p>
</div>
<div>
<h2>What this means</h2>

View File

@@ -0,0 +1,156 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { getApiBase } from '../lib/auth'
type ResetVerification = {
status: string
recipient_hint?: string
auth_provider?: string
expires_at?: string
}
function ResetPasswordPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token') ?? ''
const [verification, setVerification] = useState<ResetVerification | null>(null)
const [loading, setLoading] = useState(false)
const [verifying, setVerifying] = useState(true)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
useEffect(() => {
const verifyToken = async () => {
if (!token) {
setError('Password reset link is invalid or missing.')
setVerifying(false)
return
}
setVerifying(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(
`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`,
)
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Password reset link is invalid.')
}
setVerification(data)
} catch (err) {
console.error(err)
setVerification(null)
setError(err instanceof Error ? err.message : 'Password reset link is invalid.')
} finally {
setVerifying(false)
}
}
void verifyToken()
}, [token])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (!token) {
setError('Password reset link is invalid or missing.')
return
}
if (password.trim().length < 8) {
setError('Password must be at least 8 characters.')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/password/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: password }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to reset password.')
}
setStatus('Password updated. You can now sign in with the new password.')
setPassword('')
setConfirmPassword('')
window.setTimeout(() => router.push('/login'), 1200)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to reset password.')
} finally {
setLoading(false)
}
}
const providerLabel =
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Reset password</h1>
<p className="lede">Choose a new password for your account.</p>
<form className="auth-form" onSubmit={submit}>
{verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && (
<div className="status-banner">
This reset link was sent to {verification.recipient_hint || 'your email'} and will update the password
used for {providerLabel}.
</div>
)}
<label>
New password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading || verifying || !verification}>
{loading ? 'Updating password…' : 'Reset password'}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}>
Back to sign in
</button>
</form>
</main>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading password reset</main>}>
<ResetPasswordPageContent />
</Suspense>
)
}

View File

@@ -56,6 +56,21 @@ type AdminDiagnosticsPanelProps = {
embedded?: boolean
}
type DatabaseDiagnosticDetail = {
integrity_check?: string
database_path?: string
database_size_bytes?: number
wal_size_bytes?: number
shm_size_bytes?: number
page_size_bytes?: number
page_count?: number
freelist_pages?: number
allocated_bytes?: number
free_bytes?: number
row_counts?: Record<string, number>
timings_ms?: Record<string, number>
}
const REFRESH_INTERVAL_MS = 30000
const STATUS_LABELS: Record<string, string> = {
@@ -85,6 +100,54 @@ function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status
}
function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
return '0 B'
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${value} B`
}
function formatDetailLabel(value: string) {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
return null
}
return detail as DatabaseDiagnosticDetail
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
)
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter()
const [loading, setLoading] = useState(true)
@@ -405,6 +468,43 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<span className="system-dot" />
<span>{isRunning ? 'Running diagnostic...' : check.message}</span>
</div>
{check.key === 'database'
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail)
if (!detail) {
return null
}
return (
<div className="diagnostic-detail-panel">
{renderDatabaseMetricGroup('Storage', [
['Database file', formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)],
['Shared memory', formatBytes(detail.shm_size_bytes)],
['Allocated bytes', formatBytes(detail.allocated_bytes)],
['Free bytes', formatBytes(detail.free_bytes)],
['Page size', formatBytes(detail.page_size_bytes)],
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
'Tables',
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
'Timings',
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</div>
)
})()
: null}
</article>
)
})}

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>
<a href="/how-it-works">How it works</a>
{role === 'admin' && <a href="/admin">Settings</a>}
<div className="header-actions-center">
<a href="/how-it-works">How it works</a>
</div>
<div className="header-actions-right">
<a href="/">Requests</a>
<a href="/profile/invites">Invites</a>
</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

@@ -20,6 +20,7 @@ type UserStats = {
type AdminUser = {
id?: number
username: string
email?: string | null
role: string
auth_provider?: string | null
last_login_at?: string | null
@@ -459,6 +460,10 @@ export default function UserDetailPage() {
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Email</span>
<strong>{user.email || 'Not set'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>

View File

@@ -9,6 +9,7 @@ import AdminShell from '../ui/AdminShell'
type AdminUser = {
id: number
username: string
email?: string | null
role: string
authProvider?: string | null
lastLoginAt?: string | null
@@ -109,6 +110,7 @@ export default function UsersPage() {
setUsers(
data.users.map((user: any) => ({
username: user.username ?? 'Unknown',
email: user.email ?? null,
role: user.role ?? 'user',
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
@@ -239,6 +241,7 @@ export default function UsersPage() {
? users.filter((user) => {
const fields = [
user.username,
user.email || '',
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
@@ -419,6 +422,9 @@ export default function UsersPage() {
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<div className="user-directory-subtext">
{user.email || 'No email on file'}
</div>
<div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div>

View File

@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
"version": "0103262231",
"version": "0403261736",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0103262231",
"version": "0403261736",
"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": "0403261736",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -23,3 +23,4 @@

View File

@@ -3,8 +3,13 @@ $ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
if ($LASTEXITCODE -ne 0) {
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
}
$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"

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

316
scripts/process1.ps1 Normal file
View File

@@ -0,0 +1,316 @@
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 = "running backend quality gate"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
$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())

View File

@@ -0,0 +1,59 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Get-PythonCommand {
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
return "python"
}
function Ensure-PythonModule {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$ModuleName,
[Parameter(Mandatory = $true)][string]$PackageName
)
& $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
if ($LASTEXITCODE -eq 0) {
return
}
Write-Host "Installing missing Python package: $PackageName"
& $PythonExe -m pip install $PackageName
Assert-LastExitCode -CommandName "python -m pip install $PackageName"
}
$pythonExe = Get-PythonCommand
Write-Host "Installing backend Python requirements"
& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
Write-Host "Running Python dependency integrity check"
& $pythonExe -m pip check
Assert-LastExitCode -CommandName "python -m pip check"
Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
Write-Host "Running Python vulnerability scan"
& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
Assert-LastExitCode -CommandName "python -m pip_audit"
Write-Host "Running backend unit tests"
& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
Assert-LastExitCode -CommandName "python -m unittest discover"
Write-Host "Backend quality gate passed"