Build 2602261717: master invite policy and self-service invite controls
This commit is contained in:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user