Compare commits
19 Commits
aae2c3d418
...
0403261736
| Author | SHA1 | Date | |
|---|---|---|---|
| d30a2473ce | |||
| 4e64f79e64 | |||
| c6bc31f27e | |||
| 1ad4823830 | |||
| caa6aa76d6 | |||
| d80b1e5e4f | |||
| 1ff54690fc | |||
| 4f2b5e0922 | |||
| 96333c0d85 | |||
| bac96c7db3 | |||
| dda17a20a5 | |||
| e582ff4ef7 | |||
| 42d4caa474 | |||
| 5f2dc52771 | |||
| 9c69d9fd17 | |||
| b0ef455498 | |||
| 821f518bb3 | |||
| eeba143b41 | |||
| b068a6066e |
@@ -1 +1 @@
|
||||
0103262231
|
||||
0403261736
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
1094
backend/app/db.py
1094
backend/app/db.py
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
333
backend/app/services/password_reset.py
Normal file
333
backend/app/services/password_reset.py
Normal 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.")
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
145
backend/tests/test_backend_quality.py
Normal file
145
backend/tests/test_backend_quality.py
Normal 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.",
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
79
frontend/app/forgot-password/page.tsx
Normal file
79
frontend/app/forgot-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
638
frontend/app/profile/invites/page.tsx
Normal file
638
frontend/app/profile/invites/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 Magent’s cached sign-in.'
|
||||
: authProvider === 'local'
|
||||
passwordProvider === 'jellyfin'
|
||||
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
||||
: passwordProvider === 'local'
|
||||
? 'Change your Magent account password.'
|
||||
: 'Password changes are not available for this sign-in provider.'
|
||||
|
||||
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 you’ve issued. New invites use the admin master invite rule.'
|
||||
: 'Create and manage invite links you’ve 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 haven’t 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
156
frontend/app/reset-password/page.tsx
Normal file
156
frontend/app/reset-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@@ -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 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0103262231",
|
||||
"version": "0403261736",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -23,3 +23,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
153
scripts/import_user_emails.py
Normal file
153
scripts/import_user_emails.py
Normal 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
316
scripts/process1.ps1
Normal 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
|
||||
}
|
||||
45
scripts/render_git_changelog.py
Normal file
45
scripts/render_git_changelog.py
Normal 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())
|
||||
59
scripts/run_backend_quality_gate.ps1
Normal file
59
scripts/run_backend_quality_gate.ps1
Normal 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"
|
||||
Reference in New Issue
Block a user