mirror of
https://github.com/Infisical/infisical.git
synced 2026-05-02 03:02:03 -04:00
Merge pull request #1991 from Infisical/daniel/leave-project
Feat: Leave Project
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
frontend/src/components/v2/LeaveProjectModal/index.tsx
Normal file
1
frontend/src/components/v2/LeaveProjectModal/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { LeaveProjectModal } from "./LeaveProjectModal";
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<div className="space-x-4">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Workspace}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isLoading={isDeleting}
|
||||
isDisabled={!isAllowed || isDeleting}
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handlePopUpOpen("deleteWorkspace")}
|
||||
>
|
||||
{`Delete ${currentWorkspace?.name}`}
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{!isOnlyAdminMember && (
|
||||
<Button
|
||||
isLoading={isDeleting}
|
||||
isDisabled={!isAllowed || isDeleting}
|
||||
disabled={isMembersLoading || (members && members?.length < 2)}
|
||||
isLoading={isLeaving}
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handlePopUpOpen("deleteWorkspace")}
|
||||
onClick={() => handlePopUpOpen("leaveWorkspace")}
|
||||
>
|
||||
{`Delete ${currentWorkspace?.name}`}
|
||||
{`Leave ${currentWorkspace?.name}`}
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteWorkspace.isOpen}
|
||||
title="Are you sure want to delete this project?"
|
||||
@@ -79,6 +176,16 @@ export const DeleteProjectSection = () => {
|
||||
buttonText="Delete Project"
|
||||
onDeleteApproved={handleDeleteWorkspaceSubmit}
|
||||
/>
|
||||
|
||||
<LeaveProjectModal
|
||||
isOpen={popUp.leaveWorkspace.isOpen}
|
||||
title="Are you sure want to leave this project?"
|
||||
subTitle={`If you leave ${currentWorkspace?.name} you will lose access to the project and it's contents.`}
|
||||
onChange={(isOpen) => handlePopUpToggle("leaveWorkspace", isOpen)}
|
||||
deleteKey="confirm"
|
||||
buttonText="Leave Project"
|
||||
onLeaveApproved={handleLeaveWorkspaceSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user