diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 6f7e27295..733c653e7 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -576,7 +576,8 @@ "required": "This is required field" }, "users_autosuggest": { - "placeholder": "Add member", + "placeholder": "Enter username or email to add member", + "entered_text": "Add member", "loading": "Loading users", "no_match": "No matches found" }, diff --git a/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx b/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx index 5a3f35f62..fdb7d9ff8 100644 --- a/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx +++ b/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx @@ -35,7 +35,7 @@ export const UserAutosuggest: React.FC = ({ optionsFilter, onSelect: onSe return ( `${t('users_autosuggest.placeholder')} ${text}`} + enteredTextLabel={(text) => `${t('users_autosuggest.entered_text')} ${text}`} onChange={({ detail }) => setValue(detail.value)} options={filteredOptions} statusType={isUsersLoading ? 'loading' : undefined} diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index e51528bf0..18f85f15c 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -1,4 +1,6 @@ -from typing import List +from typing import Annotated, List + +from pydantic import Field from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.users import ProjectRole @@ -13,7 +15,10 @@ class DeleteProjectsRequest(CoreModel): class MemberSetting(CoreModel): - username: str + username: Annotated[ + str, + Field(description="The username or email of the user"), + ] project_role: ProjectRole diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 2c2b135f8..7fc4cd909 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -179,12 +179,16 @@ async def set_project_members( # FIXME: potentially long write transaction # clear_project_members() issues DELETE without commit await clear_project_members(session=session, project=project) - usernames = [m.username for m in members] - res = await session.execute(select(UserModel).where(UserModel.name.in_(usernames))) + names = [m.username for m in members] + res = await session.execute( + select(UserModel).where((UserModel.name.in_(names)) | (UserModel.email.in_(names))) + ) users = res.scalars().all() + # Create lookup maps for both username and email username_to_user = {user.name: user for user in users} + email_to_user = {user.email: user for user in users if user.email} for i, member in enumerate(members): - user_to_add = username_to_user.get(member.username) + user_to_add = username_to_user.get(member.username) or email_to_user.get(member.username) if user_to_add is None: continue await add_project_member( diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index c4deb32ed..ab655cf40 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -503,6 +503,62 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client members = res.scalars().all() assert len(members) == 3 + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_sets_project_members_by_email( + self, test_db, session: AsyncSession, client: AsyncClient + ): + project = await create_project( + session=session, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + admin = await create_user( + session=session, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.ADMIN, + ) + user1 = await create_user( + session=session, + name="user1", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + email="testemail@example.com", + ) + members = [ + { + "username": user1.email, + "project_role": ProjectRole.ADMIN, + }, + ] + body = {"members": members} + response = await client.post( + f"/api/projects/{project.name}/set_members", + headers=get_auth_headers(admin.token), + json=body, + ) + assert response.status_code == 200, response.json() + assert response.json()["members"] == [ + { + "user": { + "id": str(user1.id), + "username": user1.name, + "created_at": "2023-01-02T03:04:00+00:00", + "global_role": user1.global_role, + "email": user1.email, + "active": True, + "permissions": { + "can_create_projects": True, + }, + }, + "project_role": ProjectRole.ADMIN, + "permissions": { + "can_manage_ssh_fleets": True, + }, + }, + ] + res = await session.execute(select(MemberModel)) + members = res.scalars().all() + assert len(members) == 1 + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_manager_cannot_set_project_admins(