[OND211-2329]: Allowed users to demote themselves from admin.
This commit is contained in:
parent
b20737c4ad
commit
13b8f0cf41
1 changed files with 74 additions and 66 deletions
|
|
@ -17,11 +17,9 @@ import logging
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Any, Dict, List, Optional, Set, Union
|
from typing import Any, Dict, List, Optional, Set, Union
|
||||||
|
|
||||||
from flask import Response, request, Blueprint
|
from quart import request, Response
|
||||||
from flask_login import current_user, login_required
|
|
||||||
from quart import request
|
|
||||||
|
|
||||||
from api.apps import smtp_mail_server
|
from api.apps import smtp_mail_server, login_required, current_user
|
||||||
from api.db import FileType, UserTenantRole
|
from api.db import FileType, UserTenantRole
|
||||||
from api.db.db_models import UserTenant
|
from api.db.db_models import UserTenant
|
||||||
from api.db.services.file_service import FileService
|
from api.db.services.file_service import FileService
|
||||||
|
|
@ -45,7 +43,7 @@ from common.constants import RetCode, StatusEnum
|
||||||
from common.misc_utils import get_uuid
|
from common.misc_utils import get_uuid
|
||||||
from common.time_utils import delta_seconds
|
from common.time_utils import delta_seconds
|
||||||
|
|
||||||
manager = Blueprint("tenant", __name__)
|
# manager = Blueprint("tenant", __name__)
|
||||||
def is_team_admin_or_owner(tenant_id: str, user_id: str) -> bool:
|
def is_team_admin_or_owner(tenant_id: str, user_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a user is an OWNER or ADMIN of a team.
|
Check if a user is an OWNER or ADMIN of a team.
|
||||||
|
|
@ -198,7 +196,7 @@ def rm(tenant_id, user_id):
|
||||||
@manager.route("/create", methods=["POST"]) # noqa: F821
|
@manager.route("/create", methods=["POST"]) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
@validate_request("name")
|
@validate_request("name")
|
||||||
def create_team() -> Response:
|
async def create_team() -> Response:
|
||||||
"""
|
"""
|
||||||
Create a new team (tenant). Requires authentication - any registered user can create a team.
|
Create a new team (tenant). Requires authentication - any registered user can create a team.
|
||||||
|
|
||||||
|
|
@ -270,22 +268,15 @@ def create_team() -> Response:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
"""
|
"""
|
||||||
# Explicitly check authentication status
|
req_json = await request.json
|
||||||
if not current_user.is_authenticated:
|
if req_json is None:
|
||||||
return get_json_result(
|
|
||||||
data=False,
|
|
||||||
message="Unauthorized",
|
|
||||||
code=RetCode.UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.json is None:
|
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
data=False,
|
data=False,
|
||||||
message="Request body is required!",
|
message="Request body is required!",
|
||||||
code=RetCode.ARGUMENT_ERROR,
|
code=RetCode.ARGUMENT_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
req: Dict[str, Any] = request.json
|
req: Dict[str, Any] = req_json
|
||||||
team_name: str = req.get("name", "").strip()
|
team_name: str = req.get("name", "").strip()
|
||||||
user_id: Optional[str] = req.get("user_id")
|
user_id: Optional[str] = req.get("user_id")
|
||||||
|
|
||||||
|
|
@ -487,7 +478,7 @@ def tenant_list():
|
||||||
|
|
||||||
@manager.route("/<tenant_id>", methods=["PUT"]) # noqa: F821
|
@manager.route("/<tenant_id>", methods=["PUT"]) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
def update_team(tenant_id: str) -> Response:
|
async def update_team(tenant_id: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Update team details. Only OWNER or ADMIN can update team information.
|
Update team details. Only OWNER or ADMIN can update team information.
|
||||||
|
|
||||||
|
|
@ -574,14 +565,15 @@ def update_team(tenant_id: str) -> Response:
|
||||||
code=RetCode.DATA_ERROR
|
code=RetCode.DATA_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.json is None:
|
req_json = await request.json
|
||||||
|
if req_json is None:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
data=False,
|
data=False,
|
||||||
message="Request body is required!",
|
message="Request body is required!",
|
||||||
code=RetCode.ARGUMENT_ERROR,
|
code=RetCode.ARGUMENT_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
req: Dict[str, Any] = request.json
|
req: Dict[str, Any] = req_json
|
||||||
|
|
||||||
# Extract update fields (all optional)
|
# Extract update fields (all optional)
|
||||||
update_data: Dict[str, Any] = {}
|
update_data: Dict[str, Any] = {}
|
||||||
|
|
@ -721,7 +713,7 @@ def update_team(tenant_id: str) -> Response:
|
||||||
|
|
||||||
@manager.route("/update-request/<tenant_id>", methods=["PUT"]) # noqa: F821
|
@manager.route("/update-request/<tenant_id>", methods=["PUT"]) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
def update_request(tenant_id: str) -> Response:
|
async def update_request(tenant_id: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Accept or reject a team invitation. User must have INVITE role.
|
Accept or reject a team invitation. User must have INVITE role.
|
||||||
Takes an 'accept' boolean in the request body to accept (true) or reject (false) the invitation.
|
Takes an 'accept' boolean in the request body to accept (true) or reject (false) the invitation.
|
||||||
|
|
@ -781,7 +773,8 @@ def update_request(tenant_id: str) -> Response:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get accept boolean from request body
|
# Get accept boolean from request body
|
||||||
req: Dict[str, Any] = request.json if request.json is not None else {}
|
req_json = await request.json
|
||||||
|
req: Dict[str, Any] = req_json if req_json is not None else {}
|
||||||
accept: Optional[bool] = req.get("accept")
|
accept: Optional[bool] = req.get("accept")
|
||||||
|
|
||||||
# Validate accept parameter
|
# Validate accept parameter
|
||||||
|
|
@ -829,7 +822,7 @@ def update_request(tenant_id: str) -> Response:
|
||||||
@manager.route('/<tenant_id>/users/add', methods=['POST']) # noqa: F821
|
@manager.route('/<tenant_id>/users/add', methods=['POST']) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
@validate_request("users")
|
@validate_request("users")
|
||||||
def add_users(tenant_id: str) -> Response:
|
async def add_users(tenant_id: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Send invitations to one or more users to join a team. Only OWNER or ADMIN can send invitations.
|
Send invitations to one or more users to join a team. Only OWNER or ADMIN can send invitations.
|
||||||
Users must accept the invitation before they are added to the team.
|
Users must accept the invitation before they are added to the team.
|
||||||
|
|
@ -902,7 +895,8 @@ def add_users(tenant_id: str) -> Response:
|
||||||
code=RetCode.PERMISSION_ERROR
|
code=RetCode.PERMISSION_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
req: Dict[str, Any] = request.json if request.json is not None else {}
|
req_json = await request.json
|
||||||
|
req: Dict[str, Any] = req_json if req_json is not None else {}
|
||||||
users_input: List[Union[str, Dict[str, Any]]] = req.get("users", [])
|
users_input: List[Union[str, Dict[str, Any]]] = req.get("users", [])
|
||||||
|
|
||||||
if not isinstance(users_input, list) or len(users_input) == 0:
|
if not isinstance(users_input, list) or len(users_input) == 0:
|
||||||
|
|
@ -1059,7 +1053,7 @@ def add_users(tenant_id: str) -> Response:
|
||||||
@manager.route('/<tenant_id>/user/remove', methods=['POST']) # noqa: F821
|
@manager.route('/<tenant_id>/user/remove', methods=['POST']) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
@validate_request("user_id")
|
@validate_request("user_id")
|
||||||
def remove_user(tenant_id: str) -> Response:
|
async def remove_user(tenant_id: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Remove a user from a team. Only OWNER or ADMIN can remove users.
|
Remove a user from a team. Only OWNER or ADMIN can remove users.
|
||||||
Owners cannot be removed.
|
Owners cannot be removed.
|
||||||
|
|
@ -1118,7 +1112,8 @@ def remove_user(tenant_id: str) -> Response:
|
||||||
code=RetCode.PERMISSION_ERROR
|
code=RetCode.PERMISSION_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
req: Dict[str, Any] = request.json if request.json is not None else {}
|
req_json = await request.json
|
||||||
|
req: Dict[str, Any] = req_json if req_json is not None else {}
|
||||||
user_id: Optional[str] = req.get("user_id")
|
user_id: Optional[str] = req.get("user_id")
|
||||||
|
|
||||||
if not user_id or not isinstance(user_id, str):
|
if not user_id or not isinstance(user_id, str):
|
||||||
|
|
@ -1309,7 +1304,7 @@ def demote_admin(tenant_id: str, user_id: str) -> Response:
|
||||||
- ApiKeyAuth: []
|
- ApiKeyAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: tenant_id
|
name: tenant_iderify they are actually an admin; otherwise return:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
description: Team ID
|
description: Team ID
|
||||||
|
|
@ -1339,27 +1334,19 @@ def demote_admin(tenant_id: str, user_id: str) -> Response:
|
||||||
404:
|
404:
|
||||||
description: User not found in team or not an admin.
|
description: User not found in team or not an admin.
|
||||||
"""
|
"""
|
||||||
# Check if current user is team owner (only owners can demote admins)
|
|
||||||
if not is_team_owner(tenant_id, current_user.id):
|
|
||||||
return get_json_result(
|
|
||||||
data=False,
|
|
||||||
message="Only team owners can demote admins.",
|
|
||||||
code=RetCode.PERMISSION_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if target user exists in the team
|
# Check if target user exists in the team
|
||||||
user_tenant: Optional[UserTenant] = UserTenantService.filter_by_tenant_and_user_id(
|
user_tenant: Optional[UserTenant] = UserTenantService.filter_by_tenant_and_user_id(
|
||||||
tenant_id, user_id
|
tenant_id, user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user_tenant:
|
if not user_tenant:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
data=False,
|
data=False,
|
||||||
message="User is not a member of this team.",
|
message="User is not a member of this team.",
|
||||||
code=RetCode.DATA_ERROR,
|
code=RetCode.DATA_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cannot demote the owner (owner role is permanent)
|
# Cannot demote the owner (owner role is permanent)
|
||||||
if user_tenant.role == UserTenantRole.OWNER:
|
if user_tenant.role == UserTenantRole.OWNER:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
|
|
@ -1367,30 +1354,28 @@ def demote_admin(tenant_id: str, user_id: str) -> Response:
|
||||||
message="Cannot demote the team owner. Owner role is permanent.",
|
message="Cannot demote the team owner. Owner role is permanent.",
|
||||||
code=RetCode.DATA_ERROR,
|
code=RetCode.DATA_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if user is actually an admin
|
# Self-demotion: allow admins to demote themselves with last-admin protection,
|
||||||
if user_tenant.role != UserTenantRole.ADMIN:
|
# even if they are not the team owner.
|
||||||
# Get user info for response
|
|
||||||
user: Optional[Any] = UserService.filter_by_id(user_id)
|
|
||||||
user_email: str = user.email if user else "Unknown"
|
|
||||||
return get_json_result(
|
|
||||||
data=False,
|
|
||||||
message=f"User {user_email} is not an admin. Only admins can be demoted.",
|
|
||||||
code=RetCode.DATA_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if demoting yourself would leave the team without any admins/owners
|
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
|
if user_tenant.role != UserTenantRole.ADMIN:
|
||||||
|
user: Optional[Any] = UserService.filter_by_id(user_id)
|
||||||
|
user_email: str = user.email if user else "Unknown"
|
||||||
|
return get_json_result(
|
||||||
|
data=False,
|
||||||
|
message=f"User {user_email} is not an admin. Only admins can be demoted.",
|
||||||
|
code=RetCode.DATA_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
# Get all admins and owners in the team
|
# Get all admins and owners in the team
|
||||||
all_admins_owners: List[UserTenant] = list(
|
all_admins_owners: List[UserTenant] = list(
|
||||||
UserTenantService.model.select()
|
UserTenantService.model.select().where(
|
||||||
.where(
|
(UserTenant.tenant_id == tenant_id)
|
||||||
(UserTenant.tenant_id == tenant_id) &
|
& (UserTenant.status == StatusEnum.VALID.value)
|
||||||
(UserTenant.status == StatusEnum.VALID.value) &
|
& (UserTenant.role.in_([UserTenantRole.OWNER, UserTenantRole.ADMIN]))
|
||||||
(UserTenant.role.in_([UserTenantRole.OWNER, UserTenantRole.ADMIN]))
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# If this is the only admin/owner, prevent demotion
|
# If this is the only admin/owner, prevent demotion
|
||||||
if len(all_admins_owners) <= 1:
|
if len(all_admins_owners) <= 1:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
|
|
@ -1398,17 +1383,39 @@ def demote_admin(tenant_id: str, user_id: str) -> Response:
|
||||||
message="Cannot demote yourself. At least one owner or admin must remain in the team.",
|
message="Cannot demote yourself. At least one owner or admin must remain in the team.",
|
||||||
code=RetCode.DATA_ERROR,
|
code=RetCode.DATA_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Demote admin to normal member
|
# Safe to demote self
|
||||||
UserTenantService.filter_update(
|
UserTenantService.filter_update(
|
||||||
[UserTenant.tenant_id == tenant_id, UserTenant.user_id == user_id],
|
[UserTenant.tenant_id == tenant_id, UserTenant.user_id == user_id],
|
||||||
{"role": UserTenantRole.NORMAL.value}
|
{"role": UserTenantRole.NORMAL.value},
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Demoting someone else: only team owners can demote admins
|
||||||
|
if not is_team_owner(tenant_id, current_user.id):
|
||||||
|
return get_json_result(
|
||||||
|
data=False,
|
||||||
|
message="Only team owners can demote admins.",
|
||||||
|
code=RetCode.PERMISSION_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_tenant.role != UserTenantRole.ADMIN:
|
||||||
|
user: Optional[Any] = UserService.filter_by_id(user_id)
|
||||||
|
user_email: str = user.email if user else "Unknown"
|
||||||
|
return get_json_result(
|
||||||
|
data=False,
|
||||||
|
message=f"User {user_email} is not an admin. Only admins can be demoted.",
|
||||||
|
code=RetCode.DATA_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
UserTenantService.filter_update(
|
||||||
|
[UserTenant.tenant_id == tenant_id, UserTenant.user_id == user_id],
|
||||||
|
{"role": UserTenantRole.NORMAL.value},
|
||||||
|
)
|
||||||
|
|
||||||
# Get user info for response
|
# Get user info for response
|
||||||
user: Optional[Any] = UserService.filter_by_id(user_id)
|
user: Optional[Any] = UserService.filter_by_id(user_id)
|
||||||
user_email: str = user.email if user else "Unknown"
|
user_email: str = user.email if user else "Unknown"
|
||||||
|
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
data=True,
|
data=True,
|
||||||
message=f"User {user_email} has been demoted to normal member successfully!",
|
message=f"User {user_email} has been demoted to normal member successfully!",
|
||||||
|
|
@ -1517,7 +1524,7 @@ def get_user_permissions(tenant_id: str, user_id: str) -> Response:
|
||||||
@manager.route('/<tenant_id>/users/<user_id>/permissions', methods=['PUT']) # noqa: F821
|
@manager.route('/<tenant_id>/users/<user_id>/permissions', methods=['PUT']) # noqa: F821
|
||||||
@login_required
|
@login_required
|
||||||
@validate_request("permissions")
|
@validate_request("permissions")
|
||||||
def update_user_permissions(tenant_id: str, user_id: str) -> Response:
|
async def update_user_permissions(tenant_id: str, user_id: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Update CRUD permissions for a team member.
|
Update CRUD permissions for a team member.
|
||||||
|
|
||||||
|
|
@ -1602,14 +1609,15 @@ def update_user_permissions(tenant_id: str, user_id: str) -> Response:
|
||||||
code=RetCode.PERMISSION_ERROR,
|
code=RetCode.PERMISSION_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.json is None:
|
req_json = await request.json
|
||||||
|
if req_json is None:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
data=False,
|
data=False,
|
||||||
message="Request body is required!",
|
message="Request body is required!",
|
||||||
code=RetCode.ARGUMENT_ERROR,
|
code=RetCode.ARGUMENT_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
req: Dict[str, Any] = request.json
|
req: Dict[str, Any] = req_json
|
||||||
permissions: Optional[Dict[str, Any]] = req.get("permissions")
|
permissions: Optional[Dict[str, Any]] = req.get("permissions")
|
||||||
|
|
||||||
if not permissions or not isinstance(permissions, dict):
|
if not permissions or not isinstance(permissions, dict):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue