Continue progress on project role page

This commit is contained in:
Tuan Dang
2024-07-25 14:45:02 -07:00
parent 1cb4dc9e84
commit bf00d16c80
8 changed files with 657 additions and 24 deletions

View File

@@ -9,7 +9,7 @@ export default function Role() {
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<title>{t("common.head-title", { title: "Project Settings" })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<RolePage />

View File

@@ -5,8 +5,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
// import { OrgPermissionCan } from "@app/components/permissions";
// import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -18,11 +16,12 @@ import {
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
// import { withPermission } from "@app/hoc";
import { withProjectPermission } from "@app/hoc";
import { useDeleteOrgRole, useGetOrgRole, useGetProjectRoleBySlug } from "@app/hooks/api";
import { useGetProjectRoleBySlug } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { RoleDetailsSection, RolePermissionsSection } from "./components";
// import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
export const RolePage = withProjectPermission(
@@ -32,24 +31,17 @@ export const RolePage = withProjectPermission(
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
console.log("RolePage currentWorkspace: ", currentWorkspace);
// const { currentOrg } = useOrganization();
// const orgId = currentOrg?.id || "";
const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
console.log("useGetProjectRoleBySlug data: ", data);
// const { data } = useGetOrgRole(orgId, roleId); // TODO: get project role
const { mutateAsync: deleteOrgRole } = useDeleteOrgRole();
// const { mutateAsync: deleteOrgRole } = useDeleteOrgRole();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"role",
"deleteOrgRole"
"deleteRole"
] as const);
const onDeleteOrgRoleSubmit = async () => {
const onDeleteRoleSubmit = async () => {
try {
// if (!orgId || !roleId) return;
// await deleteOrgRole({
@@ -65,7 +57,7 @@ export const RolePage = withProjectPermission(
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete organization role";
const text = error?.response?.data?.message ?? "Failed to delete project role";
createNotification({
text,
@@ -136,7 +128,7 @@ export const RolePage = withProjectPermission(
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("deleteOrgRole");
handlePopUpOpen("deleteRole");
}}
disabled={!isAllowed}
>
@@ -150,21 +142,19 @@ export const RolePage = withProjectPermission(
</div>
<div className="flex">
<div className="mr-4 w-96">
RoleDetailsSection here
{/* <RoleDetailsSection roleId={roleId} handlePopUpOpen={handlePopUpOpen} /> */}
<RoleDetailsSection roleSlug={roleSlug} handlePopUpOpen={handlePopUpOpen} />
</div>
RolePermissionsSection here
{/* <RolePermissionsSection roleId={roleId} /> */}
<RolePermissionsSection roleSlug={roleSlug} />
</div>
</div>
)}
{/* <RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> */}
<DeleteActionModal
isOpen={popUp.deleteOrgRole.isOpen}
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete the project role ${data?.name ?? ""}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrgRole", isOpen)}
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onDeleteOrgRoleSubmit()}
onDeleteApproved={() => onDeleteRoleSubmit()}
/>
</div>
);

View File

@@ -0,0 +1,99 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetProjectRoleBySlug } from "@app/hooks/api";
// import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
roleSlug: string;
// handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void;
};
export const RoleDetailsSection = ({
roleSlug
}: // handlePopUpOpen
Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { currentWorkspace } = useWorkspace();
const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? "");
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
{isCustomRole && (
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Role}>
{(isAllowed) => {
return (
<Tooltip content="Edit Role">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
// TODO
// handlePopUpOpen("role", {
// roleId
// })
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Role ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(data.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
<p className="text-sm text-mineshaft-300">{data.slug}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Description</p>
<p className="text-sm text-mineshaft-300">
{data.description?.length ? data.description : "-"}
</p>
</div>
</div>
</div>
) : (
<div />
);
};

View File

@@ -0,0 +1,232 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
const GENERAL_PERMISSIONS = [
{ action: "read", label: "View" },
{ action: "create", label: "Create" },
{ action: "edit", label: "Modify" },
{ action: "delete", label: "Remove" }
] as const;
const WORKSPACE_PERMISSIONS = [
{ action: "edit", label: "Update project details" },
{ action: "delete", label: "Delete projects" }
] as const;
const MEMBERS_PERMISSIONS = [
{ action: "read", label: "View all members" },
{ action: "create", label: "Invite members" },
{ action: "edit", label: "Edit members" },
{ action: "delete", label: "Remove members" }
] as const;
const SECRET_ROLLBACK_PERMISSIONS = [
{ action: "create", label: "Perform Rollback" },
{ action: "read", label: "View" }
] as const;
const getPermissionList = (option: Props["formName"]) => {
switch (option) {
case "workspace":
return WORKSPACE_PERMISSIONS;
case "member":
return MEMBERS_PERMISSIONS;
case "secret-rollback":
return SECRET_ROLLBACK_PERMISSIONS;
default:
return GENERAL_PERMISSIONS;
}
};
type PermissionName =
| `permissions.workspace.${"edit" | "delete"}`
| `permissions.secret-rollback.${"create" | "read"}`
| `permissions.${Exclude<
keyof NonNullable<TFormSchema["permissions"]>,
"workspace" | "secret-rollback" | "secrets"
>}.${"read" | "create" | "edit" | "delete"}`;
type Props = {
isEditable: boolean;
title: string;
formName: keyof Omit<Exclude<TFormSchema["permissions"], undefined>, "secrets">;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: `permissions.${formName}`
});
const selectedPermissionCategory = useMemo(() => {
// TODO: re-check
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
switch (formName) {
case "secret-rollback":
case "workspace": {
const totalActions = WORKSPACE_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;
return Permission.Custom; // TODO: update
}
default: {
const totalActions = GENERAL_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 (rule && "read" in rule) {
if (score === 1 && rule?.read) return Permission.ReadOnly;
}
return Permission.Custom;
}
}
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
useEffect(() => {
const isRowCustom = selectedPermissionCategory === Permission.Custom;
if (isRowCustom) {
setIsRowExpanded.on();
}
}, []);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
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 (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td>{title}</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<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>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td
colSpan={3}
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
>
<div className="grid grid-cols-3 gap-4">
{getPermissionList(formName).map(({ action, label }) => {
const permissionName = `permissions.${formName}.${action}` as PermissionName;
return (
<Controller
name={permissionName}
key={permissionName}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={permissionName}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};

View File

@@ -0,0 +1,110 @@
import { useMemo } from "react";
import {
Control,
// Controller,
UseFormGetValues,
UseFormSetValue,
useWatch
} from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Select, SelectItem,Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
type Props = {
title: string;
formName: "secrets";
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
getValue: UseFormGetValues<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const RowPermissionSecretsRow = ({
title,
formName,
isEditable,
setValue,
getValue,
control
}: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
// const [isCustom, setIsCustom] = useToggle();
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) => {
if (!val) return;
switch (val) {
case Permission.NoAccess: {
const permissions = getValue("permissions");
if (permissions) delete permissions[formName];
setValue("permissions", permissions, { 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 (
<Tr
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td>{title}</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<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>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,199 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
// Td
} from "@app/components/v2";
import { ProjectPermissionSub,useWorkspace } from "@app/context";
import {
// useUpdateOrgRole,
useGetProjectRoleBySlug
} from "@app/hooks/api";
import {
formSchema,
rolePermission2Form,
TFormSchema} from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
import { RolePermissionRow } from "./RolePermissionRow";
const SINGLE_PERMISSION_LIST = [
{
title: "Project",
formName: "workspace"
},
{
title: "Integrations",
formName: "integrations"
},
{
title: "Secret Protect policy",
formName: ProjectPermissionSub.SecretApproval
},
{
title: "Roles",
formName: "role"
},
{
title: "User management",
formName: "member"
},
{
title: "Group management",
formName: "groups"
},
{
title: "Machine identity management",
formName: "identity"
},
{
title: "Webhooks",
formName: "webhooks"
},
{
title: "Service Tokens",
formName: "service-tokens"
},
{
title: "Settings",
formName: "settings"
},
{
title: "Environments",
formName: "environments"
},
{
title: "Tags",
formName: "tags"
},
{
title: "Audit Logs",
formName: "audit-logs"
},
{
title: "IP Allowlist",
formName: "ip-allowlist"
},
{
title: "Certificate Authorities",
formName: "certificate-authorities"
},
{
title: "Certificates",
formName: "certificates"
},
{
title: "Secret Rollback",
formName: "secret-rollback"
}
] as const;
type Props = {
roleSlug: string;
};
// TODO: fill table
export const RolePermissionsSection = ({ roleSlug }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
console.log("RolePermissionsSection role data: ", role);
const {
setValue,
control,
handleSubmit,
formState: { isDirty, isSubmitting },
reset
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
resolver: zodResolver(formSchema)
});
// const { mutateAsync: updateRole } = useUpdateOrgRole();
const onSubmit = async () => {
try {
// await updateRole({
// orgId,
// id: roleId,
// ...el,
// permissions: formRolePermission2API(el.permissions)
// });
createNotification({ type: "success", text: "Successfully updated role" });
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? "");
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Permissions</h3>
{isCustomRole && (
<div className="flex items-center">
<Button
colorSchema="primary"
type="submit"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
>
Save
</Button>
<Button
className="ml-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
onClick={() => reset()}
>
Cancel
</Button>
</div>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5" />
<Th>Resource</Th>
<Th>Permission</Th>
</Tr>
</THead>
<TBody>
{SINGLE_PERMISSION_LIST.map((permission) => {
console.log("permission: ", permission);
return (
<RolePermissionRow
title={permission.title}
formName={permission.formName}
control={control}
setValue={setValue}
key={`project-role-${roleSlug}-permission-${permission.formName}`}
isEditable={isCustomRole}
/>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
</form>
);
};

View File

@@ -0,0 +1 @@
export { RolePermissionsSection } from "./RolePermissionsSection";

View File

@@ -0,0 +1,2 @@
export { RoleDetailsSection } from "./RoleDetailsSection";
export { RolePermissionsSection } from "./RolePermissionsSection";