diff --git a/api/apps/tenant_app.py b/api/apps/tenant_app.py index 56b0c9df5..907c224c9 100644 --- a/api/apps/tenant_app.py +++ b/api/apps/tenant_app.py @@ -840,8 +840,8 @@ async def update_request(tenant_id: str) -> Response: @validate_request("users") 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. - Users must accept the invitation before they are added to the team. + Add one or more users directly to a team. Only OWNER or ADMIN can add users. + Users are added immediately without requiring invitation acceptance. Supports both single user and bulk operations. --- @@ -877,8 +877,8 @@ async def add_users(tenant_id: str) -> Response: description: User email role: type: string - description: Role to assign (normal, admin). Defaults to normal. - enum: [normal, admin] + description: Role to assign (normal, admin, invite). Defaults to normal. + enum: [normal, admin, invite] responses: 200: description: Users added successfully @@ -934,7 +934,28 @@ async def add_users(tenant_id: str) -> Response: role = UserTenantRole.NORMAL.value elif isinstance(user_input, dict): email = user_input.get("email") - role = user_input.get("role", UserTenantRole.NORMAL.value) + role_input = user_input.get("role") + # Normalize role to lowercase and validate + if role_input is not None: + if isinstance(role_input, str): + role_input = role_input.lower().strip() + if role_input in [UserTenantRole.NORMAL.value, UserTenantRole.ADMIN.value, UserTenantRole.INVITE.value]: + role = role_input + else: + failed_users.append({ + "email": email or str(user_input), + "error": f"Invalid role '{role_input}'. Allowed roles: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}, {UserTenantRole.INVITE.value}" + }) + continue + else: + failed_users.append({ + "email": email or str(user_input), + "error": f"Role must be a string. Allowed values: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}, {UserTenantRole.INVITE.value}" + }) + continue + # If role not provided, default to NORMAL + else: + role = UserTenantRole.NORMAL.value else: failed_users.append({ "email": str(user_input), @@ -949,14 +970,6 @@ async def add_users(tenant_id: str) -> Response: }) continue - # Validate role - if role not in [UserTenantRole.NORMAL.value, UserTenantRole.ADMIN.value]: - failed_users.append({ - "email": email, - "error": f"Invalid role '{role}'. Allowed roles: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}" - }) - continue - try: # Find user by email invite_users: List[Any] = UserService.query(email=email) @@ -985,20 +998,22 @@ async def add_users(tenant_id: str) -> Response: "error": "User is the owner of the team and cannot be added again." }) continue - # If user has INVITE role, resend invitation with new role (update the invitation) + # If user has INVITE role, convert it to the actual role (forcefully add them) if existing_role == UserTenantRole.INVITE: - # Update invitation - keep INVITE role, user needs to accept again - # Note: The intended role will be applied when user accepts via /agree endpoint - # For now, we'll store it by updating the invitation (user will need to accept) + # Set default permissions: read-only access to datasets and canvases + default_permissions: Dict[str, Any] = { + "dataset": {"create": False, "read": True, "update": False, "delete": False}, + "canvas": {"create": False, "read": True, "update": False, "delete": False} + } + # Update from INVITE to the specified role + UserTenantService.filter_update( + [UserTenant.tenant_id == tenant_id, UserTenant.user_id == user_id_to_add], + {"role": role, "status": StatusEnum.VALID.value, "permissions": default_permissions} + ) usr: Dict[str, Any] = invite_users[0].to_dict() usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]} - usr["role"] = "invite" # Still pending acceptance - usr["intended_role"] = role # Store intended role for reference - added_users.append({ - "email": email, - "status": "invitation_resent", - "intended_role": role - }) + usr["role"] = role + added_users.append(usr) continue # Set default permissions: read-only access to datasets and canvases @@ -1007,33 +1022,20 @@ async def add_users(tenant_id: str) -> Response: "canvas": {"create": False, "read": True, "update": False, "delete": False} } - # Send invitation - create user with INVITE role (user must accept to join) + # Add user directly with the specified role (NORMAL, ADMIN, or INVITE) UserTenantService.save( id=get_uuid(), user_id=user_id_to_add, tenant_id=tenant_id, invited_by=current_user.id, - role=UserTenantRole.INVITE, # Start with INVITE role + role=role, # Directly assign the role (NORMAL, ADMIN, or INVITE) status=StatusEnum.VALID.value, permissions=default_permissions ) - # Send invitation email if configured - if smtp_mail_server and settings.SMTP_CONF: - user_name: str = "" - _, user = UserService.get_by_id(current_user.id) - if user: - user_name = user.nickname - Thread( - target=send_invite_email, - args=(email, settings.MAIL_FRONTEND_URL, tenant_id, user_name or current_user.email), - daemon=True - ).start() - usr: Dict[str, Any] = invite_users[0].to_dict() usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]} - usr["role"] = "invite" # User is invited, not yet added - usr["intended_role"] = role # Role they will get after acceptance + usr["role"] = role added_users.append(usr) except Exception as e: @@ -1057,12 +1059,12 @@ async def add_users(tenant_id: str) -> Response: elif failed_users: return get_json_result( data=result, - message=f"Sent {len(added_users)} invitation(s). {len(failed_users)} user(s) failed." + message=f"Added {len(added_users)} user(s). {len(failed_users)} user(s) failed." ) else: return get_json_result( data=result, - message=f"Successfully sent {len(added_users)} invitation(s). Users must accept to join the team." + message=f"Successfully added {len(added_users)} user(s) to the team." ) diff --git a/test/testcases/test_http_api/test_dataset_mangement/test_dataset_permissions.py b/test/testcases/test_http_api/test_dataset_mangement/test_dataset_permissions.py index 268dbd311..f04695aca 100644 --- a/test/testcases/test_http_api/test_dataset_mangement/test_dataset_permissions.py +++ b/test/testcases/test_http_api/test_dataset_mangement/test_dataset_permissions.py @@ -23,7 +23,6 @@ import pytest import requests from common import ( - accept_team_invitation, add_users_to_team, create_dataset, create_team, @@ -82,7 +81,7 @@ class TestDatasetPermissions: web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], ) -> dict[str, Any]: - """Create a team with a user who has accepted the invitation.""" + """Create a team with a user who has been added to the team.""" tenant_id: str = test_team["id"] # Create user @@ -98,19 +97,11 @@ class TestDatasetPermissions: assert user_res["code"] == 0 user_id: str = user_res["data"]["id"] - # Add user to team + # Add user to team (users are now added directly) add_payload: dict[str, list[str]] = {"users": [email]} add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) assert add_res["code"] == 0 - # Small delay - time.sleep(0.5) - - # Accept invitation as the user - user_auth: RAGFlowWebApiAuth = login_as_user(email, password) - accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id) - assert accept_res["code"] == 0 - return { "team": test_team, "user": {"id": user_id, "email": email, "password": password}, diff --git a/test/testcases/test_http_api/test_team_management/test_add_users.py b/test/testcases/test_http_api/test_team_management/test_add_users.py index b4a584818..82923875a 100644 --- a/test/testcases/test_http_api/test_team_management/test_add_users.py +++ b/test/testcases/test_http_api/test_team_management/test_add_users.py @@ -1,5 +1,4 @@ - # - +# # Copyright 2025 The InfiniFlow Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -140,7 +139,7 @@ class TestAddUsers: assert "added" in res["data"] assert len(res["data"]["added"]) == 1 assert res["data"]["added"][0]["email"] == user_email - assert res["data"]["added"][0]["role"] == "invite" # Users are added with invite role initially + assert res["data"]["added"][0]["role"] == "normal" # Users are added directly with normal role assert "failed" in res["data"] assert len(res["data"]["failed"]) == 0 @@ -163,8 +162,7 @@ class TestAddUsers: assert res["code"] == 0, res assert len(res["data"]["added"]) == 1 assert res["data"]["added"][0]["email"] == user_email - assert res["data"]["added"][0]["role"] == "invite" # Users are added with invite role initially - assert res["data"]["added"][0]["intended_role"] == "admin" # Intended role after acceptance + assert res["data"]["added"][0]["role"] == "admin" # Users are added directly with the specified role @pytest.mark.p1 def test_add_multiple_users( @@ -207,12 +205,9 @@ class TestAddUsers: assert res["code"] == 0, res assert len(res["data"]["added"]) == 3 - assert res["data"]["added"][0]["role"] == "invite" # String format defaults to invite role initially - assert res["data"]["added"][0].get("intended_role") == "normal" # Intended role after acceptance - assert res["data"]["added"][1]["role"] == "invite" # Object format - still invite initially - assert res["data"]["added"][1]["intended_role"] == "admin" # Intended role after acceptance - assert res["data"]["added"][2]["role"] == "invite" # String format defaults to invite role initially - assert res["data"]["added"][2].get("intended_role") == "normal" # Intended role after acceptance + assert res["data"]["added"][0]["role"] == "normal" # String format defaults to normal role + assert res["data"]["added"][1]["role"] == "admin" # Object format with admin role + assert res["data"]["added"][2]["role"] == "normal" # String format defaults to normal role @pytest.mark.p1 def test_add_user_unregistered_email( @@ -248,11 +243,12 @@ class TestAddUsers: # Try to add same user again res2: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) - assert res2["code"] == 0 # Returns success - invitation is resent - # API resends invitation instead of failing - assert len(res2["data"]["added"]) == 1 - assert res2["data"]["added"][0]["email"] == user_email - assert res2["data"]["added"][0].get("status") == "invitation_resent" or "intended_role" in res2["data"]["added"][0] + # User is already a member, so it should be in failed list + # When all users fail, API returns DATA_ERROR (102) + assert res2["code"] == 102 # DATA_ERROR when all users fail + assert len(res2["data"]["failed"]) == 1 + assert len(res2["data"]["added"]) == 0 + assert "already" in res2["data"]["failed"][0]["error"].lower() or "member" in res2["data"]["failed"][0]["error"].lower() @pytest.mark.p1 def test_add_users_partial_success( @@ -318,7 +314,31 @@ class TestAddUsers: assert res["code"] == 102 # DATA_ERROR assert len(res["data"]["added"]) == 0 assert len(res["data"]["failed"]) == 1 - assert "invalid role" in res["data"]["failed"][0]["error"].lower() or "invalid" in res["data"]["failed"][0]["error"].lower() + error_msg = res["data"]["failed"][0]["error"].lower() + assert "invalid role" in error_msg or "invalid" in error_msg + # Verify error message mentions allowed roles (should include normal, admin, invite) + assert "normal" in error_msg or "admin" in error_msg or "invite" in error_msg + + @pytest.mark.p1 + def test_add_user_with_invite_role( + self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]] + ) -> None: + """Test adding a user with invite role.""" + if not test_users: + pytest.skip("No test users created") + + tenant_id: str = test_team["id"] + user_email: str = test_users[0]["email"] + + add_payload: dict[str, list[dict[str, str]]] = { + "users": [{"email": user_email, "role": "invite"}] + } + res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) + + assert res["code"] == 0, res + assert len(res["data"]["added"]) == 1 + assert res["data"]["added"][0]["email"] == user_email + assert res["data"]["added"][0]["role"] == "invite" # Users can be added with invite role @pytest.mark.p1 def test_add_users_not_owner_or_admin( @@ -340,9 +360,7 @@ class TestAddUsers: add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) assert add_res["code"] == 0 - # Small delay to ensure user is fully added - time.sleep(0.5) - + # Users are now added directly, no need to wait for invitation acceptance # Login as the normal user normal_user_auth: RAGFlowWebApiAuth = login_as_user(user_email, test_users[0]["password"]) @@ -382,3 +400,59 @@ class TestAddUsers: assert res["code"] != 0 assert len(res["data"]["added"]) == 0 + @pytest.mark.p1 + def test_add_users_with_different_roles( + self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]] + ) -> None: + """Test adding users with different roles (normal, admin, and invite).""" + if len(test_users) < 3: + pytest.skip("Need at least 3 test users") + + tenant_id: str = test_team["id"] + + add_payload: dict[str, list[Any]] = { + "users": [ + {"email": test_users[0]["email"], "role": "normal"}, + {"email": test_users[1]["email"], "role": "admin"}, + {"email": test_users[2]["email"], "role": "invite"}, + ] + } + res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) + + assert res["code"] == 0, res + assert len(res["data"]["added"]) == 3 + assert len(res["data"]["failed"]) == 0 + # Verify roles are set correctly + added_emails = {user["email"]: user["role"] for user in res["data"]["added"]} + assert added_emails[test_users[0]["email"]] == "normal" + assert added_emails[test_users[1]["email"]] == "admin" + assert added_emails[test_users[2]["email"]] == "invite" + + @pytest.mark.p1 + def test_add_users_role_case_insensitive( + self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]] + ) -> None: + """Test that role parameter is case-insensitive for all roles.""" + if len(test_users) < 3: + pytest.skip("Need at least 3 test users") + + tenant_id: str = test_team["id"] + + # Test with uppercase roles - all should be normalized to lowercase + add_payload: dict[str, list[dict[str, str]]] = { + "users": [ + {"email": test_users[0]["email"], "role": "ADMIN"}, + {"email": test_users[1]["email"], "role": "NORMAL"}, + {"email": test_users[2]["email"], "role": "INVITE"}, + ] + } + res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) + + assert res["code"] == 0, res + assert len(res["data"]["added"]) == 3 + # Verify all roles are normalized to lowercase + added_emails = {user["email"]: user["role"] for user in res["data"]["added"]} + assert added_emails[test_users[0]["email"]] == "admin" + assert added_emails[test_users[1]["email"]] == "normal" + assert added_emails[test_users[2]["email"]] == "invite" + diff --git a/test/testcases/test_http_api/test_team_management/test_user_permissions.py b/test/testcases/test_http_api/test_team_management/test_user_permissions.py index 8a6830214..bbcb3d326 100644 --- a/test/testcases/test_http_api/test_team_management/test_user_permissions.py +++ b/test/testcases/test_http_api/test_team_management/test_user_permissions.py @@ -22,7 +22,6 @@ from typing import Any import pytest from common import ( - accept_team_invitation, add_users_to_team, create_team, create_user, @@ -93,14 +92,7 @@ class TestAuthorization: if add_res.get("code", -1) != 0: pytest.skip(f"Failed to add user to team in setup: {add_res}") - # Small delay - time.sleep(0.5) - - # Accept invitation as the user - user_auth: RAGFlowWebApiAuth = login_as_user(email, password) - accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id) - if accept_res.get("code", -1) != 0: - pytest.skip(f"Failed to accept team invitation in setup: {accept_res}") + # Users are now added directly, no invitation acceptance needed # Try to get permissions with invalid auth res: dict[str, Any] = get_user_permissions(invalid_auth, tenant_id, user_id) @@ -157,14 +149,7 @@ class TestAuthorization: if add_res.get("code", -1) != 0: pytest.skip(f"Failed to add user to team in setup: {add_res}") - # Small delay - time.sleep(0.5) - - # Accept invitation as the user - user_auth: RAGFlowWebApiAuth = login_as_user(email, password) - accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id) - if accept_res.get("code", -1) != 0: - pytest.skip(f"Failed to accept team invitation in setup: {accept_res}") + # Users are now added directly, no invitation acceptance needed # Try to update permissions with invalid auth update_payload: dict[str, Any] = { @@ -207,7 +192,7 @@ class TestGetUserPermissions: test_team: dict[str, Any], clear_team_users: list[str], ) -> dict[str, Any]: - """Create a team with a user who has accepted the invitation.""" + """Create a team with a user who has been added to the team.""" tenant_id: str = test_team["id"] # Create user @@ -225,19 +210,11 @@ class TestGetUserPermissions: clear_team_users.append(email) user_id: str = user_res["data"]["id"] - # Add user to team + # Add user to team (users are now added directly) add_payload: dict[str, list[str]] = {"users": [email]} add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) if add_res["code"] != 0: pytest.skip(f"Failed to add user to team in setup: {add_res}") - - # Small delay - time.sleep(0.5) - - # Accept invitation as the user - user_auth: RAGFlowWebApiAuth = login_as_user(email, password) - accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id) - if accept_res["code"] != 0: pytest.skip(f"Failed to accept team invitation in setup: {accept_res}") return { @@ -364,7 +341,7 @@ class TestUpdateUserPermissions: test_team: dict[str, Any], clear_team_users: list[str], ) -> dict[str, Any]: - """Create a team with a user who has accepted the invitation.""" + """Create a team with a user who has been added to the team.""" tenant_id: str = test_team["id"] # Create user @@ -382,19 +359,11 @@ class TestUpdateUserPermissions: clear_team_users.append(email) user_id: str = user_res["data"]["id"] - # Add user to team + # Add user to team (users are now added directly) add_payload: dict[str, list[str]] = {"users": [email]} add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload) if add_res["code"] != 0: pytest.skip(f"Failed to add user to team in setup: {add_res}") - - # Small delay - time.sleep(0.5) - - # Accept invitation as the user - user_auth: RAGFlowWebApiAuth = login_as_user(email, password) - accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id) - if accept_res["code"] != 0: pytest.skip(f"Failed to accept team invitation in setup: {accept_res}") return {