Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Leave Project #1991

Merged
merged 9 commits into from
Jun 18, 2024
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
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
Loading