Hotfix: add logged-out password reset flow

This commit is contained in:
2026-03-02 20:44:58 +13:00
parent 9c69d9fd17
commit 5f2dc52771
11 changed files with 943 additions and 7 deletions

View File

@@ -1 +1 @@
0203261953 0203262044

View File

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

View File

@@ -2,6 +2,7 @@ import json
import os import os
import sqlite3 import sqlite3
import logging import logging
from hashlib import sha256
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -292,6 +293,22 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
recipient_email TEXT NOT NULL,
auth_provider TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
requested_by_ip TEXT,
requested_user_agent TEXT
)
"""
)
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -316,6 +333,18 @@ def init_db() -> None:
ON seerr_media_failures (suppress_until) ON seerr_media_failures (suppress_until)
""" """
) )
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_username
ON password_reset_tokens (username)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
ON password_reset_tokens (expires_at)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS user_activity ( CREATE TABLE IF NOT EXISTS user_activity (
@@ -679,6 +708,45 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
} }
def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE jellyseerr_user_id = ?
ORDER BY id ASC
LIMIT 1
""",
(jellyseerr_user_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
}
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
@@ -2271,6 +2339,138 @@ def get_settings_overrides() -> Dict[str, str]:
return overrides return overrides
def _hash_password_reset_token(token_value: str) -> str:
return sha256(str(token_value).encode("utf-8")).hexdigest()
def _password_reset_token_row_to_dict(row: Any) -> Dict[str, Any]:
return {
"id": row[0],
"token_hash": row[1],
"username": row[2],
"recipient_email": row[3],
"auth_provider": row[4],
"created_at": row[5],
"expires_at": row[6],
"used_at": row[7],
"requested_by_ip": row[8],
"requested_user_agent": row[9],
"is_expired": _is_datetime_in_past(row[6]),
"is_used": bool(row[7]),
}
def delete_expired_password_reset_tokens() -> int:
now_iso = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM password_reset_tokens
WHERE expires_at <= ? OR used_at IS NOT NULL
""",
(now_iso,),
)
return int(cursor.rowcount or 0)
def create_password_reset_token(
token_value: str,
username: str,
recipient_email: str,
auth_provider: str,
expires_at: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
created_at = datetime.now(timezone.utc).isoformat()
token_hash = _hash_password_reset_token(token_value)
delete_expired_password_reset_tokens()
with _connect() as conn:
conn.execute(
"""
DELETE FROM password_reset_tokens
WHERE username = ? AND used_at IS NULL
""",
(username,),
)
conn.execute(
"""
INSERT INTO password_reset_tokens (
token_hash,
username,
recipient_email,
auth_provider,
created_at,
expires_at,
used_at,
requested_by_ip,
requested_user_agent
)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
""",
(
token_hash,
username,
recipient_email,
auth_provider,
created_at,
expires_at,
requested_by_ip,
requested_user_agent,
),
)
logger.info(
"password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s",
username,
auth_provider,
recipient_email,
expires_at,
requested_by_ip,
)
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
"created_at": created_at,
"expires_at": expires_at,
"requested_by_ip": requested_by_ip,
"requested_user_agent": requested_user_agent,
}
def get_password_reset_token(token_value: str) -> Optional[Dict[str, Any]]:
token_hash = _hash_password_reset_token(token_value)
with _connect() as conn:
row = conn.execute(
"""
SELECT id, token_hash, username, recipient_email, auth_provider, created_at,
expires_at, used_at, requested_by_ip, requested_user_agent
FROM password_reset_tokens
WHERE token_hash = ?
""",
(token_hash,),
).fetchone()
if not row:
return None
return _password_reset_token_row_to_dict(row)
def mark_password_reset_token_used(token_value: str) -> None:
token_hash = _hash_password_reset_token(token_value)
used_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE password_reset_tokens
SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL
""",
(used_at, token_hash),
)
logger.info("password reset token marked used token_hash=%s", token_hash[:12])
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]: def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_type or not tmdb_id: if not media_type or not tmdb_id:
return None return None

View File

@@ -49,12 +49,21 @@ from ..services.user_cache import (
match_jellyseerr_user_id, match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
) )
from ..services.invite_email import send_templated_email from ..services.invite_email import 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120 STREAM_TOKEN_TTL_SECONDS = 120
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_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) _LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
@@ -223,6 +232,11 @@ def _extract_http_error_detail(exc: Exception) -> str:
return str(exc) 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: async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
try: try:
users = await client.get_users() users = await client.get_users()
@@ -880,6 +894,100 @@ 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.")
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) or len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
try:
result = await apply_password_reset(token.strip(), new_password.strip())
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") @router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict: async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or "" username = current_user.get("username") or ""

View File

