mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00: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 };
|
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,
|
TDeleteProjectMembershipsDTO,
|
||||||
TGetProjectMembershipByUsernameDTO,
|
TGetProjectMembershipByUsernameDTO,
|
||||||
TGetProjectMembershipDTO,
|
TGetProjectMembershipDTO,
|
||||||
|
TLeaveProjectDTO,
|
||||||
TUpdateProjectMembershipDTO
|
TUpdateProjectMembershipDTO
|
||||||
} from "./project-membership-types";
|
} from "./project-membership-types";
|
||||||
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
|
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
|
||||||
@@ -531,6 +532,53 @@ export const projectMembershipServiceFactory = ({
|
|||||||
return memberships;
|
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 {
|
return {
|
||||||
getProjectMemberships,
|
getProjectMemberships,
|
||||||
getProjectMembershipByUsername,
|
getProjectMembershipByUsername,
|
||||||
@@ -538,6 +586,7 @@ export const projectMembershipServiceFactory = ({
|
|||||||
addUsersToProjectNonE2EE,
|
addUsersToProjectNonE2EE,
|
||||||
deleteProjectMemberships,
|
deleteProjectMemberships,
|
||||||
deleteProjectMembership, // TODO: Remove this
|
deleteProjectMembership, // TODO: Remove this
|
||||||
addUsersToProject
|
addUsersToProject,
|
||||||
|
leaveProject
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
export type TGetProjectMembershipDTO = TProjectPermission;
|
export type TGetProjectMembershipDTO = TProjectPermission;
|
||||||
|
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
|
||||||
export enum ProjectUserMembershipTemporaryMode {
|
export enum ProjectUserMembershipTemporaryMode {
|
||||||
Relative = "relative"
|
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 {
|
export {
|
||||||
useAddGroupToWorkspace,
|
useAddGroupToWorkspace,
|
||||||
useDeleteGroupFromWorkspace,
|
useDeleteGroupFromWorkspace,
|
||||||
|
useLeaveProject,
|
||||||
useUpdateGroupWorkspaceRole
|
useUpdateGroupWorkspaceRole
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export {
|
export {
|
||||||
@@ -30,4 +31,5 @@ export {
|
|||||||
useUpdateIdentityWorkspaceRole,
|
useUpdateIdentityWorkspaceRole,
|
||||||
useUpdateUserWorkspaceRole,
|
useUpdateUserWorkspaceRole,
|
||||||
useUpdateWsEnvironment,
|
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 { useRouter } from "next/router";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||||
|
import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal";
|
||||||
import {
|
import {
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useOrganization,
|
useOrganization,
|
||||||
|
useProjectPermission,
|
||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { useToggle } from "@app/hooks";
|
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";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
export const DeleteProjectSection = () => {
|
export const DeleteProjectSection = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
"deleteWorkspace"
|
"deleteWorkspace",
|
||||||
|
"leaveWorkspace"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
|
const { hasProjectRole, membership } = useProjectPermission();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const [isDeleting, setIsDeleting] = useToggle();
|
const [isDeleting, setIsDeleting] = useToggle();
|
||||||
|
const [isLeaving, setIsLeaving] = useToggle();
|
||||||
const deleteWorkspace = useDeleteWorkspace();
|
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 () => {
|
const handleDeleteWorkspaceSubmit = async () => {
|
||||||
setIsDeleting.on();
|
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 (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<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>
|
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
|
||||||
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Workspace}>
|
<div className="space-x-4">
|
||||||
{(isAllowed) => (
|
<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
|
<Button
|
||||||
isLoading={isDeleting}
|
disabled={isMembersLoading || (members && members?.length < 2)}
|
||||||
isDisabled={!isAllowed || isDeleting}
|
isLoading={isLeaving}
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() => handlePopUpOpen("deleteWorkspace")}
|
onClick={() => handlePopUpOpen("leaveWorkspace")}
|
||||||
>
|
>
|
||||||
{`Delete ${currentWorkspace?.name}`}
|
{`Leave ${currentWorkspace?.name}`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</div>
|
||||||
|
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteWorkspace.isOpen}
|
isOpen={popUp.deleteWorkspace.isOpen}
|
||||||
title="Are you sure want to delete this project?"
|
title="Are you sure want to delete this project?"
|
||||||
@@ -79,6 +176,16 @@ export const DeleteProjectSection = () => {
|
|||||||
buttonText="Delete Project"
|
buttonText="Delete Project"
|
||||||
onDeleteApproved={handleDeleteWorkspaceSubmit}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user