Build 2602261717: master invite policy and self-service invite controls

This commit is contained in:
2026-02-26 17:18:40 +13:00
parent 23c57da3cc
commit 6a5d2c4310
10 changed files with 844 additions and 157 deletions

View File

@@ -19,6 +19,7 @@ from ..db import (
get_all_users,
get_cached_requests,
get_cached_requests_count,
get_setting,
get_request_cache_overview,
get_request_cache_missing_titles,
get_request_cache_stats,
@@ -34,9 +35,12 @@ from ..db import (
delete_user_activity_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
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,
set_user_role,
run_integrity_check,
vacuum_db,
@@ -83,6 +87,7 @@ from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
SENSITIVE_KEYS = {
"jellyseerr_api_key",
@@ -1107,6 +1112,24 @@ 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}/invite-access")
async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_user_invite_management_enabled(username, enabled)
refreshed = get_user_by_username(username)
return {
"status": "ok",
"username": username,
"invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
"user": refreshed,
}
@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)
@@ -1172,6 +1195,20 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
}
@router.post("/users/invite-access/bulk")
async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
updated = set_invite_management_enabled_for_non_admin_users(enabled)
return {
"status": "ok",
"enabled": enabled,
"updated": updated,
"scope": "non-admin-users",
}
@router.post("/users/profile/bulk")
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
@@ -1242,12 +1279,30 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("auth_provider") != "local":
raise HTTPException(
status_code=400, detail="Password changes are only available for local users."
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
new_password_clean = new_password.strip()
auth_provider = str(user.get("auth_provider") or "local").lower()
if auth_provider == "local":
set_user_password(username, new_password_clean)
return {"status": "ok", "username": username, "provider": "local"}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
try:
jf_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jf_user)
if not user_id:
raise RuntimeError("Jellyfin user ID not found")
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)
return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException(
status_code=400,
detail="Password changes are not available for this sign-in provider.",
)
@router.get("/profiles")
@@ -1384,6 +1439,63 @@ async def get_invites() -> Dict[str, Any]:
return {"invites": results}
@router.get("/invites/policy")
async def get_invite_policy() -> Dict[str, Any]:
users = get_all_users()
non_admin_users = [user for user in users if user.get("role") != "admin"]
invite_access_enabled_count = sum(
1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
)
raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
master_invite_id: Optional[int] = None
master_invite: Optional[Dict[str, Any]] = None
if raw_master_invite_id not in (None, ""):
try:
candidate = int(str(raw_master_invite_id).strip())
if candidate > 0:
master_invite_id = candidate
master_invite = get_signup_invite_by_id(candidate)
except (TypeError, ValueError):
master_invite_id = None
master_invite = None
return {
"status": "ok",
"policy": {
"master_invite_id": master_invite_id if master_invite is not None else None,
"master_invite": master_invite,
"non_admin_users": len(non_admin_users),
"invite_access_enabled_users": invite_access_enabled_count,
},
}
@router.post("/invites/policy")
async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
master_invite_value = payload.get("master_invite_id")
if master_invite_value in (None, "", 0, "0"):
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
try:
master_invite_id = int(master_invite_value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
if master_invite_id <= 0:
raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
invite = get_signup_invite_by_id(master_invite_id)
if not invite:
raise HTTPException(status_code=404, detail="Master invite not found")
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
return {
"status": "ok",
"policy": {
"master_invite_id": master_invite_id,
"master_invite": invite,
},
}
@router.get("/invites/trace")
async def get_invite_trace() -> Dict[str, Any]:
return {"status": "ok", "trace": _build_invite_trace_payload()}