Build 2602260214: invites profiles and expiry admin controls

This commit is contained in:
2026-02-26 02:15:21 +13:00
parent 9be0ec75ec
commit f78382c019
14 changed files with 2795 additions and 31 deletions

View File

@@ -2,6 +2,9 @@ from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import ipaddress
import os
import secrets
import sqlite3
import string
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -26,6 +29,8 @@ from ..db import (
set_user_blocked,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_profile_id,
set_user_expires_at,
set_user_password,
set_user_role,
run_integrity_check,
@@ -36,6 +41,16 @@ from ..db import (
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
update_user_profile,
delete_user_profile,
list_signup_invites,
get_signup_invite_by_id,
create_signup_invite,
update_signup_invite,
delete_signup_invite,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -226,6 +241,105 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
return results
def _normalize_optional_text(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
trimmed = value.strip()
return trimmed if trimmed else None
def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0")
return parsed
def _parse_optional_profile_id(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail="profile_id must be greater than 0")
profile = get_user_profile(parsed)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return parsed
def _parse_optional_expires_at(value: Any) -> Optional[str]:
if value is None or value == "":
return None
if not isinstance(value, str):
raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string")
candidate = value.strip()
if not candidate:
return None
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.isoformat()
def _normalize_invite_code(value: Optional[str]) -> str:
raw = (value or "").strip().upper()
filtered = "".join(ch for ch in raw if ch.isalnum())
if len(filtered) < 6:
raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.")
return filtered
def _generate_invite_code(length: int = 12) -> str:
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _normalize_role_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
role = value.strip().lower()
if not role:
return None
if role not in {"user", "admin"}:
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
return role
def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]:
expires_days = profile.get("account_expires_days")
if isinstance(expires_days, int) and expires_days > 0:
return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()
return None
def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]:
set_user_profile_id(username, int(profile["id"]))
role = profile.get("role") or "user"
if role in {"user", "admin"}:
set_user_role(username, role)
set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True)))
set_user_expires_at(username, _calculate_profile_expiry(profile))
refreshed = get_user_by_username(username)
if not refreshed:
raise HTTPException(status_code=404, detail="User not found")
return refreshed
@router.get("/settings")
async def list_settings() -> Dict[str, Any]:
overrides = get_settings_overrides()
@@ -607,12 +721,12 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
users = get_all_users()
return {"users": users}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
users = get_all_users()
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
@@ -674,6 +788,57 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
@router.post("/users/{username}/profile")
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
profile_id = payload.get("profile_id")
if profile_id in (None, ""):
set_user_profile_id(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
try:
parsed_profile_id = int(profile_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(parsed_profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
refreshed = _apply_profile_defaults_to_user(username, profile)
return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
@router.post("/users/{username}/expiry")
async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
clear = payload.get("clear")
if clear is True:
set_user_expires_at(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
if "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = None
if days is not None:
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
@router.post("/users/auto-search/bulk")
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
@@ -688,6 +853,68 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
}
@router.post("/users/profile/bulk")
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
profile_id_value = payload.get("profile_id")
if profile_id_value in (None, ""):
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_profile_id(user["username"], None)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
try:
profile_id = int(profile_id_value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
_apply_profile_defaults_to_user(user["username"], profile)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
@router.post("/users/expiry/bulk")
async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
clear = payload.get("clear")
expires_at: Optional[str] = None
if clear is True:
expires_at = None
elif "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
else:
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_expires_at(user["username"], expires_at)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
@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
@@ -702,3 +929,211 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.get("/profiles")
async def get_profiles() -> Dict[str, Any]:
profiles = list_user_profiles()
users = get_all_users()
invites = list_signup_invites()
user_counts: Dict[int, int] = {}
invite_counts: Dict[int, int] = {}
for user in users:
profile_id = user.get("profile_id")
if isinstance(profile_id, int):
user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
for invite in invites:
profile_id = invite.get("profile_id")
if isinstance(profile_id, int):
invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
enriched = []
for profile in profiles:
pid = int(profile["id"])
enriched.append(
{
**profile,
"assigned_users": user_counts.get(pid, 0),
"assigned_invites": invite_counts.get(pid, 0),
}
)
return {"profiles": enriched}
@router.post("/profiles")
async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if auto_search_enabled is None:
auto_search_enabled = True
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if is_active is None:
is_active = True
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = create_user_profile(
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
return {"status": "ok", "profile": profile}
@router.put("/profiles/{profile_id}")
async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_user_profile(profile_id)
if not existing:
raise HTTPException(status_code=404, detail="Profile not found")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = update_user_profile(
profile_id,
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "profile": profile}
@router.delete("/profiles/{profile_id}")
async def remove_profile(profile_id: int) -> Dict[str, Any]:
try:
deleted = delete_user_profile(profile_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "deleted": True, "profile_id": profile_id}
@router.get("/invites")
async def get_invites() -> Dict[str, Any]:
invites = list_signup_invites()
profiles = {profile["id"]: profile for profile in list_user_profiles()}
results = []
for invite in invites:
profile = profiles.get(invite.get("profile_id"))
results.append(
{
**invite,
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
}
if profile
else None
),
}
)
return {"invites": results}
@router.post("/invites")
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
raw_code = _normalize_optional_text(payload.get("code"))
code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if enabled is None:
enabled = True
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
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"))
try:
invite = create_signup_invite(
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
created_by=current_user.get("username"),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
return {"status": "ok", "invite": invite}
@router.put("/invites/{invite_id}")
async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_signup_invite_by_id(invite_id)
if not existing:
raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
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"))
try:
invite = update_signup_invite(
invite_id,
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
if not invite:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "deleted": True, "invite_id": invite_id}

View File

@@ -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 ""