Skip to content

Commit

Permalink
Merge pull request #1991 from Infisical/daniel/leave-project
Browse files Browse the repository at this point in the history
Feat: Leave Project
  • Loading branch information
maidul98 committed Jun 18, 2024
2 parents d0c76ae + f121f8e commit e3beeb6
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 12 deletions.
28 changes: 28 additions & 0 deletions backend/src/server/routes/v1/project-membership-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
TDeleteProjectMembershipsDTO,
TGetProjectMembershipByUsernameDTO,
TGetProjectMembershipDTO,
TLeaveProjectDTO,
TUpdateProjectMembershipDTO
} from "./project-membership-types";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
Expand Down Expand Up @@ -531,13 +532,61 @@ 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,
updateProjectMembership,
addUsersToProjectNonE2EE,
deleteProjectMemberships,
deleteProjectMembership, // TODO: Remove this
addUsersToProject
addUsersToProject,
leaveProject
};
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TProjectPermission } from "@app/lib/types";

export type TGetProjectMembershipDTO = TProjectPermission;
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
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 (
<Modal
isOpen={isOpen}
onOpenChange={(isOpenState) => {
setInputData("");
if (onChange) onChange(isOpenState);
}}
>
<ModalContent
title={title}
subTitle={subTitle}
footerContent={
<div className="mx-2 flex items-center">
<Button
className="mr-4"
colorSchema="danger"
isDisabled={!(deleteKey === inputData) || isLoading}
onClick={onDelete}
isLoading={isLoading}
>
{buttonText}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</ModalClose>{" "}
</div>
}
onClose={onClose}
>
<form
onSubmit={(evt) => {
evt.preventDefault();
if (deleteKey === inputData) onDelete();
}}
>
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to leave the project
</div>
}
className="mb-0"
>
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder="Type to confirm..."
/>
</FormControl>
</form>
</ModalContent>
</Modal>
);
};
1 change: 1 addition & 0 deletions frontend/src/components/v2/LeaveProjectModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LeaveProjectModal } from "./LeaveProjectModal";
4 changes: 3 additions & 1 deletion frontend/src/hooks/api/workspace/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
useAddGroupToWorkspace,
useDeleteGroupFromWorkspace,
useLeaveProject,
useUpdateGroupWorkspaceRole
} from "./mutations";
export {
Expand Down Expand Up @@ -30,4 +31,5 @@ export {
useUpdateIdentityWorkspaceRole,
useUpdateUserWorkspaceRole,
useUpdateWsEnvironment,
useUpgradeProject} from "./queries";
useUpgradeProject
} from "./queries";
12 changes: 12 additions & 0 deletions frontend/src/hooks/api/workspace/mutations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
};
Loading

0 comments on commit e3beeb6

Please sign in to comment.