Build 2602261605: invite trace and cross-system user lifecycle

This commit is contained in:
2026-02-26 16:06:09 +13:00
parent bd3c0bdade
commit 1b1a3e233b
13 changed files with 976 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
@@ -84,6 +85,29 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
try:
text = response.text.strip()
except Exception:
text = ""
if text:
return text
return f"HTTP {response.status_code}"
return str(exc)
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
try:
users = await client.get_users()
if isinstance(users, list):
save_jellyfin_users_cache(users)
except Exception:
# Cache refresh is best-effort and should not block auth/signup.
return
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
@@ -137,6 +161,11 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
set_last_login(user["username"])
@@ -299,12 +328,61 @@ async def signup(payload: dict) -> dict:
if isinstance(account_expires_days, int) and account_expires_days > 0:
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
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
) from exc
else:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
await _refresh_jellyfin_user_cache(jellyfin_client)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
if candidate_map:
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
try:
create_user(
username,
password.strip(),
local_password_value,
role=role,
auth_provider="local",
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
@@ -315,6 +393,15 @@ 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)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
and matched_jellyseerr_user_id is not None
):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
@@ -324,6 +411,7 @@ async def signup(payload: dict) -> dict:
"user": {
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},