Build 2602260214: invites profiles and expiry admin controls
This commit is contained in:
@@ -5,12 +5,16 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
verify_user_password,
|
||||
create_user,
|
||||
create_user_if_missing,
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
set_user_password,
|
||||
set_jellyfin_auth_cache,
|
||||
set_user_jellyseerr_id,
|
||||
get_signup_invite_by_code,
|
||||
increment_signup_invite_use,
|
||||
get_user_profile,
|
||||
get_user_activity,
|
||||
get_user_activity_summary,
|
||||
get_user_request_stats,
|
||||
@@ -80,13 +84,60 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_user_expired(user: dict | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
expires_at = user.get("expires_at")
|
||||
if not expires_at:
|
||||
return False
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed <= datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _assert_user_can_login(user: dict | None) -> None:
|
||||
if not user:
|
||||
return
|
||||
if user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
if _is_user_expired(user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
"expires_at": invite.get("expires_at"),
|
||||
"max_uses": invite.get("max_uses"),
|
||||
"use_count": invite.get("use_count", 0),
|
||||
"remaining_uses": invite.get("remaining_uses"),
|
||||
"is_expired": bool(invite.get("is_expired")),
|
||||
"is_usable": bool(invite.get("is_usable")),
|
||||
"profile": (
|
||||
{
|
||||
"id": profile.get("id"),
|
||||
"name": profile.get("name"),
|
||||
"description": profile.get("description"),
|
||||
}
|
||||
if profile
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
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("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
_assert_user_can_login(user)
|
||||
token = create_access_token(user["username"], user["role"])
|
||||
set_last_login(user["username"])
|
||||
return {
|
||||
@@ -107,8 +158,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
|
||||
username = form_data.username
|
||||
password = form_data.password
|
||||
user = get_user_by_username(username)
|
||||
if user and user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
_assert_user_can_login(user)
|
||||
if user and _has_valid_jellyfin_cache(user, password):
|
||||
token = create_access_token(username, "user")
|
||||
set_last_login(username)
|
||||
@@ -121,8 +171,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
user = get_user_by_username(username)
|
||||
if user and user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
_assert_user_can_login(user)
|
||||
try:
|
||||
users = await client.get_users()
|
||||
if isinstance(users, list):
|
||||
@@ -167,8 +216,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
|
||||
jellyseerr_user_id=jellyseerr_user_id,
|
||||
)
|
||||
user = get_user_by_username(form_data.username)
|
||||
if user and user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
_assert_user_can_login(user)
|
||||
if jellyseerr_user_id is not None:
|
||||
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
|
||||
token = create_access_token(form_data.username, "user")
|
||||
@@ -181,6 +229,107 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/invites/{code}")
|
||||
async def invite_details(code: str) -> dict:
|
||||
invite = get_signup_invite_by_code(code.strip())
|
||||
if not invite:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
profile = None
|
||||
profile_id = invite.get("profile_id")
|
||||
if profile_id is not None:
|
||||
profile = get_user_profile(int(profile_id))
|
||||
if profile and not profile.get("is_active", True):
|
||||
invite = {**invite, "is_usable": False}
|
||||
return {"invite": _public_invite_payload(invite, profile)}
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
invite_code = str(payload.get("invite_code") or "").strip()
|
||||
username = str(payload.get("username") or "").strip()
|
||||
password = str(payload.get("password") or "")
|
||||
if not invite_code:
|
||||
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.",
|
||||
)
|
||||
if get_user_by_username(username):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
|
||||
|
||||
invite = get_signup_invite_by_code(invite_code)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
if not invite.get("enabled"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
|
||||
if invite.get("is_expired"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
|
||||
remaining_uses = invite.get("remaining_uses")
|
||||
if remaining_uses is not None and int(remaining_uses) <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
|
||||
|
||||
profile = None
|
||||
profile_id = invite.get("profile_id")
|
||||
if profile_id is not None:
|
||||
profile = get_user_profile(int(profile_id))
|
||||
if not profile:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
|
||||
if not profile.get("is_active", True):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
|
||||
|
||||
invite_role = invite.get("role")
|
||||
profile_role = profile.get("role") if profile else None
|
||||
role = invite_role if invite_role in {"user", "admin"} else profile_role
|
||||
if role not in {"user", "admin"}:
|
||||
role = "user"
|
||||
|
||||
auto_search_enabled = (
|
||||
bool(profile.get("auto_search_enabled", True))
|
||||
if profile is not None
|
||||
else True
|
||||
)
|
||||
|
||||
expires_at = None
|
||||
account_expires_days = profile.get("account_expires_days") if profile else None
|
||||
if isinstance(account_expires_days, int) and account_expires_days > 0:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||
|
||||
try:
|
||||
create_user(
|
||||
username,
|
||||
password.strip(),
|
||||
role=role,
|
||||
auth_provider="local",
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
invited_by_code=invite.get("code"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
increment_signup_invite_use(int(invite["id"]))
|
||||
created_user = get_user_by_username(username)
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
username = current_user.get("username") or ""
|
||||
|
||||
Reference in New Issue
Block a user