Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const UserAutosuggest: React.FC<Props> = ({ optionsFilter, onSelect: onSe
return (
<Autosuggest
value={value}
enteredTextLabel={(text) => `${t('users_autosuggest.placeholder')} ${text}`}
enteredTextLabel={(text) => `${t('users_autosuggest.entered_text')} ${text}`}
onChange={({ detail }) => setValue(detail.value)}
options={filteredOptions}
statusType={isUsersLoading ? 'loading' : undefined}
Expand Down
9 changes: 7 additions & 2 deletions src/dstack/_internal/server/schemas/projects.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down
10 changes: 7 additions & 3 deletions src/dstack/_internal/server/services/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions src/tests/_internal/server/routers/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down