diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f5d910ed71..226d5edde3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -56,6 +56,9 @@ export const API = { DETAILS: (name: IProject['project_name']) => `${API.PROJECTS.BASE()}/${name}`, DETAILS_INFO: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/get`, SET_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/set_members`, + ADD_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/add_members`, + REMOVE_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/remove_members`, + UPDATE: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update`, // Repos REPOS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/repos`, diff --git a/frontend/src/components/ButtonWithConfirmation/index.tsx b/frontend/src/components/ButtonWithConfirmation/index.tsx index eeaa556a06..78c2793d9c 100644 --- a/frontend/src/components/ButtonWithConfirmation/index.tsx +++ b/frontend/src/components/ButtonWithConfirmation/index.tsx @@ -6,7 +6,13 @@ import { ConfirmationDialog } from '../ConfirmationDialog'; import { IProps } from './types'; -export const ButtonWithConfirmation: React.FC = ({ confirmTitle, confirmContent, onClick, ...props }) => { +export const ButtonWithConfirmation: React.FC = ({ + confirmTitle, + confirmContent, + onClick, + confirmButtonLabel, + ...props +}) => { const [showDeleteConfirm, setShowConfirmDelete] = useState(false); const toggleDeleteConfirm = () => { @@ -31,6 +37,7 @@ export const ButtonWithConfirmation: React.FC = ({ confirmTitle, confirm onConfirm={onConfirm} title={confirmTitle} content={content} + confirmButtonLabel={confirmButtonLabel} /> ); diff --git a/frontend/src/components/ButtonWithConfirmation/types.ts b/frontend/src/components/ButtonWithConfirmation/types.ts index a303a1916e..b796b4dfbb 100644 --- a/frontend/src/components/ButtonWithConfirmation/types.ts +++ b/frontend/src/components/ButtonWithConfirmation/types.ts @@ -5,5 +5,6 @@ import type { IProps as ButtonProps } from '../Button'; export interface IProps extends Omit { confirmTitle?: string; confirmContent?: ReactNode; + confirmButtonLabel?: string; onClick?: () => void; } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7c8f8e6e05..7aedc2d8ca 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -172,6 +172,14 @@ "runs": "Runs", "tags": "Tags", "settings": "Settings", + "join": "Join", + "leave_confirm_title": "Leave project", + "leave_confirm_message": "Are you sure you want to leave this project?", + "leave": "Leave", + "join_success": "Successfully joined the project", + "leave_success": "Successfully left the project", + "join_error": "Failed to join project", + "leave_error": "Failed to leave project", "card": { "backend": "Backend", "settings": "Settings" @@ -181,6 +189,8 @@ "project_name": "Project name", "owner": "Owner", "project_name_description": "Only latin characters, dashes, underscores, and digits", + "is_public": "Make project public", + "is_public_description": "Public projects can be accessed by any user without being a member", "backend": "Backend", "backend_config": "Backend config", "backend_config_description": "Specify the backend config in the YAML format. Click Info for examples.", @@ -189,6 +199,13 @@ "members_empty_message_title": "No members", "members_empty_message_text": "Select project's members", "update_members_success": "Members are updated", + "update_visibility_success": "Project visibility updated successfully", + "update_visibility_confirm_title": "Change project visibility", + "update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.", + "change_visibility": "Change visibility", + "project_visibility": "Project visibility", + "project_visibility_description": "Control who can access this project", + "make_project_public": "Make project public", "delete_project_confirm_title": "Delete project", "delete_project_confirm_message": "Are you sure you want to delete this project?", "delete_projects_confirm_title": "Delete projects", @@ -282,6 +299,10 @@ "error_notification": "Update project error", "validation": { "user_name_format": "Only letters, numbers, - or _" + }, + "visibility": { + "private": "Private", + "public": "Public" } }, "create": { diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 475f40b96c..d858249ce2 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { debounce } from 'lodash'; @@ -17,18 +17,21 @@ import { SelectCSD, SpaceBetween, StatusIndicator, + Toggle, } from 'components'; import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants'; import { useBreadcrumbs, useHelpPanel, useNotifications } from 'hooks'; import { riseRouterException } from 'libs'; import { ROUTES } from 'routes'; -import { useGetProjectQuery, useUpdateProjectMembersMutation } from 'services/project'; +import { useGetProjectQuery, useUpdateProjectMembersMutation, useUpdateProjectMutation } from 'services/project'; import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission'; import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand'; import { useDeleteProject } from 'pages/Project/hooks/useDeleteProject'; import { ProjectMembers } from 'pages/Project/Members'; +import { getProjectRoleByUserName } from 'pages/Project/utils'; +import { useGetUserDataQuery } from 'services/user'; import { useBackendsTable } from '../../Backends/hooks'; import { BackendsTable } from '../../Backends/Table'; @@ -51,23 +54,40 @@ export const ProjectSettings: React.FC = () => { const [pushNotification] = useNotifications(); const [updateProjectMembers] = useUpdateProjectMembersMutation(); + const [updateProject] = useUpdateProjectMutation(); const { deleteProject, isDeleting } = useDeleteProject(); + const { data: currentUser } = useGetUserDataQuery({}); const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName }); useEffect(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (error?.status === 404) { + if (error && 'status' in error && error.status === 404) { riseRouterException(); } }, [error]); + const currentUserRole = data ? getProjectRoleByUserName(data, currentUser?.username ?? '') : null; + const isProjectMember = currentUserRole !== null; + const currentOwner = { label: data?.owner.username, value: data?.owner.username, }; + const visibilityOptions = [ + { label: t('projects.edit.visibility.private') || '', value: 'private' }, + { label: t('projects.edit.visibility.public') || '', value: 'public' }, + ]; + + const currentVisibility = data?.isPublic ? 'public' : 'private'; + const [selectedVisibility, setSelectedVisibility] = useState( + data?.isPublic ? visibilityOptions[1] : visibilityOptions[0] + ); + + useEffect(() => { + setSelectedVisibility(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]); + }, [data]); + const { data: backendsData, isDeleting: isDeletingBackend, @@ -103,7 +123,7 @@ export const ProjectSettings: React.FC = () => { content: t('projects.edit.update_members_success'), }); }) - .catch((error) => { + .catch((error: any) => { pushNotification({ type: 'error', content: t('common.server_error', { error: error?.data?.detail?.msg }), @@ -113,6 +133,26 @@ export const ProjectSettings: React.FC = () => { const debouncedMembersHandler = useCallback(debounce(changeMembersHandler, 1000), []); + const changeVisibilityHandler = (is_public: boolean) => { + updateProject({ + project_name: paramProjectName, + is_public: is_public, + }) + .unwrap() + .then(() => { + pushNotification({ + type: 'success', + content: t('projects.edit.update_visibility_success'), + }); + }) + .catch((error: any) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: error?.data?.detail?.msg }), + }); + }); + }; + const isDisabledButtons = useMemo(() => { return isDeleting || !data || !isAvailableDeletingPermission(data); }, [data, isDeleting, isAvailableDeletingPermission]); @@ -120,7 +160,11 @@ export const ProjectSettings: React.FC = () => { const deleteProjectHandler = () => { if (!data) return; - deleteProject(data).then(() => navigate(ROUTES.PROJECT.LIST)); + deleteProject(data) + .then(() => navigate(ROUTES.PROJECT.LIST)) + .catch((error: any) => { + console.error('Delete project failed:', error); + }); }; if (isLoadingPage) @@ -134,42 +178,44 @@ export const ProjectSettings: React.FC = () => { <> {data && backendsData && gatewaysData && ( - openHelpPanel(CLI_INFO)} />}> - {t('projects.edit.cli')} - - } - > - - - Run the following commands to set up the CLI for this project - - -
- - {configCliCommand} - -
- {t('common.copied')}} - > -
-
-
-
-
+ {isProjectMember && ( + openHelpPanel(CLI_INFO)} />}> + {t('projects.edit.cli')} + + } + > + + + Run the following commands to set up the CLI for this project + + +
+ + {configCliCommand} + +
+ {t('common.copied')}} + > +
+
+
+
+
+ )} { members={data.members} readonly={!isProjectManager(data)} isAdmin={isProjectAdmin(data)} + project={data} /> {t('common.danger_zone')}}> @@ -216,6 +263,43 @@ export const ProjectSettings: React.FC = () => { )} + {isAvailableProjectManaging && ( + <> + + {t('projects.edit.project_visibility')} + + +
+ changeVisibilityHandler(selectedVisibility.value === 'public')} + confirmTitle={t('projects.edit.update_visibility_confirm_title')} + confirmButtonLabel={t('projects.edit.change_visibility')} + confirmContent={ + + + {t('projects.edit.update_visibility_confirm_message')} + +
+ setSelectedVisibility(event.detail.selectedOption as { label: string; value: string })} + expandToViewport={true} + filteringType="auto" + /> +
+
+ } + > + {t('projects.edit.change_visibility')} +
+
+ + )} + {t('projects.edit.owner')} diff --git a/frontend/src/pages/Project/Details/index.tsx b/frontend/src/pages/Project/Details/index.tsx index b26c921a05..81d64f9d25 100644 --- a/frontend/src/pages/Project/Details/index.tsx +++ b/frontend/src/pages/Project/Details/index.tsx @@ -1,14 +1,37 @@ -import React from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import React, { useMemo } from 'react'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; -import { ContentLayout, DetailsHeader } from 'components'; +import { Button, ContentLayout, DetailsHeader } from 'components'; + +import { useAppSelector, useNotifications } from 'hooks'; +import { selectUserData } from 'App/slice'; +import { ROUTES } from 'routes'; +import { useGetProjectQuery, useAddProjectMemberMutation, useRemoveProjectMemberMutation } from 'services/project'; +import { getProjectRoleByUserName } from '../utils'; +import { useProjectMemberActions } from '../hooks/useProjectMemberActions'; export const ProjectDetails: React.FC = () => { + const { t } = useTranslation(); const params = useParams(); + const navigate = useNavigate(); const paramProjectName = params.projectName ?? ''; + const userData = useAppSelector(selectUserData); + const { handleJoinProject, handleLeaveProject, isMemberActionLoading } = useProjectMemberActions(); + + const { data: project } = useGetProjectQuery({ name: paramProjectName }); + + const currentUserRole = useMemo(() => { + if (!userData?.username || !project) return null; + return getProjectRoleByUserName(project, userData.username); + }, [project, userData?.username]); + + const isProjectOwner = userData?.username === project?.owner.username; + + const isMember = currentUserRole !== null; return ( - }> + }> ); diff --git a/frontend/src/pages/Project/Form/index.tsx b/frontend/src/pages/Project/Form/index.tsx index 5103676c8b..c95f7f98fc 100644 --- a/frontend/src/pages/Project/Form/index.tsx +++ b/frontend/src/pages/Project/Form/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Button, Container, FormInput, FormUI, Header, SpaceBetween } from 'components'; +import { Button, Container, FormCheckbox, FormInput, FormUI, Header, SpaceBetween } from 'components'; import { useNotifications } from 'hooks'; import { isResponseServerError, isResponseServerFormFieldError } from 'libs'; @@ -16,6 +16,7 @@ export const ProjectForm: React.FC = ({ initialValues, onCancel, loading const formMethods = useForm({ defaultValues: { + isPublic: false, ...initialValues, }, }); @@ -25,7 +26,13 @@ export const ProjectForm: React.FC = ({ initialValues, onCancel, loading const onSubmit = (data: IProject) => { clearErrors(); - onSubmitProp(data).catch((errorResponse) => { + // Transform frontend camelCase to backend snake_case + const backendData = { + project_name: data.project_name, + is_public: data.isPublic, + }; + + onSubmitProp(backendData as unknown as IProject).catch((errorResponse) => { const errorRequestData = errorResponse?.data; if (isResponseServerError(errorRequestData)) { @@ -81,6 +88,14 @@ export const ProjectForm: React.FC = ({ initialValues, onCancel, loading }, }} /> + +
diff --git a/frontend/src/pages/Project/List/hooks/useColumnsDefinitions.tsx b/frontend/src/pages/Project/List/hooks/useColumnsDefinitions.tsx index 075547ff52..54b85ce94c 100644 --- a/frontend/src/pages/Project/List/hooks/useColumnsDefinitions.tsx +++ b/frontend/src/pages/Project/List/hooks/useColumnsDefinitions.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; // import { useNavigate } from 'react-router-dom'; -import { /*Button,*/ NavigateLink } from 'components'; +import { /*Button,*/ NavigateLink, StatusIndicator } from 'components'; // import { ButtonWithConfirmation } from 'components/ButtonWithConfirmation'; import { ROUTES } from 'routes'; @@ -69,6 +69,15 @@ export const useColumnsDefinitions = ({ loading, onDeleteClick }: hookArgs) => { ), }, + { + id: 'visibility', + header: t('projects.edit.project_visibility'), + cell: (project: IProject) => ( + + {project.isPublic ? t('projects.edit.visibility.public') : t('projects.edit.visibility.private')} + + ), + }, ]; }, [loading, onDeleteClick]); diff --git a/frontend/src/pages/Project/Members/index.tsx b/frontend/src/pages/Project/Members/index.tsx index b728d1962f..33eafc0813 100644 --- a/frontend/src/pages/Project/Members/index.tsx +++ b/frontend/src/pages/Project/Members/index.tsx @@ -1,12 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; -import { Button, FormSelect, Header, Link, ListEmptyMessage, Pagination, Table } from 'components'; +import { Button, ButtonWithConfirmation, FormSelect, Header, Link, ListEmptyMessage, Pagination, SpaceBetween, Table } from 'components'; -import { useCollection } from 'hooks'; +import { useAppSelector, useCollection, useNotifications } from 'hooks'; +import { selectUserData } from 'App/slice'; import { ROUTES } from 'routes'; import { useGetUserListQuery } from 'services/user'; +import { useProjectMemberActions } from '../hooks/useProjectMemberActions'; import { UserAutosuggest } from './UsersAutosuggest'; @@ -16,10 +19,14 @@ import { TRoleSelectOption } from 'pages/User/Form/types'; import styles from './styles.module.scss'; -export const ProjectMembers: React.FC = ({ members, loading, onChange, readonly, isAdmin }) => { +export const ProjectMembers: React.FC = ({ members, loading, onChange, readonly, isAdmin, project }) => { const { t } = useTranslation(); + const navigate = useNavigate(); + const [pushNotification] = useNotifications(); const [selectedItems, setSelectedItems] = useState([]); const { data: usersData } = useGetUserListQuery(); + const userData = useAppSelector(selectUserData); + const { handleJoinProject, handleLeaveProject, isMemberActionLoading } = useProjectMemberActions(); const { handleSubmit, control, getValues, setValue } = useForm({ defaultValues: { members: members ?? [] }, @@ -30,6 +37,16 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r name: 'members', }); + const currentUserRole = useMemo(() => { + if (!userData?.username) return null; + const member = members?.find(m => m.user.username === userData.username); + return member?.project_role || null; + }, [members, userData?.username]); + + const isProjectOwner = userData?.username === project?.owner.username; + + const isMember = currentUserRole !== null; + useEffect(() => { if (members) { setValue('members', members); @@ -60,7 +77,7 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r { label: t('roles.user'), value: 'user' }, ]; - const addMember = (username: string) => { + const addMemberHandler = (username: string) => { const selectedUser = usersData?.find((u) => u.username === username); if (selectedUser) { @@ -90,6 +107,66 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r onChangeHandler(); }; + const renderMemberActions = () => { + const actions = []; + + // Add management actions only if not readonly + if (!readonly) { + actions.push( + + ); + } + + // Add join/leave button if user is authenticated (available even in readonly mode) + if (userData?.username && project) { + if (!isMember) { + actions.unshift( + + ); + } else { + // Check if user is the last admin - if so, don't show leave button + const adminCount = project.members.filter(member => member.project_role === 'admin').length; + const isLastAdmin = currentUserRole === 'admin' && adminCount <= 1; + + if (!isLastAdmin) { + // Only show leave button if user is not the last admin + actions.unshift( + handleLeaveProject(project.project_name, userData.username!)} + disabled={isMemberActionLoading} + variant="danger-normal" + confirmTitle={t('projects.leave_confirm_title')} + confirmContent={t('projects.leave_confirm_message')} + confirmButtonLabel={t('projects.leave')} + > + {isMemberActionLoading + ? t('common.loading') + : t('projects.leave') + } + + ); + } + } + } + + return actions.length > 0 ? {actions} : undefined; + }; + const COLUMN_DEFINITIONS = [ { id: 'name', @@ -153,13 +230,7 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r
- {t('common.delete')} - - ) - } + actions={renderMemberActions()} > {t('projects.edit.members.section_title')}
@@ -168,7 +239,7 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r readonly ? undefined : ( addMember(detail.value)} + onSelect={({ detail }) => addMemberHandler(detail.value)} optionsFilter={(options) => options.filter((o) => !fields.find((f) => f.user.username === o.value))} /> ) diff --git a/frontend/src/pages/Project/Members/types.ts b/frontend/src/pages/Project/Members/types.ts index 6b969d24ec..282791f850 100644 --- a/frontend/src/pages/Project/Members/types.ts +++ b/frontend/src/pages/Project/Members/types.ts @@ -4,6 +4,7 @@ export interface IProps { onChange: (users: IProjectMember[]) => void; readonly?: boolean; isAdmin?: boolean; + project?: IProject; } export type TProjectMemberWithIndex = IProjectMember & { index: number }; diff --git a/frontend/src/pages/Project/hooks/useProjectMemberActions.ts b/frontend/src/pages/Project/hooks/useProjectMemberActions.ts new file mode 100644 index 0000000000..c474aedc3a --- /dev/null +++ b/frontend/src/pages/Project/hooks/useProjectMemberActions.ts @@ -0,0 +1,86 @@ +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { useNotifications } from 'hooks'; +import { ROUTES } from 'routes'; +import { useAddProjectMemberMutation, useRemoveProjectMemberMutation } from 'services/project'; + +export const useProjectMemberActions = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [pushNotification] = useNotifications(); + const [addMember, { isLoading: isAdding }] = useAddProjectMemberMutation(); + const [removeMember, { isLoading: isRemoving }] = useRemoveProjectMemberMutation(); + + const handleJoinProject = async (projectName: string, username: string) => { + if (!username || !projectName) return; + + try { + await addMember({ + project_name: projectName, + username: username, + project_role: 'user', + }).unwrap(); + + pushNotification({ + type: 'success', + content: t('projects.join_success'), + }); + } catch (error) { + console.error('Failed to join project:', error); + pushNotification({ + type: 'error', + content: t('projects.join_error'), + }); + } + }; + + const handleLeaveProject = async (projectName: string, username: string, onLeaveSuccess?: () => void) => { + if (!username || !projectName) return; + + try { + await removeMember({ + project_name: projectName, + username: username, + }).unwrap(); + + pushNotification({ + type: 'success', + content: t('projects.leave_success'), + }); + + // Optionally call the success callback + onLeaveSuccess?.(); + } catch (error: any) { + console.error('Failed to leave project:', error); + + // Extract the specific error message from the backend + let errorMessage = t('projects.leave_error'); + if (error?.data?.detail) { + if (Array.isArray(error.data.detail)) { + // Handle array format: [{msg: "error message"}] + errorMessage = error.data.detail[0]?.msg || errorMessage; + } else if (typeof error.data.detail === 'string') { + // Handle string format + errorMessage = error.data.detail; + } else if (error.data.detail.msg) { + // Handle object format: {msg: "error message"} + errorMessage = error.data.detail.msg; + } + } + + pushNotification({ + type: 'error', + content: errorMessage, + }); + } + }; + + const isMemberActionLoading = isAdding || isRemoving; + + return { + handleJoinProject, + handleLeaveProject, + isMemberActionLoading + }; +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 8812f71996..a953cbc6b3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -63,25 +63,21 @@ export const router = createBrowserRouter([ element: , children: [ { - path: ROUTES.PROJECT.DETAILS.SETTINGS.TEMPLATE, + index: true, element: , }, - { path: ROUTES.PROJECT.BACKEND.ADD.TEMPLATE, element: , }, - { path: ROUTES.PROJECT.BACKEND.EDIT.TEMPLATE, element: , }, - { path: ROUTES.PROJECT.GATEWAY.ADD.TEMPLATE, element: , }, - { path: ROUTES.PROJECT.GATEWAY.EDIT.TEMPLATE, element: , diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 712a95f950..3da6d9f880 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -4,6 +4,12 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { base64ToArrayBuffer } from 'libs'; import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; +// Helper function to transform backend response to frontend format +const transformProjectResponse = (project: any): IProject => ({ + ...project, + isPublic: project.is_public, +}); + export const projectApi = createApi({ reducerPath: 'projectApi', refetchOnMountOrArgChange: true, @@ -22,6 +28,9 @@ export const projectApi = createApi({ }; }, + transformResponse: (response: any[]): IProject[] => + response.map(transformProjectResponse), + providesTags: (result) => result ? [...result.map(({ project_name }) => ({ type: 'Projects' as const, id: project_name })), 'Projects'] @@ -36,6 +45,8 @@ export const projectApi = createApi({ }; }, + transformResponse: transformProjectResponse, + providesTags: (result) => (result ? [{ type: 'Projects' as const, id: result.project_name }] : []), }), @@ -46,6 +57,8 @@ export const projectApi = createApi({ body: project, }), + transformResponse: transformProjectResponse, + invalidatesTags: () => ['Projects'], }), @@ -58,6 +71,36 @@ export const projectApi = createApi({ }, }), + transformResponse: transformProjectResponse, + + invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }], + }), + + addProjectMember: builder.mutation({ + query: ({ project_name, username, project_role = 'user' }) => ({ + url: API.PROJECTS.ADD_MEMBERS(project_name), + method: 'POST', + body: { + members: [{ username, project_role }], + }, + }), + + transformResponse: transformProjectResponse, + + invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }], + }), + + removeProjectMember: builder.mutation({ + query: ({ project_name, username }) => ({ + url: API.PROJECTS.REMOVE_MEMBERS(project_name), + method: 'POST', + body: { + usernames: [username], + }, + }), + + transformResponse: transformProjectResponse, + invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }], }), @@ -101,6 +144,16 @@ export const projectApi = createApi({ providesTags: () => ['ProjectRepos'], }), + + updateProject: builder.mutation({ + query: ({ project_name, is_public }) => ({ + url: API.PROJECTS.UPDATE(project_name), + method: 'POST', + body: { is_public }, + }), + transformResponse: transformProjectResponse, + invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }], + }), }), }); @@ -109,7 +162,10 @@ export const { useGetProjectQuery, useCreateProjectMutation, useUpdateProjectMembersMutation, + useAddProjectMemberMutation, + useRemoveProjectMemberMutation, useDeleteProjectsMutation, useGetProjectLogsQuery, useGetProjectReposQuery, + useUpdateProjectMutation, } = projectApi; diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 4418fa8810..6121b6e6ff 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -1,4 +1,3 @@ - declare type TProjectBackend = { name: string, config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack @@ -8,7 +7,8 @@ declare interface IProject { members: IProjectMember[], backends: TProjectBackend[] owner: IUser | {username: string}, - created_at: string + created_at: string, + isPublic: boolean } declare interface IProjectMember { diff --git a/src/dstack/_internal/server/routers/gateways.py b/src/dstack/_internal/server/routers/gateways.py index 604519af0c..e0e0ad37d1 100644 --- a/src/dstack/_internal/server/routers/gateways.py +++ b/src/dstack/_internal/server/routers/gateways.py @@ -9,7 +9,10 @@ from dstack._internal.core.errors import ResourceNotExistsError from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel -from dstack._internal.server.security.permissions import ProjectAdmin, ProjectMember +from dstack._internal.server.security.permissions import ( + ProjectAdmin, + ProjectMemberOrPublicAccess, +) from dstack._internal.server.utils.routers import get_base_api_additional_responses router = APIRouter( @@ -22,7 +25,7 @@ @router.post("/list") async def list_gateways( session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ) -> List[models.Gateway]: _, project = user_project return await gateways.list_project_gateways(session=session, project=project) @@ -32,7 +35,7 @@ async def list_gateways( async def get_gateway( body: schemas.GetGatewayRequest, session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ) -> models.Gateway: _, project = user_project gateway = await gateways.get_gateway_by_name(session=session, project=project, name=body.name) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 8f5455a2f7..3f34dcd024 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -7,13 +7,18 @@ from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel from dstack._internal.server.schemas.projects import ( + AddProjectMemberRequest, CreateProjectRequest, DeleteProjectsRequest, + RemoveProjectMemberRequest, SetProjectMembersRequest, + UpdateProjectRequest, ) from dstack._internal.server.security.permissions import ( Authenticated, ProjectManager, + ProjectManagerOrPublicProject, + ProjectManagerOrSelfLeave, ProjectMemberOrPublicAccess, ) from dstack._internal.server.services import projects @@ -92,3 +97,60 @@ async def set_project_members( ) await session.refresh(project) return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/add_members", +) +async def add_project_members( + body: AddProjectMemberRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrPublicProject()), +) -> Project: + user, project = user_project + await projects.add_project_members( + session=session, + user=user, + project=project, + members=body.members, + ) + await session.refresh(project) + return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/remove_members", +) +async def remove_project_members( + body: RemoveProjectMemberRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrSelfLeave()), +) -> Project: + user, project = user_project + await projects.remove_project_members( + session=session, + user=user, + project=project, + usernames=body.usernames, + ) + await session.refresh(project) + return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/update", +) +async def update_project( + body: UpdateProjectRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()), +) -> Project: + user, project = user_project + await projects.update_project( + session=session, + user=user, + project=project, + is_public=body.is_public, + ) + await session.refresh(project) + return projects.project_model_to_project(project) diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index 3e5b99b772..355bb3a770 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -11,6 +11,10 @@ class CreateProjectRequest(CoreModel): is_public: bool = False +class UpdateProjectRequest(CoreModel): + is_public: bool + + class DeleteProjectsRequest(CoreModel): projects_names: List[str] @@ -25,3 +29,11 @@ class MemberSetting(CoreModel): class SetProjectMembersRequest(CoreModel): members: List[MemberSetting] + + +class AddProjectMemberRequest(CoreModel): + members: List[MemberSetting] + + +class RemoveProjectMemberRequest(CoreModel): + usernames: List[str] diff --git a/src/dstack/_internal/server/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index 79080aa7bd..0ecddf1d9e 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -58,7 +58,7 @@ async def __call__( raise error_invalid_token() project = await get_project_model_by_name(session=session, project_name=project_name) if project is None: - raise error_forbidden() + raise error_not_found() if user.global_role == GlobalRole.ADMIN: return user, project project_role = get_user_project_role(user=user, project=project) @@ -68,6 +68,10 @@ async def __call__( class ProjectManager: + """ + Allows project admins and managers to manage projects. + """ + async def __call__( self, project_name: str, @@ -79,12 +83,15 @@ async def __call__( raise error_invalid_token() project = await get_project_model_by_name(session=session, project_name=project_name) if project is None: - raise error_forbidden() + raise error_not_found() + if user.global_role == GlobalRole.ADMIN: return user, project + project_role = get_user_project_role(user=user, project=project) if project_role in [ProjectRole.ADMIN, ProjectRole.MANAGER]: return user, project + raise error_forbidden() @@ -135,6 +142,72 @@ async def __call__( raise error_forbidden() +class ProjectManagerOrPublicProject: + """ + Allows: + 1. Project managers to perform member management operations + 2. Access to public projects for any authenticated user + """ + + def __init__(self): + self.project_manager = ProjectManager() + + async def __call__( + self, + project_name: str, + session: AsyncSession = Depends(get_session), + token: HTTPAuthorizationCredentials = Security(HTTPBearer()), + ) -> Tuple[UserModel, ProjectModel]: + user = await log_in_with_token(session=session, token=token.credentials) + if user is None: + raise error_invalid_token() + project = await get_project_model_by_name(session=session, project_name=project_name) + if project is None: + raise error_not_found() + + if user.global_role == GlobalRole.ADMIN: + return user, project + + project_role = get_user_project_role(user=user, project=project) + if project_role in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + return user, project + + if project.is_public: + return user, project + + raise error_forbidden() + + +class ProjectManagerOrSelfLeave: + """ + Allows: + 1. Project managers to remove any members + 2. Any project member to leave (remove themselves) + """ + + async def __call__( + self, + project_name: str, + session: AsyncSession = Depends(get_session), + token: HTTPAuthorizationCredentials = Security(HTTPBearer()), + ) -> Tuple[UserModel, ProjectModel]: + user = await log_in_with_token(session=session, token=token.credentials) + if user is None: + raise error_invalid_token() + project = await get_project_model_by_name(session=session, project_name=project_name) + if project is None: + raise error_not_found() + + if user.global_role == GlobalRole.ADMIN: + return user, project + + project_role = get_user_project_role(user=user, project=project) + if project_role is not None: + return user, project + + raise error_forbidden() + + class OptionalServiceAccount: def __init__(self, token: Optional[str]) -> None: self._token = token diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 48fa3614eb..1e46979dcf 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -74,8 +74,8 @@ async def list_user_accessible_projects( ) -> List[Project]: """ Returns all projects accessible to the user: - - For global admins: ALL projects in the system - - For regular users: Projects where user is a member + public projects where user is NOT a member + - Projects where user is a member (public or private) + - Public projects where user is NOT a member """ if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) @@ -150,6 +150,17 @@ async def create_project( return project_model_to_project(project_model) +async def update_project( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + is_public: bool, +): + """Update project visibility (public/private).""" + project.is_public = is_public + await session.commit() + + async def delete_projects( session: AsyncSession, user: UserModel, @@ -163,7 +174,8 @@ async def delete_projects( for project_name in projects_names: if project_name not in user_project_names: raise ForbiddenError() - for project in user_projects: + projects_to_delete = [p for p in user_projects if p.name in projects_names] + for project in projects_to_delete: if not _is_project_admin(user=user, project=project): raise ForbiddenError() if all(name in projects_names for name in user_project_names): @@ -187,7 +199,6 @@ async def set_project_members( project: ProjectModel, members: List[MemberSetting], ): - # reload with members project = await get_project_model_by_name_or_error( session=session, project_name=project.name, @@ -212,7 +223,6 @@ async def set_project_members( 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): @@ -230,6 +240,77 @@ async def set_project_members( await session.commit() +async def add_project_members( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + members: List[MemberSetting], +): + """Add multiple members to a project.""" + project = await get_project_model_by_name_or_error( + session=session, + project_name=project.name, + ) + requesting_user_role = get_user_project_role(user=user, project=project) + + is_self_join_to_public = ( + len(members) == 1 + and project.is_public + and (members[0].username == user.name or members[0].username == user.email) + and requesting_user_role is None + ) + + if not is_self_join_to_public: + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + raise ForbiddenError("Access denied: insufficient permissions to add members") + + if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER: + for member in members: + if member.project_role == ProjectRole.ADMIN: + raise ForbiddenError( + "Access denied: only global admins can add project admins" + ) + else: + if members[0].project_role != ProjectRole.USER: + raise ForbiddenError("Access denied: can only join public projects as user role") + + usernames = [member.username for member in members] + + res = await session.execute( + select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))) + ) + users_found = res.scalars().all() + + username_to_user = {user.name: user for user in users_found} + email_to_user = {user.email: user for user in users_found if user.email} + + member_by_user_id = {m.user_id: m for m in project.members} + + for member_setting in members: + user_to_add = username_to_user.get(member_setting.username) or email_to_user.get( + member_setting.username + ) + if user_to_add is None: + raise ServerClientError(f"User not found: {member_setting.username}") + + if user_to_add.id in member_by_user_id: + existing_member = member_by_user_id[user_to_add.id] + if existing_member.project_role != member_setting.project_role: + existing_member.project_role = member_setting.project_role + else: + await add_project_member( + session=session, + project=project, + user=user_to_add, + project_role=member_setting.project_role, + member_num=None, + commit=False, + ) + member_by_user_id[user_to_add.id] = None + + await session.commit() + + async def add_project_member( session: AsyncSession, project: ProjectModel, @@ -497,8 +578,86 @@ def _is_project_admin( user: UserModel, project: ProjectModel, ) -> bool: + if user.id == project.owner_id: + return True + for m in project.members: if user.id == m.user_id: if m.project_role == ProjectRole.ADMIN: return True return False + + +async def remove_project_members( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + usernames: List[str], +): + """Remove multiple members from a project.""" + project = await get_project_model_by_name_or_error( + session=session, + project_name=project.name, + ) + requesting_user_role = get_user_project_role(user=user, project=project) + + is_self_leave = ( + len(usernames) == 1 + and (usernames[0] == user.name or usernames[0] == user.email) + and requesting_user_role is not None + ) + + if not is_self_leave: + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + raise ForbiddenError("Access denied: insufficient permissions to remove members") + + res = await session.execute( + select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))) + ) + users_found = res.scalars().all() + + username_to_user = {user.name: user for user in users_found} + email_to_user = {user.email: user for user in users_found if user.email} + + member_by_user_id = {m.user_id: m for m in project.members} + + members_to_remove = [] + admin_removals = 0 + + for username in usernames: + user_to_remove = username_to_user.get(username) or email_to_user.get(username) + if user_to_remove is None: + raise ServerClientError(f"User not found: {username}") + + if user_to_remove.id not in member_by_user_id: + raise ServerClientError(f"User is not a member of this project: {username}") + + member_to_remove = member_by_user_id[user_to_remove.id] + + if member_to_remove.project_role == ProjectRole.ADMIN: + if is_self_leave: + total_admins = sum( + 1 for member in project.members if member.project_role == ProjectRole.ADMIN + ) + if total_admins <= 1: + raise ServerClientError("Cannot leave project: you are the last admin") + else: + if user.global_role != GlobalRole.ADMIN: + raise ForbiddenError( + f"Access denied: only global admins can remove project admins (user: {username})" + ) + admin_removals += 1 + + members_to_remove.append(member_to_remove) + + if not is_self_leave: + total_admins = sum( + 1 for member in project.members if member.project_role == ProjectRole.ADMIN + ) + if admin_removals >= total_admins: + raise ServerClientError("Cannot remove all project admins") + + for member in members_to_remove: + await session.delete(member) + + await session.commit() diff --git a/src/dstack/_internal/server/services/users.py b/src/dstack/_internal/server/services/users.py index 8504f2af75..7aaf4b9799 100644 --- a/src/dstack/_internal/server/services/users.py +++ b/src/dstack/_internal/server/services/users.py @@ -44,9 +44,7 @@ async def list_users_for_user( session: AsyncSession, user: UserModel, ) -> List[User]: - if user.global_role == GlobalRole.ADMIN: - return await list_all_users(session=session) - return [user_model_to_user(user)] + return await list_all_users(session=session) async def list_all_users( diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index da6eccc4f4..0fb47c9ab5 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -3,10 +3,13 @@ from pydantic import parse_obj_as from dstack._internal.core.models.projects import Project +from dstack._internal.core.models.users import ProjectRole from dstack._internal.server.schemas.projects import ( + AddProjectMemberRequest, CreateProjectRequest, DeleteProjectsRequest, MemberSetting, + RemoveProjectMemberRequest, SetProjectMembersRequest, ) from dstack.api.server._group import APIClientGroup @@ -34,3 +37,24 @@ def set_members(self, project_name: str, members: List[MemberSetting]) -> Projec body = SetProjectMembersRequest(members=members) resp = self._request(f"/api/projects/{project_name}/set_members", body=body.json()) return parse_obj_as(Project.__response__, resp.json()) + + def add_member(self, project_name: str, username: str, project_role: ProjectRole) -> Project: + member_setting = MemberSetting(username=username, project_role=project_role) + body = AddProjectMemberRequest(members=[member_setting]) + resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def add_members(self, project_name: str, members: List[MemberSetting]) -> Project: + body = AddProjectMemberRequest(members=members) + resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def remove_member(self, project_name: str, username: str) -> Project: + body = RemoveProjectMemberRequest(usernames=[username]) + resp = self._request(f"/api/projects/{project_name}/remove_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def remove_members(self, project_name: str, usernames: List[str]) -> Project: + body = RemoveProjectMemberRequest(usernames=usernames) + resp = self._request(f"/api/projects/{project_name}/remove_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 620c2d1eec..b0364b4235 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -336,19 +336,16 @@ async def test_no_project_quota_for_global_admins( async def test_forbids_if_no_permission_to_create_projects( self, test_db, session: AsyncSession, client: AsyncClient ): - user = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + user = await create_user(session=session, global_role=GlobalRole.USER) with default_permissions_context( - DefaultPermissions( - allow_non_admins_create_projects=False, - allow_non_admins_manage_ssh_fleets=True, - ) + DefaultPermissions(allow_non_admins_create_projects=False) ): response = await client.post( "/api/projects/create", headers=get_auth_headers(user.token), - json={"project_name": "test_project"}, + json={"project_name": "new_project"}, ) - assert response.status_code == 403 + assert response.status_code == 403 @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @@ -994,143 +991,188 @@ async def test_global_admin_manager_can_set_project_admins( @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_non_manager_cannot_set_project_members( + async def test_add_member_errors_on_nonexistent_user( self, test_db, session: AsyncSession, client: AsyncClient ): - project = await create_project(session=session) - user = await create_user(session=session, global_role=GlobalRole.USER) + # Setup project and admin + 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) + ) await add_project_member( - session=session, - project=project, - user=user, - project_role=ProjectRole.USER, + session=session, project=project, user=admin, project_role=ProjectRole.ADMIN ) - user1 = await create_user(session=session, name="user1") - members = [ - { - "username": user.name, - "project_role": ProjectRole.ADMIN, - }, - { - "username": user1.name, - "project_role": ProjectRole.ADMIN, - }, - ] - body = {"members": members} + + # Try to add non-existent user - should now error instead of silently skipping + body = {"members": [{"username": "nonexistent", "project_role": "user"}]} response = await client.post( - f"/api/projects/{project.name}/set_members", - headers=get_auth_headers(user.token), + f"/api/projects/{project.name}/add_members", + headers=get_auth_headers(admin.token), json=body, ) - assert response.status_code == 403 - -class TestListUserProjectsService: - """Test the service-level functions for backward compatibility""" + # Operation should fail with 400 error for non-existent user + assert response.status_code == 400 + response_json = response.json() + assert "User not found: nonexistent" in str(response_json) @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_list_user_projects_only_returns_member_projects( + async def test_add_member_manager_cannot_add_admin_without_global_admin( self, test_db, session: AsyncSession, client: AsyncClient ): - # Create project owner - owner = await create_user( - session=session, - name="owner", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), - global_role=GlobalRole.USER, + # Setup project with manager (not global admin) + project = await create_project( + session=session, created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) ) - - # Create a different user who is not a member - non_member = await create_user( + manager = await create_user( session=session, - name="non_member", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=manager, project_role=ProjectRole.MANAGER ) - # Create a public project - public_project = await create_project( + # Create user to add + _new_user = await create_user( session=session, - owner=owner, - name="public_project", + name="newuser", created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), - is_public=True, ) - # Add owner as admin - await add_project_member( - session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + # Try to add admin + body = {"members": [{"username": "newuser", "project_role": "admin"}]} + response = await client.post( + f"/api/projects/{project.name}/add_members", + headers=get_auth_headers(manager.token), + json=body, ) - # Test: list_user_projects should NOT return public projects for non-members - from dstack._internal.server.services.projects import list_user_projects + assert response.status_code == 403 - projects = await list_user_projects(session=session, user=non_member) - assert len(projects) == 0 # Non-member should see NO projects - # Test: list_user_projects should return projects where user IS a member - projects = await list_user_projects(session=session, user=owner) - assert len(projects) == 1 - assert projects[0].project_name == "public_project" +class TestUpdateProjectVisibility: + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClient): + response = await client.post("/api/projects/test/update") + assert response.status_code in [401, 403] @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_list_user_accessible_projects_returns_member_and_public_projects( + async def test_returns_404_if_project_does_not_exist( self, test_db, session: AsyncSession, client: AsyncClient ): - # Create project owner - owner = await create_user( - session=session, - name="owner", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), - global_role=GlobalRole.USER, + user = await create_user(session=session) + response = await client.post( + "/api/projects/nonexistent/update", + headers=get_auth_headers(user.token), + json={"is_public": True}, ) + assert response.status_code == 404 - # Create a different user who is not a member - non_member = await create_user( - session=session, - name="non_member", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), - global_role=GlobalRole.USER, + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_project_admin_can_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN ) - # Create a public project - public_project = await create_project( - session=session, - owner=owner, - name="public_project", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), - is_public=True, + # Admin should be able to make project public + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"is_public": True}, ) + assert response.status_code == 200 + assert response.json()["is_public"] == True - # Create a private project - private_project = await create_project( - session=session, - owner=owner, - name="private_project", - created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), - is_public=False, + # Admin should be able to make project private again + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"is_public": False}, ) + assert response.status_code == 200 + assert response.json()["is_public"] == False - # Add owner as admin to both projects + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_regular_user_cannot_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin and regular user + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + regular_user = await create_user(session=session, name="user", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) await add_project_member( - session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN ) await add_project_member( - session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + session=session, project=project, user=regular_user, project_role=ProjectRole.USER ) - # Test: list_user_accessible_projects should return public projects for non-members - from dstack._internal.server.services.projects import list_user_accessible_projects + # Regular user should not be able to update visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(regular_user.token), + json={"is_public": True}, + ) + assert response.status_code == 403 - projects = await list_user_accessible_projects(session=session, user=non_member) - assert len(projects) == 1 # Should see only the public project - assert projects[0].project_name == "public_project" + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_non_member_cannot_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin and separate non-member user + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + non_member_user = await create_user( + session=session, name="nonmember", global_role=GlobalRole.USER + ) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) - # Test: list_user_accessible_projects should return ALL projects for members - projects = await list_user_accessible_projects(session=session, user=owner) - assert len(projects) == 2 # Should see both projects - project_names = [p.project_name for p in projects] - assert "public_project" in project_names - assert "private_project" in project_names + # Non-member should not be able to update visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(non_member_user.token), + json={"is_public": True}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_global_admin_can_update_any_project_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with regular owner and global admin + project_owner = await create_user( + session=session, name="owner", global_role=GlobalRole.USER + ) + global_admin = await create_user( + session=session, name="admin", global_role=GlobalRole.ADMIN + ) + project = await create_project(session=session, owner=project_owner, is_public=False) + await add_project_member( + session=session, project=project, user=project_owner, project_role=ProjectRole.ADMIN + ) + + # Global admin should be able to update any project's visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(global_admin.token), + json={"is_public": True}, + ) + assert response.status_code == 200 + assert response.json()["is_public"] == True