feat(rbac): implemented project based permission loading and role management

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent aac3168c80
commit 520a553ea1
63 changed files with 2237 additions and 488 deletions
@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetRoles } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { MemberListTab } from "./components/MemberListTab";
import { ProjectRoleListTab } from "./components/ProjectRoleListTab";
enum TabSections {
Member = "members",
Roles = "roles"
}
export const MembersPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const orgId = currentWorkspace?.organization || "";
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">{t("settings.members.title")}</p>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>Members</Tab>
{process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && (
<Tab value={TabSections.Roles}>Roles</Tab>
)}
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab roles={roles as TRole<string>[]} />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Roles}>
<ProjectRoleListTab roles={roles as TRole<string>[]} isRolesLoading={isRolesLoading} />
</TabPanel>
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,419 @@
import { useCallback, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import {
GeneralPermissionActions,
OrgPermissionSubjects,
useOrganization,
useUser,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAddUserToWs,
useDeleteUserFromWorkspace,
useGetOrgUsers,
useGetUserWsKey,
useGetWorkspaceUsers,
useUpdateUserWorkspaceRole,
useUploadWsKey
} from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
type Props = {
roles?: TRole<string>[];
};
const addMemberFormSchema = z.object({
email: z.string().email().trim()
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
export const MemberListTab = ({ roles = [] }: Props) => {
const { createNotification } = useNotificationContext();
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const userId = user?._id || "";
const orgId = currentOrg?._id || "";
const workspaceId = currentWorkspace?._id || "";
const { data: wsKey } = useGetUserWsKey(workspaceId);
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan"
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
const { mutateAsync: addUserToWorkspace } = useAddUserToWs();
const { mutateAsync: uploadWsKey } = useUploadWsKey();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const onAddMember = async ({ email }: TAddMemberForm) => {
if (!currentOrg?._id) return;
try {
await addUserToWorkspace({
email,
workspaceId
});
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
handlePopUpClose("addMember");
reset();
};
const handleRemoveUser = async () => {
const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
if (!currentOrg?._id) return;
try {
await removeUserFromWorkspace(membershipId);
createNotification({
text: "Successfully removed user from workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
handlePopUpClose("removeMember");
};
const isIamOwner = useMemo(
() => members?.find(({ user: u }) => userId === u?._id)?.role === "owner",
[userId, members]
);
const findRoleFromId = useCallback(
(roleId: string) => {
return roles.find(({ _id: id }) => id === roleId);
},
[roles]
);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?._id) return;
try {
await updateUserWorkspaceRole({ membershipId, role });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const filterdUsers = useMemo(
() =>
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
u?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
),
[members, searchMemberFilter]
);
const filteredOrgUsers = useMemo(() => {
const wsUserEmails = new Map();
members?.forEach((member) => {
wsUserEmails.set(member.user.email, true);
});
return (orgUsers || []).filter(
({ status, user: u }) => status === "accepted" && !wsUserEmails.has(u.email)
);
}, [orgUsers, members]);
const onGrantAccess = async (grantedUserId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey({
userId: grantedUserId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const isLoading = isMembersLoading;
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<OrgPermissionCan I={GeneralPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addMember")}
>
Add Member
</Button>
)}
</OrgPermissionCan>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isLoading &&
filterdUsers?.map(
({ user: u, inviteEmail, _id: membershipId, status, customRole, role }) => {
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
return (
<Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<OrgPermissionCan
I={GeneralPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<>
<Select
defaultValue={
role === "custom" ? findRoleFromId(customRole)?.slug : role
}
isDisabled={userId === u?._id || !isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(membershipId, selectedRole)
}
>
{roles
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
{status === "completed" && user.email !== email && (
<div className="rounded-md border border-mineshaft-700 bg-white/5 text-white duration-200 hover:bg-primary hover:text-black">
<Button
colorSchema="secondary"
isDisabled={!isAllowed}
onClick={() => onGrantAccess(u?._id, u?.publicKey)}
>
Grant Access
</Button>
</div>
)}
</>
)}
</OrgPermissionCan>
</Td>
<Td>
{userId !== u?._id && (
<OrgPermissionCan
I={GeneralPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<IconButton
ariaLabel="delete"
colorSchema="danger"
isDisabled={userId === u?._id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { id: membershipId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
)}
</Td>
</Tr>
);
}
)}
</TBody>
</Table>
{!isLoading && filterdUsers?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} />
)}
</TableContainer>
</div>
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
>
<ModalContent
title={t("section.members.add-dialog.add-member-to-project") as string}
subTitle={t("section.members.add-dialog.user-will-email")}
>
<form onSubmit={handleSubmit(onAddMember)}>
<Controller
control={control}
defaultValue={filteredOrgUsers?.[0]?.user?.email}
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Select
position="popper"
className="w-full"
defaultValue={filteredOrgUsers?.[0]?.user?.email}
value={field.value}
onValueChange={field.onChange}
>
{filteredOrgUsers.map(({ _id: orgUserId, user: u }) => (
<SelectItem value={u?.email} key={`org-membership-join-${orgUserId}`}>
{u?.email}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addMember")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
title="Do you want to remove this user from the org?"
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div>
);
};
@@ -0,0 +1 @@
export { MemberListTab } from "./MemberListTab";
@@ -0,0 +1,45 @@
import { motion } from "framer-motion";
import { usePopUp } from "@app/hooks";
import { TRole } from "@app/hooks/api/roles/types";
import { ProjectRoleList } from "./components/ProjectRoleList";
import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
type Props = {
roles?: TRole<string>[];
isRolesLoading?: boolean;
};
export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
return popUp.editRole.isOpen ? (
<motion.div
key="role-modify"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleModifySection
role={popUp.editRole.data as TRole<string>}
onGoBack={() => handlePopUpClose("editRole")}
/>
</motion.div>
) : (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ProjectRoleList
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
</motion.div>
);
};
@@ -0,0 +1,143 @@
import { useState } from "react";
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
DeleteActionModal,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
type Props = {
isRolesLoading?: boolean;
roles?: TRole<string>[];
onSelectRole: (role?: TRole<string>) => void;
};
export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
const [searchRoles, setSearchRoles] = useState("");
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || "";
const workspaceId = currentWorkspace?._id || "";
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { mutateAsync: deleteRole } = useDeleteRole();
const handleRoleDelete = async () => {
const { _id: id } = popUp?.deleteRole?.data as TRole<string>;
try {
await deleteRole({
orgId,
workspaceId,
id
});
createNotification({ type: "success", text: "Successfully removed the role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchRoles}
onChange={(e) => setSearchRoles(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
/>
</div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}>
Add Role
</Button>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>Created At</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
const { _id: id, name, createdAt, slug } = role;
const isNonMutatable = ["owner", "admin", "member"].includes(slug);
return (
<Tr key={`role-list-${id}`}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
{createdAt ? format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa") : "-"}
</Td>
<Td>
<div className="flex space-x-2 items-center">
<Tooltip content="Edit">
<IconButton
ariaLabel="edit"
onClick={() => onSelectRole(role)}
variant="plain"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
<Tooltip
content={isNonMutatable ? "Reserved roles are non-removable" : "Delete"}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteRole?.data as TRole<string>)?.name || " "
} role?`}
deleteKey={(popUp?.deleteRole?.data as TRole<string>)?.slug || ""}
onClose={() => handlePopUpClose("deleteRole")}
onDeleteApproved={handleRoleDelete}
/>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectRoleList } from "./ProjectRoleList";
@@ -0,0 +1,236 @@
import { useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import {
Checkbox,
Input,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
formName: "secrets" | "folders" | "secret-imports";
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
icon: IconProp;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const MultiEnvProjectPermission = ({
isNonEditable,
setValue,
control,
formName,
title,
subtitle,
icon
}: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const customRule = useWatch({
control,
name: `permissions.${formName}.custom`
});
const isCustom = Boolean(customRule);
const allRule = useWatch({ control, name: `permissions.${formName}.all` });
const selectedPermissionCategory = useMemo(() => {
const { read, delete: del, edit, create } = allRule || {};
if (read && del && edit && create) return Permission.FullAccess;
if (read) return Permission.ReadOnly;
return Permission.NoAccess;
}, [allRule]);
const handlePermissionChange = (val: Permission) => {
switch (val) {
case Permission.NoAccess:
setValue(`permissions.${formName}`, {}, { shouldDirty: true });
break;
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
}
};
return (
<div
className={twMerge(
"px-10 py-6 bg-mineshaft-800 rounded-md",
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
"border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={icon} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">{title}</div>
<div className="text-xs font-light">{subtitle}</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={isCustom ? Permission.Custom : selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "auto" : 0 }}
className="overflow-hidden"
>
<TableContainer className="border-mineshaft-500 mt-6">
<Table>
<THead>
<Tr>
<Th />
<Th className="min-w-[8rem]">Secret Path</Th>
<Th className="text-center">Read</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Edit</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
environments.map(({ name, slug }) => (
<Tr key={`custom-role-project-secret-${slug}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.secretPath`}
control={control}
render={({ field }) => (
<Input {...field} className="w-full overflow-ellipsis" />
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.read`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.${formName}.${slug}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.delete`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</motion.div>
</div>
);
};
@@ -0,0 +1,290 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { faElementor } from "@fortawesome/free-brands-svg-icons";
import {
faAnchorLock,
faArrowLeft,
faBook,
faCog,
faFolder,
faKey,
faLink,
faLock,
faMagnifyingGlass,
faNetworkWired,
faPuzzlePiece,
faTags,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { useCreateRole, useUpdateRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SingleProjectPermission } from "./SingleProjectPermission";
const SINGLE_PERMISSION_LIST = [
{
title: "Integrations",
subtitle: "Integration management control",
icon: faPuzzlePiece,
formName: "integrations"
},
{
title: "Roles",
subtitle: "Role management control",
icon: faUsers,
formName: "role"
},
{
title: "Project Members",
subtitle: "Project members management control",
icon: faUser,
formName: "member"
},
{
title: "Webhooks",
subtitle: "Webhook management control",
icon: faAnchorLock,
formName: "webhooks"
},
{
title: "Service Tokens",
subtitle: "Token management control",
icon: faKey,
formName: "service-tokens"
},
{
title: "Settings",
subtitle: "Settings control",
icon: faCog,
formName: "settings"
},
{
title: "Environments",
subtitle: "Environment management control",
icon: faElementor,
formName: "environments"
},
{
title: "Tags",
subtitle: "Tag management control",
icon: faTags,
formName: "tags"
},
{
title: "Audit Logs",
subtitle: "Audit log management control",
icon: faBook,
formName: "audit-logs"
},
{
title: "IP Allowlist",
subtitle: "IP allowlist management control",
icon: faNetworkWired,
formName: "ip-allowlist"
}
] as const;
type Props = {
role?: TRole<string>;
onGoBack: VoidFunction;
};
export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
const [searchPermission, setSearchPermission] = useState("");
const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || "");
const isNewRole = !role?.slug;
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const orgId = currentOrg?._id || "";
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, errors },
setValue,
control
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
resolver: zodResolver(formSchema)
});
const { mutateAsync: createRole } = useCreateRole();
const { mutateAsync: updateRole } = useUpdateRole();
const handleRoleUpdate = async (el: TFormSchema) => {
if (!role?._id) return;
try {
await updateRole({
orgId,
id: role?._id,
workspaceId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const handleFormSubmit = async (el: TFormSchema) => {
if (!isNewRole) {
await handleRoleUpdate(el);
return;
}
try {
await createRole({
orgId,
workspaceId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Created new role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex justify-between mb-2 items-center">
<h1 className="text-xl font-semibold text-mineshaft-100">
{isNewRole ? "New" : "Edit"} Role
</h1>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
</div>
<p className="mb-8 text-gray-400">
Roles are used to grant access to particular resources in your organization
</p>
<div className="flex flex-col space-y-6">
<FormControl
label="Name"
isRequired
className="mb-0"
isError={Boolean(errors?.name)}
errorText={errors?.name?.message}
>
<Input {...register("name")} placeholder="Billing Team" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Slug"
isRequired
isError={Boolean(errors?.slug)}
errorText={errors?.slug?.message}
>
<Input {...register("slug")} placeholder="biller" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Description"
helperText="A short description about this role"
isError={Boolean(errors?.description)}
errorText={errors?.description?.message}
>
<Input {...register("description")} isReadOnly={isNonEditable} />
</FormControl>
<div className="flex justify-between items-center pt-4 border-t border-t-mineshaft-800">
<div>
<h2 className="text-xl font-medium">Add Permission</h2>
</div>
<div className="flex-1 max-w-md">
<Input
value={searchPermission}
onChange={(e) => setSearchPermission(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search permissions..."
/>
</div>
</div>
<div className="flex flex-col space-y-4">
<MultiEnvProjectPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={faLock}
title="Secrets"
subtitle="Secret management control"
formName="secrets"
/>
</div>
<div className="flex flex-col space-y-4">
<MultiEnvProjectPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={faFolder}
title="Folders"
subtitle="Folder management control"
formName="folders"
/>
</div>
<div className="flex flex-col space-y-4">
<MultiEnvProjectPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={faLink}
title="Secret Imports"
subtitle="Secret import management control"
formName="secret-imports"
/>
</div>
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
<div className="flex flex-col space-y-4" key={`permission-${title}`}>
<SingleProjectPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={icon}
title={title}
subtitle={subtitle}
formName={formName}
/>
</div>
))}
</div>
<div className="flex items-center space-x-4 mt-12">
<Button
type="submit"
isDisabled={isSubmitting || isNonEditable || !isDirty}
isLoading={isSubmitting}
>
{isNewRole ? "Create Role" : "Save Role"}
</Button>
<Button onClick={onGoBack} variant="outline_bg">
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,171 @@
/* eslint-disable no-param-reassign */
import { z } from "zod";
import { TProjectPermission } from "@app/hooks/api/roles/types";
const generalPermissionSchema = z
.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
delete: z.boolean().optional(),
create: z.boolean().optional()
})
.optional();
const multiEnvPermissionSchema = z
.object({
secretPath: z.string().optional(),
read: z.boolean().optional(),
edit: z.boolean().optional(),
delete: z.boolean().optional(),
create: z.boolean().optional()
})
.optional();
const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
const MULTI_ENV_KEY = ["secrets", "folders", "secret-imports"] as const;
export const formSchema = z.object({
name: z.string(),
description: z.string().optional(),
slug: z.string(),
permissions: z.object({
secrets: z.record(multiEnvPermissionSchema).optional(),
folders: z.record(multiEnvPermissionSchema).optional(),
"secret-imports": z.record(multiEnvPermissionSchema).optional(),
member: generalPermissionSchema,
role: generalPermissionSchema,
integrations: generalPermissionSchema,
webhooks: generalPermissionSchema,
"service-tokens": generalPermissionSchema,
settings: generalPermissionSchema,
environments: generalPermissionSchema,
tags: generalPermissionSchema,
"audit-logs": generalPermissionSchema,
"ip-allowlist": generalPermissionSchema,
workspace: z
.object({
edit: z.boolean().optional(),
delete: z.boolean().optional()
})
.optional()
})
});
export type TFormSchema = z.infer<typeof formSchema>;
const multiEnvApi2Form = (
formVal: TFormSchema["permissions"]["secrets"],
permission: TProjectPermission
) => {
const isCustomRule = Boolean(permission?.condition?.slug);
// full access
if (isCustomRule && formVal && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true };
}
const secretEnv = permission?.condition?.slug || "all";
const secretPath = permission?.condition?.secretPath;
// initialize
if (formVal && !formVal?.[secretEnv]) {
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
}
formVal![secretEnv]![permission.action] = true;
};
// convert role permission to form compatiable data structure
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const formVal: TFormSchema["permissions"] = {
secrets: {},
folders: {},
integrations: {},
settings: {},
role: {},
member: {},
"service-tokens": {},
workspace: {},
environments: {},
tags: {},
webhooks: {},
"audit-logs": {},
"ip-allowlist": {},
"secret-imports": {}
};
permissions.forEach((permission) => {
if (["secrets", "folders", "secret-imports"].includes(permission.subject)) {
multiEnvApi2Form(formVal?.secrets, permission);
} else {
// everything else follows same pattern
// formVal[settings][read | write] = true
const key = permission.subject as keyof Omit<
TFormSchema["permissions"],
"secrets" | "workspace"
>;
formVal[key]![permission.action] = true;
}
});
return formVal;
};
const multiEnvForm2Api = (
permissions: TProjectPermission[],
formVal: TFormSchema["permissions"]["secrets"],
subject: (typeof MULTI_ENV_KEY)[number]
) => {
const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]);
// if any of them is set in all push it without any condition
PERMISSION_ACTIONS.forEach((action) => {
if (formVal?.all?.[action]) permissions.push({ action, subject });
});
if (!isFullAccess) {
Object.keys(formVal || {})
.filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter
.forEach((slug) => {
const actions = Object.keys(formVal?.[slug] || {}) as [
"read",
"edit",
"create",
"delete",
"secretPath"
];
actions.forEach((action) => {
// if not full access for an action
if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
permissions.push({
action,
subject,
condition: { slug, secretPath: formVal[slug]?.secretPath }
});
}
});
});
}
};
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TProjectPermission[] = [];
MULTI_ENV_KEY.forEach((formName) => {
multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(formVal[formName] || {})), formName);
});
// other than workspace everything else follows same
// if in future there is a different follow the above on how workspace is done
(Object.keys(formVal) as Array<keyof typeof formVal>)
.filter((key) => !["secret-imports", "folders", "secrets"].includes(key))
.forEach((rule) => {
// all these type annotations are due to Object.keys of ts cannot infer and put it just a string[]
// quite annoying i know
const actions = Object.keys(formVal[rule] || {}) as Array<
keyof z.infer<typeof generalPermissionSchema>
>;
actions.forEach((action) => {
// akhilmhdh: set it as any due to the union type bug i would end up writing an if else with same condition on both side
if (formVal[rule]?.[action as keyof typeof formVal.workspace]) {
permissions.push({ subject: rule, action } as any);
}
});
});
return permissions;
};
@@ -0,0 +1,171 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
formName:
| "role"
| "member"
| "integrations"
| "webhooks"
| "service-tokens"
| "settings"
| "environments"
| "tags"
| "audit-logs"
| "ip-allowlist";
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
icon: IconProp;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "read", label: "Read" },
{ action: "create", label: "Create" },
{ action: "edit", label: "Update" },
{ action: "delete", label: "Remove" }
] as const;
export const SingleProjectPermission = ({
isNonEditable,
setValue,
control,
formName,
subtitle,
title,
icon
}: Props) => {
const rule = useWatch({
control,
name: `permissions.${formName}`
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule?.read) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ read: true, edit: true, create: true, delete: true },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ read: true, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
}
};
return (
<div
className={twMerge(
"px-10 py-6 bg-mineshaft-800 rounded-md",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={icon} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">{title}</div>
<div className="text-xs font-light">{subtitle}</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.${formName}.${action}`}
key={`permissions.${formName}.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectRoleModifySection } from "./ProjectRoleModifySection";
@@ -0,0 +1 @@
export { ProjectRoleListTab } from "./ProjectRoleListTab";
@@ -0,0 +1 @@
export { MembersPage } from "./MembersPage";