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
5 changes: 5 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,11 @@
"account_settings": "User settings",
"settings": "Settings",
"projects": "Projects",
"projects_table": {
"project": "Project",
"role": "Role",
"empty_message": "This user doesn't own or belong to any projects."
},
"events": "Events",
"public_keys": {
"title": "SSH keys",
Expand Down
74 changes: 44 additions & 30 deletions frontend/src/pages/User/Details/Projects/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';

import {
ListEmptyMessage,
NavigateLink,
// Pagination,
Table,
} from 'components';
import { Button, ListEmptyMessage, Loader, NavigateLink, Table } from 'components';

import { useBreadcrumbs, useCollection } from 'hooks';
import { DEFAULT_TABLE_PAGE_SIZE } from 'consts';
import { useAppSelector, useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks';
import { ROUTES } from 'routes';
import { useGetProjectsQuery } from 'services/project';
import { useLazyGetProjectsQuery } from 'services/project';

import { selectUserData } from 'App/slice';
import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission';

export const UserProjectList: React.FC = () => {
const { t } = useTranslation();
const params = useParams();
const paramUserName = params.userName ?? '';
const navigate = useNavigate();
const userData = useAppSelector(selectUserData);
const isCurrentUser = userData?.username === paramUserName;
const { isAvailableProjectManaging } = useCheckAvailableProjectPermission();

const { isLoading, isFetching, data } = useGetProjectsQuery({ limit: 200 });
const { data, isLoading, isLoadingMore } = useInfiniteScroll<IProject, TGetProjectListParams>({
useLazyQuery: useLazyGetProjectsQuery,
args: { username: paramUserName, limit: DEFAULT_TABLE_PAGE_SIZE },
getPaginationParams: (project) => ({
prev_created_at: project.created_at,
prev_id: project.project_id,
}),
});

useBreadcrumbs([
{
Expand All @@ -36,48 +46,52 @@ export const UserProjectList: React.FC = () => {
]);

const renderEmptyMessage = (): React.ReactNode => {
return <ListEmptyMessage title={t('projects.empty_message_title')} message={t('projects.empty_message_text')} />;
};

const filteredData = useMemo<IProject[]>(() => {
if (!data?.data) return [];
if (isLoading) return null;

// eslint-disable-next-line no-unsafe-optional-chaining
return [...data?.data]
.filter((p) => p.owner.username === paramUserName)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}, [data]);
return (
<ListEmptyMessage title={t('projects.empty_message_title')} message={t('users.projects_table.empty_message')}>
{isCurrentUser && isAvailableProjectManaging && (
<Button onClick={() => navigate(ROUTES.PROJECT.ADD)}>{t('common.create')}</Button>
)}
</ListEmptyMessage>
);
};

const {
items,
collectionProps,
// paginationProps
} = useCollection(filteredData, {
const { items, collectionProps } = useCollection(data, {
filtering: {
empty: renderEmptyMessage(),
},
// pagination: { pageSize: 20 },
selection: {},
});

const columns = [
{
id: 'project_name',
header: `${t('projects.edit.project_name')}`,
header: t('users.projects_table.project'),
cell: (project: IProject) => (
<NavigateLink href={ROUTES.PROJECT.DETAILS.FORMAT(project.project_name)}>{project.project_name}</NavigateLink>
),
},
{
id: 'role',
header: t('users.projects_table.role'),
cell: (project: IProject) => {
if (project.owner.username === paramUserName) return t('projects.edit.owner');

const member = project.members.find(({ user }) => user.username === paramUserName);
return member ? t(`roles.${member.project_role}`) : '-';
},
},
];

return (
<Table
{...collectionProps}
columnDefinitions={columns}
items={items}
loading={isLoading || isFetching}
loading={isLoading}
loadingText={t('common.loading')}
// pagination={<Pagination {...paginationProps} disabled={isLoading} />}
footer={isLoadingMore ? <Loader padding={{ vertical: 'm' }} /> : undefined}
/>
);
};
1 change: 1 addition & 0 deletions frontend/src/types/project.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare type TCreateWizardProjectParams = {

declare type TGetProjectListParams = TBaseRequestListParams & {
include_not_joined?: boolean;
username?: string;
return_total_count?: boolean;
name_pattern?: string;
};
Expand Down
8 changes: 7 additions & 1 deletion src/dstack/_internal/server/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ async def list_projects(
Returns all accessible projects (member projects for regular users, all non-deleted
projects for global admins, plus public projects if `include_not_joined` is `True`).

`members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them.
If `username` is specified, restrict results to projects that user owns or belongs to,
and include only that user's membership in `members`.
Only projects accessible to the authenticated user are returned.

Without `username`, `members` are empty. `backends` are always empty -
call `/api/projects/{project_name}/get` to retrieve them.
"""
if body is None:
# For backward compatibility
Expand All @@ -64,6 +69,7 @@ async def list_projects(
prev_id=body.prev_id,
limit=body.limit,
ascending=body.ascending,
username=body.username,
)
)

Expand Down
6 changes: 6 additions & 0 deletions src/dstack/_internal/server/schemas/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class ListProjectsRequest(CoreModel):
include_not_joined: Annotated[
bool, Field(description="Include public projects where user is not a member.")
] = True
username: Annotated[
Optional[str],
Field(
description="Include only projects owned by or joined by this user, with this user's membership."
),
] = None
return_total_count: Annotated[
bool, Field(description="Return `total_count` with the total number of projects.")
] = False
Expand Down
29 changes: 23 additions & 6 deletions src/dstack/_internal/server/services/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,31 @@ async def list_user_accessible_projects(
prev_id: Optional[uuid.UUID],
limit: int,
ascending: bool,
username: Optional[str] = None,
) -> ProjectsInfoListOrProjectsList:
"""
Returns all projects accessible to the user:
Returns all projects accessible to the authenticated `user`:
- All projects for global admins
- Projects where user is a member (public or private)
- if `include_not_joined`: Public projects where user is NOT a member

`username` optionally filters to projects owned or joined by the profile user and
includes only their membership. Access is still checked for `user`.
"""
filters = [ProjectModel.deleted == False]
user_id = None
if username is not None:
user_id = select(UserModel.id).where(UserModel.name == username).scalar_subquery()
# TODO: Add a members.user_id index for this filter and global run/event lists;
# these lookups can scan the membership table as it grows.
filters.append(
or_(
ProjectModel.owner_id == user_id,
ProjectModel.id.in_(
select(MemberModel.project_id).where(MemberModel.user_id == user_id)
),
)
)
if name_pattern:
name_pattern = name_pattern.replace("_", "/_")
filters.append(ProjectModel.name.ilike(f"%{name_pattern}%", escape="/"))
Expand Down Expand Up @@ -147,15 +164,15 @@ async def list_user_accessible_projects(
if return_total_count:
res = await session.execute(stmt.with_only_columns(safunc.count(literal_column("1"))))
total_count = res.scalar_one()
options = [joinedload(ProjectModel.owner)]
if user_id is not None:
options.append(joinedload(ProjectModel.members.and_(MemberModel.user_id == user_id)))
res = await session.execute(
stmt.where(*pagination_filters)
.order_by(*order_by)
.limit(limit)
.options(joinedload(ProjectModel.owner))
stmt.where(*pagination_filters).order_by(*order_by).limit(limit).options(*options)
)
project_models = res.unique().scalars().all()
projects = [
project_model_to_project(p, include_backends=False, include_members=False)
project_model_to_project(p, include_backends=False, include_members=user_id is not None)
for p in project_models
]
if total_count is None:
Expand Down
67 changes: 67 additions & 0 deletions src/tests/_internal/server/routers/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,73 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client:
],
}

@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
async def test_filters_by_owner_or_member_before_pagination(
self, test_db, session: AsyncSession, client: AsyncClient
):
admin = await create_user(session=session, name="admin")
user = await create_user(session=session, name="member")
await create_project(session=session, owner=user, name="owned")
joined = await create_project(
session=session,
owner=admin,
name="joined",
created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc),
)
for member in (user, admin):
await add_project_member(
session=session, project=joined, user=member, project_role=ProjectRole.MANAGER
)
await create_project(
session=session,
owner=admin,
name="unrelated",
created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc),
)

params = {"username": user.name, "return_total_count": True, "limit": 1}
for expected_name in ("joined", "owned"):
response = await client.post(
"/api/projects/list", headers=get_auth_headers(admin.token), json=params
)
assert response.status_code == 200
data = response.json()
assert data["total_count"] == 2
assert len(data["projects"]) == 1
project_data = data["projects"][0]
assert project_data["project_name"] == expected_name
if expected_name == "joined":
members = project_data["members"]
assert len(members) == 1
assert members[0]["user"]["username"] == user.name
assert members[0]["project_role"] == "manager"
params.update(
prev_created_at=project_data["created_at"], prev_id=project_data["project_id"]
)

@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
async def test_username_filter_preserves_visibility(
self, test_db, session: AsyncSession, client: AsyncClient
):
viewer = await create_user(session=session, name="viewer", global_role=GlobalRole.USER)
owner = await create_user(session=session, name="owner")
await create_project(session=session, owner=owner, name="private")
public = await create_project(session=session, owner=owner, name="public", is_public=True)
shared = await create_project(session=session, owner=owner, name="shared")
await add_project_member(
session=session, project=shared, user=viewer, project_role=ProjectRole.USER
)

response = await client.post(
"/api/projects/list",
headers=get_auth_headers(viewer.token),
json={"username": owner.name},
)
assert response.status_code == 200
assert {p["project_name"] for p in response.json()} == {public.name, shared.name}


class TestListOnlyNoFleets:
@pytest.mark.asyncio
Expand Down
Loading