diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 6bbb8d7ef3..4f92783c5c 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -309,4 +309,32 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider return { membership }; } }); + + server.route({ + method: "DELETE", + url: "/:workspaceId/leave", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const membership = await server.services.projectMembership.leaveProject({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.workspaceId + }); + return { membership }; + } + }); }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index a6682465f0..45e3f9ab55 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -36,6 +36,7 @@ import { TDeleteProjectMembershipsDTO, TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, + TLeaveProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; @@ -531,6 +532,53 @@ export const projectMembershipServiceFactory = ({ return memberships; }; + const leaveProject = async ({ projectId, actorId, actor }: TLeaveProjectDTO) => { + if (actor !== ActorType.USER) { + throw new BadRequestError({ message: "Only users can leave projects" }); + } + + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version !== ProjectVersion.V2) { + throw new BadRequestError({ + message: "Please ask your project administrator to upgrade the project before leaving." + }); + } + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + + if (!projectMembers?.length) { + throw new BadRequestError({ message: "Failed to find project members" }); + } + + if (projectMembers.length < 2) { + throw new BadRequestError({ message: "You cannot leave the project as you are the only member" }); + } + + const adminMembers = projectMembers.filter( + (member) => member.roles.map((r) => r.role).includes("admin") && member.userId !== actorId + ); + if (!adminMembers.length) { + throw new BadRequestError({ + message: "You cannot leave the project as you are the only admin. Promote another user to admin before leaving." + }); + } + + const deletedMembership = ( + await projectMembershipDAL.delete({ + projectId: project.id, + userId: actorId + }) + )?.[0]; + + if (!deletedMembership) { + throw new BadRequestError({ message: "Failed to leave project" }); + } + + return deletedMembership; + }; + return { getProjectMemberships, getProjectMembershipByUsername, @@ -538,6 +586,7 @@ export const projectMembershipServiceFactory = ({ addUsersToProjectNonE2EE, deleteProjectMemberships, deleteProjectMembership, // TODO: Remove this - addUsersToProject + addUsersToProject, + leaveProject }; }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 1eab752657..dc3a620168 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" } diff --git a/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx new file mode 100644 index 0000000000..563c33befb --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + deleteKey: string; + title: string; + onLeaveApproved: () => Promise; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + isOpen?: boolean; + subTitle?: string; + buttonText?: string; +}; + +export const LeaveProjectModal = ({ + isOpen, + onClose, + onChange, + deleteKey, + onLeaveApproved, + title, + subTitle, + buttonText = "Leave Project" +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onLeaveApproved(); + } catch { + setIsLoading.off(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + {" "} + + } + onClose={onClose} + > +
{ + evt.preventDefault(); + if (deleteKey === inputData) onDelete(); + }} + > + + Type {deleteKey} to leave the project + + } + className="mb-0" + > + setInputData(e.target.value)} + placeholder="Type to confirm..." + /> + +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/LeaveProjectModal/index.tsx b/frontend/src/components/v2/LeaveProjectModal/index.tsx new file mode 100644 index 0000000000..5d33aa1083 --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/index.tsx @@ -0,0 +1 @@ +export { LeaveProjectModal } from "./LeaveProjectModal"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 1daec0fd0f..f5e5855d40 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,6 +1,7 @@ export { useAddGroupToWorkspace, useDeleteGroupFromWorkspace, + useLeaveProject, useUpdateGroupWorkspaceRole } from "./mutations"; export { @@ -30,4 +31,5 @@ export { useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment, - useUpgradeProject} from "./queries"; + useUpgradeProject +} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 11853157f8..5aba02098e 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -62,3 +62,15 @@ export const useDeleteGroupFromWorkspace = () => { } }); }; + +export const useLeaveProject = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, { workspaceId: string }>({ + mutationFn: ({ workspaceId }) => { + return apiRequest.delete(`/api/v1/workspace/${workspaceId}/leave`); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index 12d7359521..edbb78c2e4 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -1,29 +1,52 @@ +import { useMemo } from "react"; import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; +import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal"; import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, + useProjectPermission, useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; -import { useDeleteWorkspace } from "@app/hooks/api"; +import { useDeleteWorkspace, useGetWorkspaceUsers, useLeaveProject } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; export const DeleteProjectSection = () => { const router = useRouter(); - + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "deleteWorkspace" + "deleteWorkspace", + "leaveWorkspace" ] as const); const { currentOrg } = useOrganization(); + const { hasProjectRole, membership } = useProjectPermission(); const { currentWorkspace } = useWorkspace(); const [isDeleting, setIsDeleting] = useToggle(); + const [isLeaving, setIsLeaving] = useToggle(); const deleteWorkspace = useDeleteWorkspace(); + const leaveProject = useLeaveProject(); + const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers( + currentWorkspace?.id || "" + ); + + // If isNoAccessMember is true, then the user can't read the workspace members. So we need to handle this case separately. + const isNoAccessMember = hasProjectRole("no-access"); + + const isOnlyAdminMember = useMemo(() => { + if (!members || !membership || !hasProjectRole("admin")) return false; + + const adminMembers = members.filter( + (member) => member.roles.map((r) => r.role).includes("admin") && member.id !== membership.id // exclude the current user + ); + + return !adminMembers.length; + }, [members, membership]); const handleDeleteWorkspaceSubmit = async () => { setIsDeleting.on(); @@ -53,23 +76,97 @@ export const DeleteProjectSection = () => { } }; + const handleLeaveWorkspaceSubmit = async () => { + console.log({ + currentWorkspace, + currentOrg, + members, + isNoAccessMember, + membership + }); + + try { + setIsLeaving.on(); + + if (!currentWorkspace?.id || !currentOrg?.id) return; + + // If there's no members, and the user has access to read members, something went wrong. + if (!members && !isNoAccessMember) return; + + // If the user has elevated permissions and can read members: + if (!isNoAccessMember) { + if (!members) return; + + if (members.length < 2) { + createNotification({ + text: "You can't leave the project as you are the only member", + type: "error" + }); + return; + } + // If the user has access to read members, and there's less than 1 admin member excluding the current user, they can't leave the project. + if (isOnlyAdminMember) { + createNotification({ + text: "You can't leave a project with no admin members left. Promote another member to admin first.", + type: "error" + }); + return; + } + } + + // If it's actually a no-access member, then we don't really care about the members. + + await leaveProject.mutateAsync({ + workspaceId: currentWorkspace.id + }); + + router.push(`/org/${currentOrg.id}/overview`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to leave project", + type: "error" + }); + } finally { + setIsLeaving.off(); + } + }; + return (

Danger Zone

- - {(isAllowed) => ( +
+ + {(isAllowed) => ( + + )} + + {!isOnlyAdminMember && ( )} - +
+ { buttonText="Delete Project" onDeleteApproved={handleDeleteWorkspaceSubmit} /> + + handlePopUpToggle("leaveWorkspace", isOpen)} + deleteKey="confirm" + buttonText="Leave Project" + onLeaveApproved={handleLeaveWorkspaceSubmit} + />
); };