@@ -537,3 +537,63 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
if warning: if warning:
result["warning"] = warning result["warning"] = warning
return result return result
async def send_password_reset_email(
*,
recipient_email: str,
username: str,
token: str,
expires_at: str,
auth_provider: str,
) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:
raise RuntimeError(detail)
resolved_email = _normalize_email(recipient_email)
if not resolved_email:
raise RuntimeError("No valid recipient email is available for password reset.")
app_url = _build_default_base_url()
reset_url = f"{app_url}/reset-password?token={token}"
provider_label = "Jellyfin, Seerr, and Magent" if auth_provider == "jellyfin" else "Magent"
subject = f"{env_settings.app_name} password reset"
body_text = (
f"A password reset was requested for {username}.\n\n"
f"This link will reset the password used for {provider_label}.\n"
f"Reset link: {reset_url}\n"
f"Expires: {expires_at}\n\n"
"If you did not request this reset, you can ignore this email.\n"
)
body_html = (
f"<h1>{html.escape(env_settings.app_name)} password reset</h1>"
f"<p>A password reset was requested for <strong>{html.escape(username)}</strong>.</p>"
f"<p>This link will reset the password used for <strong>{html.escape(provider_label)}</strong>.</p>"
f"<p><a href=\"{html.escape(reset_url)}\">Reset password</a></p>"
f"<p><strong>Expires:</strong> {html.escape(expires_at)}</p>"
"<p>If you did not request this reset, you can ignore this email.</p>"
)
await asyncio.to_thread(
_send_email_sync,
recipient_email=resolved_email,
subject=subject,
body_text=body_text,
body_html=body_html,
)
logger.info(
"Password reset email sent: username=%s recipient=%s provider=%s",
username,
resolved_email,
auth_provider,
)
result = {
"recipient_email": resolved_email,
"subject": subject,
"reset_url": reset_url,
}
warning = smtp_email_delivery_warning()
if warning:
result["warning"] = warning
return result

View File

@@ -0,0 +1,330 @@
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):
username = str(local_user.get("username") or "").strip()
if "@" in username:
return username
if isinstance(seerr_user, dict):
email = str(seerr_user.get("email") or "").strip()
if "@" in email:
return email
return None
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
normalized_identifier = str(identifier or "").strip()
if not normalized_identifier:
return None
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
)
seerr_users: list[dict] | None = None
seerr_user: dict | None = None
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_users = await _fetch_all_seerr_users()
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not local_user:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
if seerr_user:
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
try:
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
except (TypeError, ValueError):
seerr_user_id = None
if seerr_user_id is not None:
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
if not local_user:
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
if not isinstance(candidate, str) or not candidate.strip():
continue
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
)
if local_user:
break
if not local_user:
return None
auth_provider = resolve_user_auth_provider(local_user)
username = str(local_user.get("username") or "").strip()
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
if isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not seerr_user:
seerr_user = _find_matching_seerr_user(username, seerr_users)
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
return None
if auth_provider == "jellyseerr":
runtime = get_runtime_settings()
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
try:
jellyfin_user = await jellyfin_client.find_user_by_name(username)
except Exception:
jellyfin_user = None
if isinstance(jellyfin_user, dict):
auth_provider = "jellyfin"
if auth_provider not in {"local", "jellyfin"}:
return None
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
}
def _token_record_is_usable(record: Optional[dict]) -> bool:
if not isinstance(record, dict):
return False
if record.get("is_used"):
return False
if record.get("is_expired"):
return False
return True
def _mask_email(email: str) -> str:
candidate = str(email or "").strip()
if "@" not in candidate:
return "valid reset link"
local_part, domain = candidate.split("@", 1)
if not local_part:
return f"***@{domain}"
if len(local_part) == 1:
return f"{local_part}***@{domain}"
return f"{local_part[0]}***{local_part[-1]}@{domain}"
async def request_password_reset(
identifier: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier)
if not target:
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
create_password_reset_token(
token,
target["username"],
target["recipient_email"],
target["auth_provider"],
expires_at,
requested_by_ip=requested_by_ip,
requested_user_agent=requested_user_agent,
)
await send_password_reset_email(
recipient_email=target["recipient_email"],
username=target["username"],
token=token,
expires_at=expires_at,
auth_provider=target["auth_provider"],
)
return {
"status": "ok",
"issued": True,
"username": target["username"],
"recipient_email": target["recipient_email"],
"auth_provider": target["auth_provider"],
"expires_at": expires_at,
}
def verify_password_reset_token(token: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
return {
"status": "ok",
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
"auth_provider": record.get("auth_provider"),
"expires_at": record.get("expires_at"),
}
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
username = str(record.get("username") or "").strip()
if not username:
raise ValueError("Password reset link is invalid or has expired.")
stored_user = normalize_user_auth_provider(get_user_by_username(username))
if not stored_user:
raise ValueError("Password reset link is invalid or has expired.")
auth_provider = resolve_user_auth_provider(stored_user)
if auth_provider == "jellyseerr":
auth_provider = "jellyfin"
if auth_provider == "local":
set_user_password(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
set_user_auth_provider(username, "local")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=local", username)
return {"status": "ok", "provider": "local", "username": username}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
jellyfin_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jellyfin_user)
if not user_id:
raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin", "username": username}
raise ValueError("Password reset is not available for this sign-in provider.")

View File

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

View File

@@ -85,6 +85,9 @@ export default function LoginPage() {
> >
Sign in with Magent account Sign in with Magent account
</button> </button>
<a className="ghost-button" href="/forgot-password">
Forgot password?
</a>
<a className="ghost-button" href="/signup"> <a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent) Have an invite? Create your account (Jellyfin + Magent)
</a> </a>

View File

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

View File

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

View File

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