diff --git a/frontend-v2/src/components/permissions/ProjectPermissionCan.tsx b/frontend-v2/src/components/permissions/ProjectPermissionCan.tsx index 4fc5acde9..d8f794bf9 100644 --- a/frontend-v2/src/components/permissions/ProjectPermissionCan.tsx +++ b/frontend-v2/src/components/permissions/ProjectPermissionCan.tsx @@ -1,11 +1,31 @@ import { FunctionComponent, ReactNode } from "react"; import { AbilityTuple, MongoAbility } from "@casl/ability"; import { Can } from "@casl/react"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext"; import { Tooltip } from "../v2/Tooltip"; +export const ProjectPermissionGuardBanner = () => { + return ( +
+
+
+ +
+
+
Access Restricted
+
+ Your role has limited permissions, please
contact your admin to gain access +
+
+
+
+ ); +}; + type Props = { label?: ReactNode; // this prop is used when there exist already a tooltip as helper text for users @@ -17,6 +37,7 @@ type Props = { I: T[0]; a: T[1]; ability?: MongoAbility; + renderGuardBanner?: boolean; }; export const ProjectPermissionCan: FunctionComponent> = ({ @@ -25,6 +46,7 @@ export const ProjectPermissionCan: FunctionComponent passThrough = true, renderTooltip, allowedLabel, + renderGuardBanner, ...props }) => { const { permission } = useProjectPermission(); @@ -39,10 +61,14 @@ export const ProjectPermissionCan: FunctionComponent return {finalChild}; } - if (isAllowed && renderTooltip) { + if (isAllowed && renderTooltip && allowedLabel) { return {finalChild}; } + if (!isAllowed && renderGuardBanner) { + return ; + } + if (!isAllowed) return null; return finalChild; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx deleted file mode 100644 index 104e2144e..000000000 --- a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend-v2/src/const/routes.ts b/frontend-v2/src/const/routes.ts index f2e75a03c..913b3f60d 100644 --- a/frontend-v2/src/const/routes.ts +++ b/frontend-v2/src/const/routes.ts @@ -31,5 +31,13 @@ export const ROUTE_PATHS = Object.freeze({ OrgRoleByIDPage: setRoute( "/organization/roles/$roleId", "/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/" + ), + ProductAccessControlPage: setRoute( + "/secret-manager/$projectId/access", + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/" + ), + SecretDashboardPage: setRoute( + "/secret-manager/$projectId/secrets/$envSlug", + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/" ) }); diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/AccessControlPage.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/AccessControlPage.tsx new file mode 100644 index 000000000..cb0205180 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/AccessControlPage.tsx @@ -0,0 +1,100 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useSearch } from "@tanstack/react-router"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { getProjectTitle } from "@app/helpers/project"; +import { withProjectPermission } from "@app/hoc"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ProjectAccessControlTabs } from "@app/types/project"; + +import { + GroupsTab, + IdentityTab, + MembersTab, + ProjectRoleListTab, + ServiceTokenTab +} from "./components"; + +const Page = withProjectPermission( + () => { + const navigate = useNavigate(); + const { currentWorkspace } = useWorkspace(); + const selectedTab = useSearch({ + strict: false, + select: (el) => el.selectedTab + }); + + const updateSelectedTab = (tab: string) => { + navigate({ + to: `/${currentWorkspace.type}/$projectId/access` as const, + search: (prev) => ({ ...prev, selectedTab: tab }), + params: { + projectId: currentWorkspace.id + } + }); + }; + + return ( +
+
+

+ {currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access + Control +

+ + + Users + Groups + +
+

Machine Identities

+
+
+ {currentWorkspace?.type === ProjectType.SecretManager && ( + Service Tokens + )} + Project Roles +
+ + + + + + + + + + {currentWorkspace?.type === ProjectType.SecretManager && ( + + + + )} + + + +
+
+
+ ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Member + } +); + +export const AccessControlPage = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/GroupsTab.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/GroupsTab.tsx new file mode 100644 index 000000000..19fb35773 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/GroupsTab.tsx @@ -0,0 +1,18 @@ +import { motion } from "framer-motion"; + +import { GroupsSection } from "./components"; + +export const GroupsTab = () => { + return ( + + + + ); +}; + diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx new file mode 100644 index 000000000..bd150a6a1 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Link } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useListWorkspaceGroups +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + group: z.object({ id: z.string(), name: z.string() }), + role: z.object({ slug: z.string(), name: z.string() }) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["group"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; +}; + +// TODO: update backend to support adding multiple roles at once + +const Content = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?.id || ""; + + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.id || ""); + + const { data: roles } = useGetProjectRoles(currentWorkspace?.id || ""); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ group, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + projectId: currentWorkspace?.id || "", + groupId: group.id, + role: role.slug || undefined + }); + + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); + } + }; + + return filteredGroupMembershipOrgs.length ? ( +
+ ( + + option.id} + getOptionLabel={(option) => option.name} + options={filteredGroupMembershipOrgs} + placeholder="Select group..." + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + options={roles} + placeholder="Select role..." + /> + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ ); +}; + +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { + return ( + handlePopUpToggle("group", isOpen)} + > + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx new file mode 100644 index 000000000..f7a941586 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -0,0 +1,453 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api"; +import { TGroupMembership } from "@app/hooks/api/groups/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const IdentityTemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Set Role Temporarily +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + groupId: string; + roles: TGroupMembership["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles( + currentWorkspace?.id ?? "" + ); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + try { + await updateGroupWorkspaceRole.mutateAsync({ + projectId: currentWorkspace?.id || "", + groupId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated group role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch { + createNotification({ text: "Failed to update group role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx new file mode 100644 index 000000000..8ae272725 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -0,0 +1,106 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; + +import { GroupModal } from "./GroupModal"; +import { GroupTable } from "./GroupsTable"; + +export const GroupsSection = () => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "group", + "deleteGroup", + "upgradePlan" + ] as const); + + const handleAddGroupModal = () => { + if (!subscription?.groups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage users more efficiently with groups if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("group"); + } + }; + + const onRemoveGroupSubmit = async (groupId: string) => { + try { + await deleteMutateAsync({ + groupId, + projectId: currentWorkspace?.id || "" + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteGroup"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove group from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( +
+
+

User Groups

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveGroupSubmit((popUp?.deleteGroup?.data as { id: string })?.id) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx new file mode 100644 index 000000000..24f459a93 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faMagnifyingGlass, + faSearch, + faTrash, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListWorkspaceGroups } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupRoles } from "./GroupRoles"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteGroup", "group"]>, + data?: { + id?: string; + name?: string; + } + ) => void; +}; + +enum GroupsOrderBy { + Name = "name" +} + +export const GroupTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + toggleOrderDirection + } = usePagination(GroupsOrderBy.Name, { initPerPage: 20 }); + + const { data: groupMemberships = [], isPending } = useListWorkspaceGroups( + currentWorkspace?.id || "" + ); + + const filteredGroupMemberships = useMemo(() => { + const filtered = search + ? groupMemberships?.filter( + ({ group: { name, slug } }) => + name.toLowerCase().includes(search.toLowerCase()) || + slug.toLowerCase().includes(search.toLowerCase()) + ) + : groupMemberships; + + const ordered = filtered?.sort((a, b) => + a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase()) + ); + + return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse(); + }, [search, groupMemberships, orderBy, orderDirection]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + {isPending && } + {!isPending && + filteredGroupMemberships && + filteredGroupMemberships.length > 0 && + filteredGroupMemberships + .slice(offset, perPage * page) + .map(({ group: { id, name }, roles, createdAt }) => { + return ( + + + + + + + ); + })} + +
+
+ Name + + + +
+
RoleAdded on +
{name} + + {(isAllowed) => ( + + )} + + {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( +
+ + { + handlePopUpOpen("deleteGroup", { + id, + name + }); + }} + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + +
+ )} +
+
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isPending && !filteredGroupMemberships?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/GroupsSection/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/components/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/index.tsx new file mode 100644 index 000000000..419579efc --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/GroupsTab/index.tsx @@ -0,0 +1 @@ +export { GroupsTab } from "./GroupsTab"; \ No newline at end of file diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/IdentityTab.tsx new file mode 100644 index 000000000..d3ad8d0e5 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -0,0 +1,443 @@ +import { subject } from "@casl/ability"; +import { + faArrowDown, + faArrowUp, + faArrowUpRightFromSquare, + faClock, + faEllipsisV, + faMagnifyingGlass, + faPlus, + faServer, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Pagination, + Spinner, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectIdentityOrderBy } from "@app/hooks/api/workspace/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { IdentityModal } from "./components/IdentityModal"; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.NoAccess) return "No access"; + return role; +}; +export const IdentityTab = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate(); + + const { + offset, + limit, + orderBy, + setOrderBy, + orderDirection, + setOrderDirection, + search, + debouncedSearch, + setPage, + setSearch, + perPage, + page, + setPerPage + } = usePagination(ProjectIdentityOrderBy.Name); + + const workspaceId = currentWorkspace?.id ?? ""; + + const { data, isPending, isFetching } = useGetWorkspaceIdentityMemberships( + { + workspaceId: currentWorkspace?.id || "", + offset, + limit, + orderDirection, + orderBy, + search: debouncedSearch + }, + { placeholderData: (prevData) => prevData } + ); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "identity", + "deleteIdentity", + "upgradePlan" + ] as const); + + const onRemoveIdentitySubmit = async (identityId: string) => { + try { + await deleteMutateAsync({ + identityId, + workspaceId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteIdentity"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove identity from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const handleSort = (column: ProjectIdentityOrderBy) => { + if (column === orderBy) { + setOrderDirection((prev) => + prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC + ); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + return ( + +
+
+

Identities

+ + + {(isAllowed) => ( + + )} + +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + /> + + + + + + + + + + + + {isPending && } + {!isPending && + data && + data.identityMemberships.length > 0 && + data.identityMemberships.map((identityMember) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; + return ( + { + if (evt.key === "Enter") { + navigate({ + to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const, + params: { + projectId: currentWorkspace.id, + identityId: id + } + }); + } + }} + onClick={() => + navigate({ + to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const, + params: { + projectId: currentWorkspace.id, + identityId: id + } + }) + } + > + + + + + + + ); + })} + +
+
+ Name + handleSort(ProjectIdentityOrderBy.Name)} + > + + +
+
RoleAdded on{isFetching ? : null}
{name} +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
+ {formatRoleName(role, customRoleName)} +
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
+ )} +
+
{format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + evt.stopPropagation(); + evt.preventDefault(); + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + + + + +
+ {!isPending && data && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && data && data?.identityMemberships.length === 0 && ( + 0 + ? "No identities match search filter" + : "No identities have been added to this project" + } + icon={faServer} + /> + )} +
+ + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } +); diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx new file mode 100644 index 000000000..bd161a5ec --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx @@ -0,0 +1,207 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Link } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalClose, + ModalContent, + Spinner +} from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useAddIdentityToWorkspace, + useGetIdentityMembershipOrgs, + useGetProjectRoles, + useGetWorkspaceIdentityMemberships +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + identity: z.object({ name: z.string(), id: z.string() }), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["identity"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; +}; + +const Content = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const organizationId = currentOrg?.id || ""; + const workspaceId = currentWorkspace?.id || ""; + + const { data: identityMembershipOrgsData, isPending: isMembershipsLoading } = + useGetIdentityMembershipOrgs({ + organizationId, + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR + }); + const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships; + const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({ + workspaceId, + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above + }); + const identityMemberships = identityMembershipsData?.identityMemberships; + + const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); + + const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); + + const filteredIdentityMembershipOrgs = useMemo(() => { + const wsIdentityIds = new Map(); + + identityMemberships?.forEach((identityMembership) => { + wsIdentityIds.set(identityMembership.identity.id, true); + }); + + return (identityMembershipOrgs || []).filter(({ identity: i }) => !wsIdentityIds.has(i.id)); + }, [identityMembershipOrgs, identityMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ identity, role }: FormData) => { + try { + await addIdentityToWorkspaceMutateAsync({ + workspaceId, + identityId: identity.id, + role: role.slug || undefined + }); + + createNotification({ + text: "Successfully added identity to project", + type: "success" + }); + + const nextAvailableMembership = filteredIdentityMembershipOrgs.filter( + (membership) => membership.identity.id !== identity.id + )[0]; + + // prevents combobox from displaying previously added identity + reset({ + identity: { + name: nextAvailableMembership?.identity.name, + id: nextAvailableMembership?.identity.id + } + }); + handlePopUpToggle("identity", false); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to add identity to project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + if (isMembershipsLoading || isRolesLoading) + return ( +
+ +
+ ); + + return filteredIdentityMembershipOrgs.length ? ( +
+ ( + + membership.identity)} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + + + +
+ + ) : ( +
+
+ All identities in your organization have already been added to this project. +
+ + + +
+ ); +}; + +export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { + return ( + { + handlePopUpToggle("identity", isOpen); + }} + > + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/index.tsx new file mode 100644 index 000000000..86d3c710a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/IdentityTab/index.tsx @@ -0,0 +1 @@ +export { IdentityTab } from "./IdentityTab"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/MembersTab.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/MembersTab.tsx new file mode 100644 index 000000000..6b5660c83 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/MembersTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { MembersSection } from "./components"; + +export const MembersTab = () => { + return ( + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx new file mode 100644 index 000000000..b448200d8 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -0,0 +1,222 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Link } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useAddUsersToOrg, + useGetOrgUsers, + useGetProjectRoles, + useGetWorkspaceUsers +} from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const addMemberFormSchema = z.object({ + orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1), + projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1) +}); + +type TAddMemberForm = z.infer; + +type Props = { + popUp: UsePopUpState<["addMember"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void; +}; + +export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { + const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?.id || ""; + const workspaceId = currentWorkspace?.id || ""; + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: orgUsers } = useGetOrgUsers(orgId); + + const { data: roles } = useGetProjectRoles(currentWorkspace?.id || ""); + + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(addMemberFormSchema), + defaultValues: { orgMemberships: [], projectRoleSlugs: [] } + }); + + const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); + + const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => { + if (!currentWorkspace) return; + if (!currentOrg?.id) return; + + const selectedMembers = orgMemberships.map((orgMembership) => + orgUsers?.find((orgUser) => orgUser.id === orgMembership.value) + ); + + if (!selectedMembers) return; + + try { + if (currentWorkspace.version === ProjectVersion.V1) { + createNotification({ + type: "error", + text: "Please upgrade your project to invite new members to the project." + }); + } else { + const inviteeEmails = selectedMembers + .map((member) => member?.user.username as string) + .filter(Boolean); + if (inviteeEmails.length) { + await addMembersToProject({ + inviteeEmails, + organizationId: orgId, + organizationRoleSlug: ProjectMembershipRole.Member, // ? This doesn't apply in this case, because we know the users being added are already part of the organization + projects: [ + { + slug: currentWorkspace.slug, + id: currentWorkspace.id, + projectRoleSlug: projectRoleSlugs.map((role) => role.slug) + } + ] + }); + } + } + createNotification({ + text: "Successfully added user to the project", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to add user to project", + type: "error" + }); + } + handlePopUpToggle("addMember", false); + reset(); + }; + + const filteredOrgUsers = useMemo(() => { + const wsUserUsernames = new Map(); + members?.forEach((member) => { + wsUserUsernames.set(member.user.username, true); + }); + return (orgUsers || []) + .filter(({ user: u }) => !wsUserUsernames.has(u.username)) + .map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({ + value: id, + label: + firstName && lastName + ? `${firstName} ${lastName}` + : firstName || lastName || email || inviteEmail + })); + }, [orgUsers, members]); + + const selectedOrgMemberships = watch("orgMemberships"); + const selectedRoleSlugs = watch("projectRoleSlugs"); + + return ( + handlePopUpToggle("addMember", isOpen)} + > + + {filteredOrgUsers.length ? ( +
+
+ ( + + + + )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+
+ + +
+
+ ) : ( +
+
All the users in your organization are already invited.
+ + + +
+ )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx new file mode 100644 index 000000000..70155eb13 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx @@ -0,0 +1,351 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isPending) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId: projectMember.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + roleForm.reset(undefined, { keepValues: true }); + } catch { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx new file mode 100644 index 000000000..93839337b --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx @@ -0,0 +1,38 @@ +import { Link } from "@tanstack/react-router"; + +import { Alert, AlertDescription } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MemberRbacSection } from "./MemberRbacSection"; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { currentWorkspace } = useWorkspace(); + return ( +
+ + + + + + Click here to access them now + + + + +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..06641be7a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,572 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { + faArrowRotateLeft, + faCaretDown, + faCheck, + faClock, + faLockOpen, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { removeTrailingSlash } from "@app/helpers/string"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateAccessRequest, + useDeleteProjectUserAdditionalPrivilege +} from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +export const SpecificPrivilegeSecretForm = ({ + privilege, + policies, + onClose +}: { + privilege?: TProjectUserPrivilege; + policies?: TAccessApprovalPolicy[]; + onClose?: () => void; +}) => { + const { currentWorkspace } = useWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege", + "requestAccess" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = + permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; + + const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const requestAccess = useCreateAccessRequest(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + ...(privilege + ? { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob + ? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob) + : "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + : { + environmentSlug: currentWorkspace.environments?.[0]?.slug, + read: false, + edit: false, + create: false, + delete: false, + temporaryAccess: { + isTemporary: false + } + }) + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const selectedEnvironment = privilegeForm.watch("environmentSlug"); + const secretPath = privilegeForm.watch("secretPath"); + + const readAccess = privilegeForm.watch("read"); + const createAccess = privilegeForm.watch("create"); + const editAccess = privilegeForm.watch("edit"); + const deleteAccess = privilegeForm.watch("delete"); + + const accessSelected = readAccess || createAccess || editAccess || deleteAccess; + + const selectablePaths = useMemo(() => { + if (!policies) return []; + const environmentPolicies = policies.filter( + (policy) => policy.environment.slug === selectedEnvironment + ); + + privilegeForm.setValue("secretPath", "", { + shouldValidate: true + }); + + return [...environmentPolicies.map((policy) => policy.secretPath)]; + }, [policies, selectedEnvironment]); + + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleDeletePrivilege = async () => { + if (!privilege) { + createNotification({ + type: "error", + text: "No privilege to delete found.", + title: "Error" + }); + return; + } + + if (deleteUserPrivilege.isPending) return; + try { + await deleteUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + // This is used for requesting access additional privileges, not directly creating a privilege! + const handleRequestAccess = async (data: TSecretPermissionForm) => { + if (!policies) return; + if (!currentWorkspace) { + createNotification({ + type: "error", + text: "No workspace found.", + title: "Error" + }); + return; + } + + if (!data.secretPath) { + createNotification({ + type: "error", + text: "Please select a secret path", + title: "Error" + }); + return; + } + + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await requestAccess.mutateAsync({ + ...data, + ...(data.temporaryAccess.isTemporary && { + temporaryRange: data.temporaryAccess.temporaryRange + }), + projectSlug: currentWorkspace.slug, + isTemporary: data.temporaryAccess.isTemporary, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }); + + createNotification({ + type: "success", + text: "Successfully requested access" + }); + privilegeForm.reset(); + if (onClose) onClose(); + }; + + const handleSubmit = async (data: TSecretPermissionForm) => { + handleRequestAccess(data); + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + + if (exactTime && !policies) { + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + } + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + { + if (policies) { + return ( + +
+ + + +
+
+ ); + } + return ( + + + + ); + }} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {/* eslint-disable-next-line no-nested-ternary */} + {privilegeForm.formState.isDirty && privilege ? ( + <> + + privilegeForm.reset()} + > + + + + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + ) : // eslint-disable-next-line no-nested-ternary + privilege ? ( + + handlePopUpOpen("deletePrivilege")} + > + + + + ) : ( +
+ )} +
+
+ {!!policies && ( + + )} + + handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/index.tsx new file mode 100644 index 000000000..765b4061c --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MemberRoleForm/index.tsx @@ -0,0 +1 @@ +export { MemberRoleForm } from "./MemberRoleForm"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersSection.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersSection.tsx new file mode 100644 index 000000000..ee1f791d3 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersSection.tsx @@ -0,0 +1,91 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteUserFromWorkspace } from "@app/hooks/api"; + +import { AddMemberModal } from "./AddMemberModal"; +import { MembersTable } from "./MembersTable"; + +export const MembersSection = () => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "addMember", + "removeMember", + "upgradePlan" + ] as const); + + const handleRemoveUser = async () => { + const username = (popUp?.removeMember?.data as { username: string })?.username; + if (!currentOrg?.id) return; + if (!currentWorkspace?.id) return; + + try { + await removeUserFromWorkspace({ + workspaceId: currentWorkspace.id, + usernames: [username], + orgId: currentOrg.id + }); + createNotification({ + text: "Successfully removed user from project", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove user from the project", + type: "error" + }); + } + handlePopUpClose("removeMember"); + }; + + return ( +
+
+

Users

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("removeMember", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersTable.tsx new file mode 100644 index 000000000..c7937bc9d --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -0,0 +1,375 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faClock, + faEllipsisV, + faMagnifyingGlass, + faSearch, + faTrash, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useUser, + useWorkspace +} from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.NoAccess) return "No access"; + return role; +}; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, + data?: object + ) => void; +}; + +enum MembersOrderBy { + Name = "firstName", + Email = "email" +} + +export const MembersTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { user } = useUser(); + const navigate = useNavigate(); + + const userId = user?.id || ""; + const workspaceId = currentWorkspace?.id || ""; + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + setOrderBy, + setOrderDirection, + toggleOrderDirection + } = usePagination(MembersOrderBy.Name, { initPerPage: 20 }); + + const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); + + const filteredUsers = useMemo( + () => + members + ?.filter( + ({ user: u, inviteEmail }) => + u?.firstName?.toLowerCase().includes(search.toLowerCase()) || + u?.lastName?.toLowerCase().includes(search.toLowerCase()) || + u?.username?.toLowerCase().includes(search.toLowerCase()) || + u?.email?.toLowerCase().includes(search.toLowerCase()) || + inviteEmail?.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => { + const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + let valueOne: string; + let valueTwo: string; + + switch (orderBy) { + case MembersOrderBy.Email: + valueOne = memberOne.user.email || memberOne.inviteEmail; + valueTwo = memberTwo.user.email || memberTwo.inviteEmail; + break; + case MembersOrderBy.Name: + default: + valueOne = memberOne.user.firstName; + valueTwo = memberTwo.user.firstName; + } + + if (!valueOne) return 1; + if (!valueTwo) return -1; + + return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase()); + }), + [members, search, orderDirection, orderBy] + ); + + useResetPageHelper({ + totalCount: filteredUsers.length, + offset, + setPage + }); + + const handleSort = (column: MembersOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + {isMembersLoading && } + {!isMembersLoading && + filteredUsers.slice(offset, perPage * page).map((projectMember) => { + const { user: u, inviteEmail, id: membershipId, roles } = projectMember; + const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-"; + const email = u?.email || inviteEmail; + + return ( + { + if (evt.key === "Enter") { + navigate({ + to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const, + params: { + projectId: workspaceId, + membershipId + } + }); + } + }} + onClick={() => + navigate({ + to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const, + params: { + projectId: workspaceId, + membershipId + } + }) + } + > + + + + + + ); + })} + +
+
+ Name + handleSort(MembersOrderBy.Name)} + > + + +
+
+
+ Email + handleSort(MembersOrderBy.Email)} + > + + +
+
Role +
{name}{email} +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = + new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
+ {formatRoleName(role, customRoleName)} +
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
+ )} +
+
+ {userId !== u?.id && ( +
+ + {(isAllowed) => ( + { + evt.preventDefault(); + evt.stopPropagation(); + handlePopUpOpen("removeMember", { username: u.username }); + }} + > + + + )} + + + + +
+ )} +
+ {Boolean(filteredUsers.length) && ( + + )} + {!isMembersLoading && !filteredUsers?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/index.tsx new file mode 100644 index 000000000..ccb1cc6cf --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/components/index.tsx @@ -0,0 +1 @@ +export { MembersSection } from "./MembersSection"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/index.tsx new file mode 100644 index 000000000..f557950c8 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/MembersTab/index.tsx @@ -0,0 +1 @@ +export { MembersTab } from "./MembersTab"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx new file mode 100644 index 000000000..4a5fc37ac --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -0,0 +1,23 @@ +import { motion } from "framer-motion"; + +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + +import { ProjectRoleList } from "./components/ProjectRoleList"; + +export const ProjectRoleListTab = withProjectPermission( + () => { + return ( + + + + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx new file mode 100644 index 000000000..312de70ac --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -0,0 +1,184 @@ +import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; +import { TProjectRole } from "@app/hooks/api/roles/types"; +import { RoleModal } from "@app/routes/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/-components"; + +export const ProjectRoleList = () => { + const navigate = useNavigate(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(projectId); + + const { mutateAsync: deleteRole } = useDeleteProjectRole(); + + const handleRoleDelete = async () => { + const { id } = popUp?.deleteRole?.data as TProjectRole; + try { + await deleteRole({ + projectId, + id + }); + createNotification({ type: "success", text: "Successfully removed the role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to delete role" }); + } + }; + + return ( +
+
+

Project Roles

+ + {(isAllowed) => ( + + )} + +
+ + + + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { id, name, slug } = role; + const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug); + + return ( + + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }) + } + > + + + + + ); + })} + +
NameSlug +
{name}{slug} + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }); + }} + disabled={!isAllowed} + > + {`${isNonMutatable ? "View" : "Edit"} Role`} + + )} + + {!isNonMutatable && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", role); + }} + disabled={!isAllowed} + > + Delete Role + + )} + + )} + +
+
+
+ + handlePopUpClose("deleteRole")} + onDeleteApproved={handleRoleDelete} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx new file mode 100644 index 000000000..9f8e88a82 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleList } from "./ProjectRoleList"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/index.tsx new file mode 100644 index 000000000..5dc87a2d7 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ProjectRoleListTab/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleListTab } from "./ProjectRoleListTab"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx new file mode 100644 index 000000000..f30c853e6 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx @@ -0,0 +1,49 @@ +// import { faWarning } from "@fortawesome/free-solid-svg-icons"; +// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; + +import { ServiceTokenSection } from "./components"; + +export const ServiceTokenTab = () => { + return ( + +
+ {/*
+ +
+ Deprecation Notice +

+ Service Tokens are being deprecated in favor of Machine Identities. +
+ They will be removed in the future in accordance with the deprecation notice and + timeline stated{" "} + + here + + . +
+ + Learn more about Machine Identities + +

+
+
*/} + +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx new file mode 100644 index 000000000..571adf5f3 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -0,0 +1,380 @@ +import crypto from "crypto"; + +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosError } from "axios"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + decryptAssymmetric, + encryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + Checkbox, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useCreateServiceToken, useGetUserWsKey } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const apiTokenExpiry = [ + { label: "1 Day", value: 86400 }, + { label: "7 Days", value: 604800 }, + { label: "1 Month", value: 2592000 }, + { label: "6 months", value: 15552000 }, + { label: "12 months", value: 31104000 }, + { label: "Never", value: null } +]; + +// TODO(rbr): test this form +const schema = z.object({ + name: z.string().max(100), + scopes: z + .object({ + environment: z.string().max(50), + secretPath: z + .string() + .default("/") + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) + }) + .array() + .min(1), + expiresIn: z.string().optional(), + permissions: z + .object({ + read: z.boolean(), + write: z.boolean() + }) + .required() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["createAPIToken"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void; +}; + +export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { + const { t } = useTranslation(); + + const { currentWorkspace } = useWorkspace(); + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + scopes: [ + { + secretPath: "/", + environment: currentWorkspace?.environments?.[0]?.slug + } + ] + } + }); + + const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); + + const [newToken, setToken] = useState(""); + const [isTokenCopied, setIsTokenCopied] = useToggle(false); + + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?.id ?? ""); + const createServiceToken = useCreateServiceToken(); + const hasServiceToken = Boolean(newToken); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isTokenCopied) { + timer = setTimeout(() => setIsTokenCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isTokenCopied]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newToken); + setIsTokenCopied.on(); + }; + + const onFormSubmit = async ({ name, scopes, expiresIn, permissions }: FormData) => { + try { + if (!currentWorkspace?.id) return; + if (!latestFileKey) return; + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const randomBytes = crypto.randomBytes(16).toString("hex"); + + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: key, + key: randomBytes + }); + + const { serviceToken } = await createServiceToken.mutateAsync({ + encryptedKey: ciphertext, + iv, + tag, + scopes, + expiresIn: Number(expiresIn), + name, + workspaceId: currentWorkspace.id, + randomBytes, + permissions: Object.entries(permissions) + .filter(([, permissionsValue]) => permissionsValue) + .map(([permissionsKey]) => permissionsKey) + }); + + setToken(serviceToken); + createNotification({ + text: "Successfully created a service token", + type: "success" + }); + } catch (err) { + console.error(err); + const axiosError = err as AxiosError; + if (axiosError?.response?.status === 401) { + createNotification({ + text: "You do not have access to the selected environment/path", + type: "error" + }); + } else { + createNotification({ + text: "Failed to create a service token", + type: "error" + }); + } + } + }; + + return ( + { + handlePopUpToggle("createAPIToken", open); + reset(); + setToken(""); + }} + > + + {!hasServiceToken ? ( +
+ ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> + { + const options = [ + { + label: "Read (default)", + value: "read" + }, + { + label: "Write (optional)", + value: "write" + } + ] as const; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ + ) : ( +
+

{newToken}

+ + + + {t("common.click-to-copy")} + + +
+ )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx new file mode 100644 index 000000000..46e9e2ceb --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from "react-i18next"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteServiceToken } from "@app/hooks/api"; + +import { AddServiceTokenModal } from "./AddServiceTokenModal"; +import { ServiceTokenTable } from "./ServiceTokenTable"; + +type DeleteModalData = { name: string; id: string }; + +export const ServiceTokenSection = withProjectPermission( + () => { + const { t } = useTranslation(); + + const deleteServiceToken = useDeleteServiceToken(); + + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createAPIToken", + "deleteAPITokenConfirmation" + ] as const); + + const onDeleteApproved = async () => { + try { + deleteServiceToken.mutateAsync( + (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id + ); + createNotification({ + text: "Successfully deleted service token", + type: "success" + }); + + handlePopUpClose("deleteAPITokenConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token", + type: "error" + }); + } + }; + + return ( +
+
+

Service Tokens

+ + {(isAllowed) => ( + + )} + +
+

{t("section.token.service-tokens-description")}

+ + + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} + deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx new file mode 100644 index 000000000..51459e766 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -0,0 +1,108 @@ +import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useGetUserWsServiceTokens } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; +}; + +export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetUserWsServiceTokens({ + workspaceID: currentWorkspace?.id || "" + }); + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.map((row) => ( + + + + + + + ))} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
Token NameEnvironment - Secret PathValid Until +
{row.name} +
+ {row?.scopes.map(({ secretPath, environment }) => ( +
+
{environment}
+ + {secretPath} +
+ ))} +
+
{row.expiresAt && new Date(row.expiresAt).toUTCString()} + + {(isAllowed) => ( + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row.id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx new file mode 100644 index 000000000..ddc3bdedb --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenSection } from "./ServiceTokenSection"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/index.tsx new file mode 100644 index 000000000..ddc3bdedb --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/components/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenSection } from "./ServiceTokenSection"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/index.tsx new file mode 100644 index 000000000..1c093e700 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/ServiceTokenTab/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenTab } from "./ServiceTokenTab"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/components/index.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/index.tsx new file mode 100644 index 000000000..edb14ec8f --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/components/index.tsx @@ -0,0 +1,5 @@ +export { GroupsTab } from "./GroupsTab"; +export { IdentityTab } from "./IdentityTab"; +export { MembersTab } from "./MembersTab"; +export { ProjectRoleListTab } from "./ProjectRoleListTab"; +export { ServiceTokenTab } from "./ServiceTokenTab"; diff --git a/frontend-v2/src/pages/secret-manager/AccessControlPage/route.tsx b/frontend-v2/src/pages/secret-manager/AccessControlPage/route.tsx new file mode 100644 index 000000000..1c93f45c9 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/AccessControlPage/route.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { ProjectAccessControlTabs } from "@app/types/project"; + +import { AccessControlPage } from "./AccessControlPage"; + +const AccessControlPageQuerySchema = z.object({ + selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member) +}); + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/" +)({ + component: AccessControlPage, + validateSearch: zodValidator(AccessControlPageQuerySchema) +}); diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/IPAllowListPage.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/IPAllowListPage.tsx new file mode 100644 index 000000000..03524131e --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/IPAllowListPage.tsx @@ -0,0 +1,35 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; + +import { IPAllowlistSection } from "./components"; + +export const IPAllowListPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + +
+
+
+

IP Allowlist

+
+
+ + + +
+
+ + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistModal.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistModal.tsx new file mode 100644 index 000000000..70f75271f --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistModal.tsx @@ -0,0 +1,168 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useAddTrustedIp, useGetMyIp, useUpdateTrustedIp } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + ipAddress: z.string(), + comment: z.string() + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["trustedIp"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void; +}; + +export const IPAllowlistModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { data, isLoading } = useGetMyIp(); + + const { currentWorkspace } = useWorkspace(); + const addTrustedIp = useAddTrustedIp(); + const updateTrustedIp = useUpdateTrustedIp(); + + const { + control, + setValue, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + const trustedIpData = popUp?.trustedIp?.data as { + ipAddress: string; + comment: string; + prefix: number; + }; + + if (popUp?.trustedIp?.data) { + reset({ + ipAddress: `${trustedIpData.ipAddress}${ + trustedIpData.prefix !== undefined ? `/${trustedIpData.prefix}` : "" + }`, + comment: trustedIpData.comment + }); + } else { + reset({ + ipAddress: "", + comment: "" + }); + } + }, [popUp?.trustedIp?.data]); + + const onIPAllowlistModalSubmit = async ({ ipAddress, comment }: FormData) => { + try { + if (!currentWorkspace?.id) return; + + if (popUp?.trustedIp?.data) { + await updateTrustedIp.mutateAsync({ + workspaceId: currentWorkspace.id, + trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId, + ipAddress, + comment, + isActive: true + }); + } else { + await addTrustedIp.mutateAsync({ + workspaceId: currentWorkspace.id, + ipAddress, + comment, + isActive: true + }); + } + + createNotification({ + text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`, + type: "success" + }); + + reset(); + handlePopUpClose("trustedIp"); + } catch { + createNotification({ + text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("trustedIp", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + {!isLoading && data && ( + + )} + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx new file mode 100644 index 000000000..9fba3f3fe --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx @@ -0,0 +1,112 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useDeleteTrustedIp } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { IPAllowlistModal } from "./IPAllowlistModal"; +import { IPAllowlistTable } from "./IPAllowlistTable"; + +export const IPAllowlistSection = () => { + const { mutateAsync } = useDeleteTrustedIp(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "trustedIp", + "deleteTrustedIp", + "upgradePlan" + ] as const); + + const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { + try { + if (!currentWorkspace?.id) return; + + await mutateAsync({ + workspaceId: currentWorkspace.id, + trustedIpId + }); + + createNotification({ + text: "Successfully deleted IP access range", + type: "success" + }); + + handlePopUpClose("deleteTrustedIp"); + } catch (err) { + console.log(err); + createNotification({ + text: "Failed to delete IP access range", + type: "error" + }); + } + }; + + return ( +
+
+

IP Allowlist

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteTrustedIp", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTrustedIpSubmit( + (popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx new file mode 100644 index 000000000..39dab6fee --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx @@ -0,0 +1,166 @@ +import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetTrustedIps } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["upgradePlan"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, + data?: { + trustedIpId: string; + ipAddress?: string; + comment?: string; + isActive?: boolean; + prefix?: number; + } + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; +}; + +export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetTrustedIps(currentWorkspace?.id ?? ""); + + const formatType = (type: string, prefix?: number) => { + return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${ + prefix !== undefined ? "CIDR" : "" + }`; + }; + + return ( +
+ + + + + + + + {/* */} + + + + {!isLoading && + data && + data?.length > 0 && + data + .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + .map(({ id, ipAddress, comment, type, prefix, isActive }) => { + return ( + + + + + {/* */} + + + ); + })} + {isLoading && ( + + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
IP Address / RangeFormatCommentStatus +
{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}{formatType(type, prefix)}{comment} +
+ +

Active

+
+
+ + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("trustedIp", { + trustedIpId: id, + ipAddress, + comment, + prefix, + isActive + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("deleteTrustedIp", { + trustedIpId: id + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/index.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/index.tsx new file mode 100644 index 000000000..488a07e71 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/components/index.tsx @@ -0,0 +1 @@ +export { IPAllowlistSection } from "./IPAllowlistSection"; diff --git a/frontend-v2/src/pages/secret-manager/IPAllowlistPage/route.tsx b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/route.tsx new file mode 100644 index 000000000..ac298f8b3 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IPAllowlistPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { IPAllowListPage } from "./IPAllowListPage"; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/" +)({ + component: () => IPAllowListPage +}); diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx new file mode 100644 index 000000000..61faa8d4c --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -0,0 +1,198 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { subject } from "@casl/ability"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { format } from "date-fns"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { getProjectTitle } from "@app/helpers/project"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteIdentityFromWorkspace, + useGetWorkspaceIdentityMembershipDetails +} from "@app/hooks/api"; + +import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; +import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection"; + +const Page = () => { + const navigate = useNavigate(); + const identityId = useParams({ + strict: false, + select: (el) => el.identityId as string + }); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } = + useGetWorkspaceIdentityMembershipDetails(workspaceId, identityId); + + const { mutateAsync: deleteMutateAsync, isPending: isDeletingIdentity } = + useDeleteIdentityFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "deleteIdentity", + "upgradePlan" + ] as const); + + const onRemoveIdentitySubmit = async () => { + try { + await deleteMutateAsync({ + identityId, + workspaceId + }); + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + handlePopUpClose("deleteIdentity"); + navigate({ + to: `/${currentWorkspace.type}/$projectId/access` as const, + params: { + projectId: workspaceId + }, + search: { + selectedTab: "identities" + } + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove identity from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + if (isMembershipDetailsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ {identityMembershipDetails ? ( + <> +
+
+
+

+ Project Identity Access +

+
+ + {(isAllowed) => ( + + )} + +
+
+
+
+ Name + {identityMembershipDetails && ( +

+ {identityMembershipDetails?.identity?.name} +

+ )} +
+
+
+ Joined on{" "} + {identityMembershipDetails?.createdAt && + format(new Date(identityMembershipDetails?.createdAt || ""), "yyyy-MM-dd")} +
+
+
+ + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="remove" + onDeleteApproved={() => onRemoveIdentitySubmit()} + /> + + ) : ( + + )} +
+ ); +}; + +export const IdentityDetailsByIDPage = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx new file mode 100644 index 000000000..2284e05c2 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -0,0 +1,435 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { + faCaretDown, + faChevronLeft, + faClock, + faPlus, + faSave +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + FormLabel, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { + useCreateIdentityProjectAdditionalPrivilege, + useGetIdentityProjectPrivilegeDetails, + useUpdateIdentityProjectAdditionalPrivilege +} from "@app/hooks/api"; +import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types"; +import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies"; +import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState"; +import { + formRolePermission2API, + isConditionalSubjects, + PROJECT_PERMISSION_OBJECT, + projectRoleFormSchema, + rolePermission2Form +} from "@app/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils"; +import { renderConditionalComponents } from "@app/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection"; + +type Props = { + privilegeId?: string; + identityId: string; + onGoBack: () => void; + isDisabled?: boolean; +}; + +export const formSchema = z.object({ + slug: z.string().optional(), + temporaryAccess: z + .discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + .default({ isTemporary: false }), + permissions: projectRoleFormSchema.shape.permissions +}); + +type TFormSchema = z.infer; + +export const IdentityProjectAdditionalPrivilegeModifySection = ({ + privilegeId = "", + onGoBack, + identityId, + isDisabled +}: Props) => { + const isCreate = !privilegeId; + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: privilegeDetails, isPending } = useGetIdentityProjectPrivilegeDetails({ + identityId, + projectId, + privilegeId + }); + const { permission } = useProjectPermission(); + const isIdentityEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + + const form = useForm({ + values: privilegeDetails + ? { + ...privilegeDetails, + permissions: rolePermission2Form(privilegeDetails.permissions), + temporaryAccess: privilegeDetails.isTemporary + ? { + isTemporary: true, + temporaryRange: privilegeDetails.temporaryRange || "", + temporaryAccessEndTime: privilegeDetails.temporaryAccessEndTime || "", + temporaryAccessStartTime: privilegeDetails.temporaryAccessStartTime || "" + } + : { + isTemporary: privilegeDetails.isTemporary + } + } + : undefined, + resolver: zodResolver(formSchema) + }); + + const { + handleSubmit, + formState: { isDirty, isSubmitting } + } = form; + + const { mutateAsync: updateIdentityProjectAdditionalPrivilege } = + useUpdateIdentityProjectAdditionalPrivilege(); + const { mutateAsync: createIdentityProjectAdditionalPrivilege } = + useCreateIdentityProjectAdditionalPrivilege(); + + const onSubmit = async (el: TFormSchema) => { + const accessType = !el.temporaryAccess.isTemporary + ? { isTemporary: false as const } + : { + isTemporary: true as const, + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + + try { + if (isCreate) { + await createIdentityProjectAdditionalPrivilege({ + permissions: formRolePermission2API(el.permissions), + identityId, + projectId, + slug: el.slug || undefined, + type: accessType + }); + createNotification({ type: "success", text: "Successfully created privilege" }); + } else { + if (!projectId || !privilegeDetails?.id) return; + await updateIdentityProjectAdditionalPrivilege({ + privilegeId: privilegeDetails.id, + permissions: formRolePermission2API(el.permissions), + projectId, + identityId, + slug: el.slug || undefined, + type: accessType + }); + createNotification({ type: "success", text: "Successfully updated privilege" }); + } + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update privilege" }); + } + }; + + const privilegeTemporaryAccess = form.watch("temporaryAccess"); + const isTemporary = privilegeTemporaryAccess?.isTemporary; + const isExpired = + privilegeTemporaryAccess?.isTemporary && + new Date() > new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""); + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + + if (isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + + return ( +
+ +
+ +
+ {isDirty && ( + + )} +
+ + + + + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((permissionSubject) => ( + onNewPolicy(permissionSubject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title} + + ))} + + +
+
+
+
+
Overview
+

+ Additional privileges take precedence over roles when permissions conflict +

+
+
+ ( + + + + )} + /> +
+
+ + +
+ + + + +
+
+ +
+
+ Configure Timed Access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {isTemporary && ( + + )} +
+
+
+
+
+
+
+
+
Policies
+ {(isCreate || !isPending) && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map( + (permissionSubject) => ( + + {renderConditionalComponents(permissionSubject, isDisabled)} + + ) + )} +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx new file mode 100644 index 000000000..ef566be71 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -0,0 +1,255 @@ +import { subject } from "@casl/ability"; +import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries"; + +import { IdentityProjectAdditionalPrivilegeModifySection } from "./IdentityProjectAdditionalPrivilegeModifySection"; + +type Props = { + identityMembershipDetails: IdentityMembership; +}; + +export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDetails }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege", + "modifyPrivilege" + ] as const); + const { permission } = useProjectPermission(); + const identityId = identityMembershipDetails?.identity?.id; + const projectId = identityMembershipDetails?.project?.id; + + const { mutateAsync: deletePrivilege } = useDeleteIdentityProjectAdditionalPrivilege(); + + const { data: identityProjectPrivileges, isPending } = useListIdentityProjectPrivileges({ + identityId: identityMembershipDetails?.identity?.id, + projectId: identityMembershipDetails?.project?.id + }); + + const handlePrivilegeDelete = async () => { + const { id } = popUp?.deletePrivilege?.data as { id: string }; + try { + await deletePrivilege({ + privilegeId: id, + projectId, + identityId + }); + createNotification({ type: "success", text: "Successfully removed the privilege" }); + handlePopUpClose("deletePrivilege"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to delete privilege" }); + } + }; + + return ( +
+ + {popUp?.modifyPrivilege.isOpen ? ( + + handlePopUpClose("modifyPrivilege")} + identityId={identityId} + privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} + isDisabled={permission.cannot( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { + identityId + }) + )} + /> + + ) : ( + +
+

+ Project Additional Privileges +

+ + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed} + > + + + )} + +
+
+ + + + + + + + + + {isPending && ( + + )} + {!isPending && + identityProjectPrivileges?.map((privilegeDetails) => { + const isTemporary = privilegeDetails?.isTemporary; + const isExpired = + privilegeDetails.isTemporary && + new Date() > new Date(privilegeDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (privilegeDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( + { + if (evt.key === "Enter") { + handlePopUpOpen("modifyPrivilege", privilegeDetails); + } + }} + onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} + > + + + + + ); + })} + +
NameDuration +
{privilegeDetails.slug} + + + {text} + + + +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + e.preventDefault(); + handlePopUpOpen("deletePrivilege", { + id: privilegeDetails?.id, + slug: privilegeDetails?.slug + }); + }} + > + + + )} + + + + +
+
+ {!isPending && !identityProjectPrivileges?.length && ( + + )} +
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + onDeleteApproved={() => handlePrivilegeDelete()} + /> +
+ )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/index.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/index.tsx new file mode 100644 index 000000000..94f239c3d --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { IdentityProjectAdditionalPrivilegeSection } from "./IdentityProjectAdditionalPrivilegeSection"; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx new file mode 100644 index 000000000..1fa98628e --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -0,0 +1,238 @@ +import { subject } from "@casl/ability"; +import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { formatProjectRoleName } from "@app/helpers/roles"; +import { usePopUp } from "@app/hooks"; +import { useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { TProjectRole } from "@app/hooks/api/roles/types"; + +import { IdentityRoleModify } from "./IdentityRoleModify"; + +type Props = { + identityMembershipDetails: IdentityMembership; + isMembershipDetailsLoading?: boolean; +}; + +export const IdentityRoleDetailsSection = ({ + identityMembershipDetails, + isMembershipDetailsLoading +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deleteRole", + "modifyRole" + ] as const); + const { mutateAsync: updateIdentityWorkspaceRole } = useUpdateIdentityWorkspaceRole(); + + const handleRoleDelete = async () => { + const { id } = popUp?.deleteRole?.data as TProjectRole; + try { + const updatedRoles = identityMembershipDetails?.roles?.filter((el) => el.id !== id); + await updateIdentityWorkspaceRole({ + workspaceId: currentWorkspace?.id || "", + identityId: identityMembershipDetails.identity.id, + roles: updatedRoles.map( + ({ + role, + customRoleSlug, + isTemporary, + temporaryMode, + temporaryRange, + temporaryAccessStartTime, + temporaryAccessEndTime + }) => ({ + role: role === "custom" ? customRoleSlug : role, + ...(isTemporary + ? { + isTemporary, + temporaryMode, + temporaryRange, + temporaryAccessStartTime, + temporaryAccessEndTime + } + : { + isTemporary + }) + }) + ) + }); + createNotification({ type: "success", text: "Successfully removed role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to delete role" }); + } + }; + + return ( +
+
+

Project Roles

+ + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + + )} + +
+
+ + + + + + + + + + {isMembershipDetailsLoading && ( + + )} + {!isMembershipDetailsLoading && + identityMembershipDetails?.roles?.map((roleDetails) => { + const isTemporary = roleDetails?.isTemporary; + const isExpired = + roleDetails.isTemporary && + new Date() > new Date(roleDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (roleDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(roleDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(roleDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( + + + + + + ); + })} + +
RoleDuration +
+ {roleDetails.role === "custom" + ? roleDetails.customRoleName + : formatProjectRoleName(roleDetails.role)} + + + + {text} + + + +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + > + + + )} + +
+
+ {!isMembershipDetailsLoading && !identityMembershipDetails?.roles?.length && ( + + )} +
+
+ handlePopUpToggle("deleteRole", isOpen)} + onDeleteApproved={() => handleRoleDelete()} + /> + handlePopUpToggle("modifyRole", isOpen)} + > + + + + +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx new file mode 100644 index 000000000..5aa4ffc32 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx @@ -0,0 +1,332 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + identityProjectMembership: IdentityMembership; +}; + +export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isIdentityEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: identityProjectMembership?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateIdentityWorkspaceRole.isPending) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + try { + await updateIdentityWorkspaceRole.mutateAsync({ + workspaceId, + identityId: identityProjectMembership.identity.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + } catch { + createNotification({ text: "Failed to update roles", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure Timed Access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/index.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/index.tsx new file mode 100644 index 000000000..45a444e73 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/index.tsx @@ -0,0 +1 @@ +export { IdentityRoleDetailsSection } from "./IdentityRoleDetailsSection"; diff --git a/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/route.tsx b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/route.tsx new file mode 100644 index 000000000..eeb591164 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/IdentityDetailsByIDPage/route.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage"; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/" +)({ + component: IdentityDetailsByIDPage +}); diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx new file mode 100644 index 000000000..2f104e707 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -0,0 +1,206 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { format } from "date-fns"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; +import { getProjectTitle } from "@app/helpers/project"; +import { usePopUp } from "@app/hooks"; +import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api"; + +import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; +import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; + +export const Page = () => { + const navigate = useNavigate(); + const membershipId = useParams({ + strict: false, + select: (el) => el.membershipId as string + }); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: membershipDetails, isPending: isMembershipDetailsLoading } = + useGetWorkspaceUserDetails(workspaceId, membershipId); + + const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = + useDeleteUserFromWorkspace(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeMember", + "upgradePlan" + ] as const); + + const handleRemoveUser = async () => { + if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return; + + try { + await removeUserFromWorkspace({ + workspaceId: currentWorkspace.id, + usernames: [membershipDetails?.user?.username], + orgId: currentOrg.id + }); + createNotification({ + text: "Successfully removed user from project", + type: "success" + }); + navigate({ + to: `/${currentWorkspace.type}/$projectId/access` as const, + params: { + projectId: currentWorkspace.id + } + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove user from the project", + type: "error" + }); + } + handlePopUpClose("removeMember"); + }; + + if (isMembershipDetailsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ {membershipDetails ? ( + <> +
+
+
+

Project User Access

+
+ + {(isAllowed) => ( + + )} + +
+
+
+
+ Name + {membershipDetails && ( +

+ {membershipDetails.user.firstName || membershipDetails.user.lastName + ? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}` + : "-"} +

+ )} +
+
+ Email + {membershipDetails &&

{membershipDetails?.user?.email}

} +
+
+
+ Joined on{" "} + {membershipDetails?.createdAt && + format(new Date(membershipDetails?.createdAt || ""), "yyyy-MM-dd")} +
+
+
+ + handlePopUpOpen("upgradePlan", { + description: + "You can assign custom roles to members if you upgrade your Infisical plan." + }) + } + /> + + handlePopUpToggle("removeMember", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + + ) : ( + + )} +
+ ); +}; + +export const MemberDetailsByIDPage = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx new file mode 100644 index 000000000..95a7ffa66 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx @@ -0,0 +1,259 @@ +import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useUser +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteProjectUserAdditionalPrivilege, + useListProjectUserPrivileges +} from "@app/hooks/api"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MembershipProjectAdditionalPrivilegeModifySection } from "./MembershipProjectAdditionalPrivilegeModifySection"; + +type Props = { + membershipDetails: TWorkspaceUser; +}; + +export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: Props) => { + const { user } = useUser(); + const userId = user?.id; + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege", + "modifyPrivilege" + ] as const); + const { permission } = useProjectPermission(); + + const { mutateAsync: deletePrivilege } = useDeleteProjectUserAdditionalPrivilege(); + + const { data: userProjectPrivileges, isPending } = useListProjectUserPrivileges( + membershipDetails?.id + ); + + const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id; + + const handlePrivilegeDelete = async () => { + const { id } = popUp?.deletePrivilege?.data as { id: string }; + try { + await deletePrivilege({ + privilegeId: id, + projectMembershipId: membershipDetails.id + }); + createNotification({ type: "success", text: "Successfully removed the privilege" }); + handlePopUpClose("deletePrivilege"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to delete privilege" }); + } + }; + + return ( +
+ + {popUp?.modifyPrivilege.isOpen ? ( + + handlePopUpClose("modifyPrivilege")} + projectMembershipId={membershipDetails?.id} + privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} + isDisabled={ + isOwnProjectMembershipDetails || + permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) + } + /> + + ) : ( + +
+

+ Project Additional Privileges +

+ {userId !== membershipDetails?.user?.id && + membershipDetails?.status !== "invited" && ( + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed} + > + + + )} + + )} +
+
+ + + + + + + + + + {isPending && } + {!isPending && + userProjectPrivileges?.map((privilegeDetails) => { + const isTemporary = privilegeDetails?.isTemporary; + const isExpired = + privilegeDetails.isTemporary && + new Date() > new Date(privilegeDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (privilegeDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( + { + if (evt.key === "Enter") { + handlePopUpOpen("modifyPrivilege", privilegeDetails); + } + }} + onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} + > + + + + + ); + })} + +
NameDuration +
{privilegeDetails.slug} + + + {text} + + + +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + e.preventDefault(); + handlePopUpOpen("deletePrivilege", { + id: privilegeDetails?.id, + slug: privilegeDetails?.slug + }); + }} + > + + + )} + + + + +
+
+ {!isPending && !userProjectPrivileges?.length && ( + + )} +
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + onDeleteApproved={() => handlePrivilegeDelete()} + /> +
+ )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx new file mode 100644 index 000000000..e6ccda691 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -0,0 +1,429 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { + faCaretDown, + faChevronLeft, + faClock, + faPlus, + faSave +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { GeneralPermissionPolicies } from "@app/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies"; +import { PermissionEmptyState } from "@app/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState"; +import { + formRolePermission2API, + isConditionalSubjects, + PROJECT_PERMISSION_OBJECT, + projectRoleFormSchema, + rolePermission2Form +} from "@app/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils"; +import { renderConditionalComponents } from "@app/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + FormLabel, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { + useCreateProjectUserAdditionalPrivilege, + useGetProjectUserPrivilegeDetails, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; + +type Props = { + privilegeId?: string; + projectMembershipId: string; + onGoBack: () => void; + isDisabled?: boolean; +}; + +export const formSchema = z.object({ + slug: z.string().optional(), + temporaryAccess: z + .discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + .default({ isTemporary: false }), + permissions: projectRoleFormSchema.shape.permissions +}); + +type TFormSchema = z.infer; + +export const MembershipProjectAdditionalPrivilegeModifySection = ({ + privilegeId, + onGoBack, + projectMembershipId, + isDisabled +}: Props) => { + const isCreate = !privilegeId; + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: privilegeDetails, isPending } = useGetProjectUserPrivilegeDetails( + privilegeId || "" + ); + + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const form = useForm({ + values: privilegeDetails + ? { + ...privilegeDetails, + permissions: rolePermission2Form(privilegeDetails.permissions), + temporaryAccess: privilegeDetails.isTemporary + ? { + isTemporary: true, + temporaryRange: privilegeDetails.temporaryRange || "", + temporaryAccessEndTime: privilegeDetails.temporaryAccessEndTime || "", + temporaryAccessStartTime: privilegeDetails.temporaryAccessStartTime || "" + } + : { + isTemporary: privilegeDetails.isTemporary + } + } + : undefined, + resolver: zodResolver(formSchema) + }); + + const { + handleSubmit, + formState: { isDirty, isSubmitting } + } = form; + + const { mutateAsync: updateUserProjectAdditionalPrivilege } = + useUpdateProjectUserAdditionalPrivilege(); + const { mutateAsync: createUserProjectAdditionalPrivilege } = + useCreateProjectUserAdditionalPrivilege(); + + const onSubmit = async (el: TFormSchema) => { + const accessType = !el.temporaryAccess.isTemporary + ? { isTemporary: false as const } + : { + isTemporary: true as const, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + + try { + if (isCreate) { + await createUserProjectAdditionalPrivilege({ + permissions: formRolePermission2API(el.permissions), + projectMembershipId, + slug: el.slug || undefined, + type: accessType + }); + createNotification({ type: "success", text: "Successfully created privilege" }); + } else { + if (!projectId || !privilegeDetails?.id) return; + await updateUserProjectAdditionalPrivilege({ + privilegeId: privilegeDetails.id, + permissions: formRolePermission2API(el.permissions), + projectMembershipId, + slug: el.slug || undefined, + type: accessType + }); + createNotification({ type: "success", text: "Successfully updated privilege" }); + } + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update privilege" }); + } + }; + + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + + const privilegeTemporaryAccess = form.watch("temporaryAccess"); + const isTemporary = privilegeTemporaryAccess?.isTemporary; + const isExpired = + privilegeTemporaryAccess?.isTemporary && + new Date() > new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""); + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + + if (isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( +
+ +
+ +
+ {isDirty && ( + + )} +
+ + + + + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + + +
+
+
+
+
Overview
+

+ Additional privileges take precedence over roles when permissions conflict +

+
+
+ ( + + + + )} + /> +
+
+ + +
+ + + + +
+
+ +
+
+ Configure Timed Access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {isTemporary && ( + + )} +
+
+
+
+
+
+
+
+
Policies
+ {(isCreate || !isPending) && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/index.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/index.tsx new file mode 100644 index 000000000..c563ab035 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { MemberProjectAdditionalPrivilegeSection } from "./MemberProjectAdditionalPrivilegeSection"; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx new file mode 100644 index 000000000..5ff78360d --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx @@ -0,0 +1,249 @@ +import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useUser, + useWorkspace +} from "@app/context"; +import { formatProjectRoleName } from "@app/helpers/roles"; +import { usePopUp } from "@app/hooks"; +import { useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { TProjectRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MemberRoleModify } from "./MemberRoleModify"; + +type Props = { + membershipDetails: TWorkspaceUser; + isMembershipDetailsLoading?: boolean; + onOpenUpgradeModal: () => void; +}; + +export const MemberRoleDetailsSection = ({ + membershipDetails, + isMembershipDetailsLoading, + onOpenUpgradeModal +}: Props) => { + const { user } = useUser(); + const userId = user?.id; + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deleteRole", + "modifyRole" + ] as const); + const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole(); + + const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id; + + const handleRoleDelete = async () => { + const { id } = popUp?.deleteRole?.data as TProjectRole; + try { + const updatedRoles = membershipDetails?.roles?.filter((el) => el.id !== id); + await updateUserWorkspaceRole({ + workspaceId: currentWorkspace?.id || "", + roles: updatedRoles.map( + ({ + role, + customRoleSlug, + isTemporary, + temporaryMode, + temporaryRange, + temporaryAccessStartTime, + temporaryAccessEndTime + }) => ({ + role: role === "custom" ? customRoleSlug : role, + ...(isTemporary + ? { + isTemporary, + temporaryMode, + temporaryRange, + temporaryAccessStartTime, + temporaryAccessEndTime + } + : { + isTemporary + }) + }) + ), + membershipId: membershipDetails.id + }); + createNotification({ type: "success", text: "Successfully removed role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to delete role" }); + } + }; + + return ( +
+
+

Project Roles

+ {!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && ( + + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + + )} + + )} +
+
+ + + + + + + + + + {isMembershipDetailsLoading && ( + + )} + {!isMembershipDetailsLoading && + membershipDetails?.roles?.map((roleDetails) => { + const isTemporary = roleDetails?.isTemporary; + const isExpired = + roleDetails.isTemporary && + new Date() > new Date(roleDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (roleDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(roleDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(roleDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( + + + + + + ); + })} + +
RoleDuration +
+ {roleDetails.role === "custom" + ? roleDetails.customRoleName + : formatProjectRoleName(roleDetails.role)} + + + + {text} + + + +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + > + + + )} + +
+
+ {!isMembershipDetailsLoading && !membershipDetails?.roles?.length && ( + + )} +
+
+ handlePopUpToggle("deleteRole", isOpen)} + onDeleteApproved={() => handleRoleDelete()} + /> + handlePopUpToggle("modifyRole", isOpen)} + > + + + + +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx new file mode 100644 index 000000000..3a140ba56 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx @@ -0,0 +1,346 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; + +export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isPending) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId: projectMember.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + } catch { + createNotification({ text: "Failed to update roles", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure Timed Access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/index.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/index.tsx new file mode 100644 index 000000000..cdeb3a958 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/components/MemberRoleDetailsSection/index.tsx @@ -0,0 +1 @@ +export { MemberRoleDetailsSection } from "./MemberRoleDetailsSection"; diff --git a/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/route.tsx b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/route.tsx new file mode 100644 index 000000000..e45a6954a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/MemberDetailsByIDPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { MemberDetailsByIDPage } from "./MemberDetailsByIdPage"; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/" +)({ + component: MemberDetailsByIDPage +}); diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/OverviewPage.tsx new file mode 100644 index 000000000..cd96b7dd9 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -0,0 +1,1167 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { subject } from "@casl/ability"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faAngleDown, + faArrowDown, + faArrowUp, + faFingerprint, + faFolder, + faFolderBlank, + faFolderPlus, + faKey, + faList, + faPlus +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import NavHeader from "@app/components/navigation/NavHeader"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + TFoot, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { + useCreateFolder, + useCreateSecretV3, + useDeleteSecretV3, + useGetImportedSecretsAllEnvs, + useGetWsTags, + useUpdateSecretV3 +} from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; +import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; +import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; +import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; +import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; + +import { FolderForm } from "../secrets.$envSlug/-components/ActionBar/FolderForm"; +import { CreateSecretForm } from "./components/CreateSecretForm"; +import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; +import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; +import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; +import { + SecretNoAccessOverviewTableRow, + SecretOverviewTableRow +} from "./components/SecretOverviewTableRow"; +import { SecretSearchInput } from "./components/SecretSearchInput"; +import { SecretTableResourceCount } from "./components/SecretTableResourceCount"; +import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection"; +import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "secret" +} + +type Filter = { + [key in RowType]: boolean; +}; + +const DEFAULT_FILTER_STATE = { + [RowType.Folder]: true, + [RowType.DynamicSecret]: true, + [RowType.Secret]: true +}; + +export const OverviewPage = () => { + const { t } = useTranslation(); + + const router = useRouter(); + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + const routerSearch = useSearch({ + from: "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/", + select: (el) => ({ + secretPath: el.secretPath, + search: el.search + }) + }); + const [scrollOffset, setScrollOffset] = useState(0); + const [debouncedScrollOffset] = useDebounce(scrollOffset); + const { permission } = useProjectPermission(); + + const { currentWorkspace } = useWorkspace(); + const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; + const workspaceId = currentWorkspace?.id as string; + const projectSlug = currentWorkspace?.slug as string; + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter); + const secretPath = (routerSearch?.secretPath as string) || "/"; + + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); + const [filterHistory, setFilterHistory] = useState< + Map + >(new Map()); + + const [selectedEntries, setSelectedEntries] = useState<{ + // selectedEntries[name/key][envSlug][resource] + [EntryType.FOLDER]: Record>; + [EntryType.SECRET]: Record>; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name); + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); + + useEffect(() => { + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + const unsubscribeRouterEvent = router.subscribe("onLoad", onRouteChangeStart); + + return () => { + unsubscribeRouterEvent(); + }; + }, []); + + const userAvailableEnvs = currentWorkspace?.environments || []; + + const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); + + useEffect(() => { + setVisibleEnvs(userAvailableEnvs); + }, [userAvailableEnvs]); + + const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = + useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (userAvailableEnvs || []).map(({ slug }) => slug) + }); + + const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: visibleEnvs.map((env) => env.slug), + secretPath, + orderDirection, + orderBy, + includeFolders: filter.folder, + includeDynamicSecrets: filter.dynamic, + includeSecrets: filter.secret, + search: debouncedSearchFilter, + limit, + offset + }, + { enabled: isProjectV3 } + ); + + const { + secrets, + folders, + dynamicSecrets, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount, + totalCount = 0, + totalUniqueFoldersInPage, + totalUniqueSecretsInPage, + totalUniqueDynamicSecretsInPage + } = overview ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets); + const { data: tags } = useGetWsTags( + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" + ); + + const { mutateAsync: createSecretV3 } = useCreateSecretV3(); + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolderBatch } = useUpdateFolderBatch(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "addSecretsInAllEnvs", + "addFolder", + "misc", + "updateFolder" + ] as const); + + const handleFolderCreate = async (folderName: string) => { + const promises = userAvailableEnvs.map((env) => { + const environment = env.slug; + return createFolder({ + name: folderName, + path: secretPath, + environment, + projectId: workspaceId + }); + }); + + const results = await Promise.allSettled(promises); + const isFoldersAdded = results.some((result) => result.status === "fulfilled"); + + if (isFoldersAdded) { + handlePopUpClose("addFolder"); + createNotification({ + type: "success", + text: "Successfully created folder" + }); + } else { + createNotification({ + type: "error", + text: "Failed to create folder" + }); + } + }; + + const handleFolderUpdate = async (newFolderName: string) => { + const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; + + const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; + userAvailableEnvs.forEach((env) => { + if ( + permission.can( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) + ) + ) { + const folder = getFolderByNameAndEnv(oldFolderName, env.slug); + if (folder) { + updatedFolders.push({ + environment: env.slug, + name: newFolderName, + id: folder.id, + path: secretPath + }); + } + } + }); + + if (updatedFolders.length === 0) { + createNotification({ + type: "info", + text: "You don't have access to rename selected folder" + }); + + handlePopUpClose("updateFolder"); + return; + } + + try { + await updateFolderBatch({ + projectSlug, + folders: updatedFolders, + projectId: workspaceId + }); + createNotification({ + type: "success", + text: "Successfully renamed folder across environments" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to rename folder across environments" + }); + } finally { + handlePopUpClose("updateFolder"); + } + }; + + const handleSecretCreate = async (env: string, key: string, value: string) => { + try { + // create folder if not existing + if (secretPath !== "/") { + // /hello/world -> [hello","world"] + const pathSegment = secretPath.split("/").filter(Boolean); + const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; + const folderName = pathSegment.at(-1); + if (folderName && parentPath) { + await createFolder({ + projectId: workspaceId, + path: parentPath, + environment: env, + name: folderName + }); + } + } + const result = await createSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue: value, + secretComment: "", + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully created secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create secret" + }); + } + }; + + const handleEnvSelect = (envId: string) => { + if (visibleEnvs.map((env) => env.id).includes(envId)) { + setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId)); + } else { + setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); + } + }; + + const handleSecretUpdate = async ( + env: string, + key: string, + value: string, + type = SecretType.Shared + ) => { + try { + const result = await updateSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue: value, + type + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully updated secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to update secret" + }); + } + }; + + const handleSecretDelete = async (env: string, key: string, secretId?: string) => { + try { + const result = await deleteSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretId, + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully deleted secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to delete secret" + }); + } + }; + + const handleResetSearch = (path: string) => { + const restore = filterHistory.get(path); + setFilter(restore?.filter ?? DEFAULT_FILTER_STATE); + const el = restore?.searchFilter ?? ""; + setSearchFilter(el); + setDebouncedSearchFilter(el); + }; + + const handleFolderClick = (path: string) => { + // store for breadcrumb nav to restore previously used filters + setFilterHistory((prev) => { + const curr = new Map(prev); + curr.set(secretPath, { filter, searchFilter }); + return curr; + }); + + navigate({ + search: (prev) => ({ + ...prev, + secretPath: `${routerSearch?.secretPath || ""}/${path}` + }) + }).then(() => { + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(""); + setDebouncedSearchFilter(""); + }); + }; + + const handleExploreEnvClick = async (slug: string) => { + if (secretPath !== "/") { + const pathSegment = secretPath.split("/").filter(Boolean); + const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; + const folderName = pathSegment.at(-1); + const canCreateFolder = permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.SecretFolders, { + environment: slug, + secretPath: parentPath + }) + ); + if (folderName && parentPath && canCreateFolder) { + await createFolder({ + projectId: workspaceId, + environment: slug, + path: parentPath, + name: folderName + }); + } + } + + const query: Record = { ...routerSearch, env: slug, searchFilter }; + const envIndex = visibleEnvs.findIndex((el) => slug === el.slug); + if (envIndex !== -1) { + navigate({ + to: `/${ProjectType.SecretManager}/$projectId/secrets/$envSlug` as const, + params: { + projectId: workspaceId, + envSlug: slug + }, + search: query + }); + } + }; + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); + + const allRowsSelectedOnPage = useMemo(() => { + if (!secrets?.length && !folders?.length) return { isChecked: false, isIndeterminate: false }; + + if ( + (!secrets?.length || + secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) && + (!folders?.length || + folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name])) + ) + return { isChecked: true, isIndeterminate: false }; + + if ( + secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) || + folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name]) + ) + return { isChecked: true, isIndeterminate: true }; + + return { isChecked: false, isIndeterminate: false }; + }, [selectedEntries, secrets, folders]); + + const toggleSelectedEntry = useCallback( + (type: EntryType, key: string) => { + const isChecked = Boolean(selectedEntries[type]?.[key]); + const newChecks = { ...selectedEntries }; + + // remove selection if its present else add it + if (isChecked) { + delete newChecks[type][key]; + } else { + newChecks[type][key] = {}; + userAvailableEnvs.forEach((env) => { + const resource = + type === EntryType.SECRET + ? getSecretByKey(env.slug, key) + : getFolderByNameAndEnv(key, env.slug); + + if (resource) newChecks[type][key][env.slug] = resource; + }); + } + + setSelectedEntries(newChecks); + }, + [selectedEntries, getFolderByNameAndEnv, getSecretByKey] + ); + + const toggleSelectAllRows = () => { + const newChecks = { ...selectedEntries }; + + userAvailableEnvs.forEach((env) => { + secrets?.forEach((secret) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.SECRET][secret.key]; + } else { + if (!newChecks[EntryType.SECRET][secret.key]) + newChecks[EntryType.SECRET][secret.key] = {}; + + const resource = getSecretByKey(env.slug, secret.key); + + if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource; + } + }); + + folders?.forEach((folder) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.FOLDER][folder.name]; + } else { + if (!newChecks[EntryType.FOLDER][folder.name]) + newChecks[EntryType.FOLDER][folder.name] = {}; + + const resource = getFolderByNameAndEnv(folder.name, env.slug); + + if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource; + } + }); + }); + + setSelectedEntries(newChecks); + }; + + useEffect(() => { + if (routerSearch.search) { + const { search, ...query } = routerSearch; + // temp workaround until we transition state to query params + navigate({ + search: query + }); + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(routerSearch.search as string); + setDebouncedSearchFilter(routerSearch.search as string); + } + }, [routerSearch.search]); + + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { + return ( +
+ loading animation +
+ ); + } + + const canViewOverviewPage = Boolean(userAvailableEnvs.length); + // This is needed to also show imports from other paths – right now those are missing. + // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; + + const isTableEmpty = totalCount === 0; + + const isTableFiltered = + Boolean(Object.values(filter).filter((enabled) => !enabled).length) || + userAvailableEnvs.length !== visibleEnvs.length; + + if (!isProjectV3) + return ( +
+ +
+ ); + + return ( +
+ + {t("common.head-title", { title: t("dashboard.title") })} + + + + + +
+
+ +
+
+
+
+

Secrets Overview

+

+ Inject your secrets using + + Infisical CLI + + , + + Infisical API + + , + + Infisical SDKs + + , and + + more + + . +

+
+
+
+ +
+ {userAvailableEnvs.length > 0 && ( + + + + + + + + + + {/* + + */} + Filter project resources + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
+ Choose visible environments + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; + + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" + > +
{name}
+
+ ); + })} +
+
+ )} + + {userAvailableEnvs.length > 0 && ( +
+ + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
+ + {(isAllowed) => ( + + )} + +
+
+
+
+ )} +
+
+
+ +
+ setScrollOffset(e.currentTarget.scrollLeft)} + className="thin-scrollbar rounded-b-none" + > + + + + + {visibleEnvs?.map(({ name, slug }, index) => { + const envSecKeyCount = getEnvSecretKeyCount(slug); + const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); + const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; + + return ( + + ); + })} + + + + {canViewOverviewPage && isOverviewLoading && ( + + )} + {userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && ( + + + + )} + {userAvailableEnvs.length === 0 && ( + + + + )} + {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && ( + + + + )} + {!isOverviewLoading && visibleEnvs.length > 0 && ( + <> + {folderNames.map((folderName, index) => ( + + toggleSelectedEntry(EntryType.FOLDER, folderName) + } + environments={visibleEnvs} + key={`overview-${folderName}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } + /> + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secKeys.map((key, index) => ( + toggleSelectedEntry(EntryType.SECRET, key)} + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${key}-${index + 1}`} + environments={visibleEnvs} + secretKey={key} + getSecretByKey={getSecretByKey} + scrollOffset={debouncedScrollOffset} + /> + ))} + totalCount ? totalCount % perPage : perPage) - + (totalUniqueFoldersInPage || 0) - + (totalUniqueDynamicSecretsInPage || 0) - + (totalUniqueSecretsInPage || 0), + 0 + )} + /> + + )} + + + + + {visibleEnvs?.map(({ name, slug }) => ( + + ))} + + +
+
+ 0 + ? `${ + !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" + } all folders and secrets on page` + : "" + } + > +
+ +
+
+ Name + + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } + > + + +
+
+
+ + {missingKeyCount > 0 && ( + +
+ {missingKeyCount} +
+
+ )} +
+
+ +
+ + + + + +
+ + + +
+
+
+
+ +
+
+
+ {!isOverviewLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} +
+
+ handlePopUpToggle("addSecretsInAllEnvs", isOpen)} + > + e.preventDefault()} + > + handlePopUpClose("addSecretsInAllEnvs")} + /> + + + handlePopUpToggle("addFolder", isOpen)} + > + + + + + handlePopUpToggle("updateFolder", isOpen)} + > + + )?.name} + onUpdateFolder={handleFolderUpdate} + /> + + +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx new file mode 100644 index 000000000..aabd7899e --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -0,0 +1,318 @@ +import { ClipboardEvent, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2"; +import { CreatableSelect } from "@app/components/v2/CreatableSelect"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { getKeyValue } from "@app/helpers/parseEnvVar"; +import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; +import { SecretType } from "@app/hooks/api/types"; + +const typeSchema = z + .object({ + key: z.string().trim().min(1, "Key is required"), + value: z.string().optional(), + environments: z.object({ name: z.string(), slug: z.string() }).array(), + tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional() + }) + .refine((data) => data.key !== undefined, { + message: "Please enter secret name" + }); + +type TFormSchema = z.infer; + +type Props = { + secretPath?: string; + // modal props + onClose: () => void; +}; + +export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { + const { + register, + handleSubmit, + control, + reset, + setValue, + watch, + formState: { isSubmitting, errors } + } = useForm({ resolver: zodResolver(typeSchema) }); + + const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); + const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + const workspaceId = currentWorkspace?.id || ""; + const environments = currentWorkspace?.environments || []; + + const { mutateAsync: createSecretV3 } = useCreateSecretV3(); + const { mutateAsync: createFolder } = useCreateFolder(); + const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags( + canReadTags ? workspaceId : "" + ); + + const secretKeyInputRef = useRef(null); + const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key"); + + const secretKey = watch("key"); + + const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => { + const promises = selectedEnv.map(async (env) => { + const environment = env.slug; + // create folder if not existing + if (secretPath !== "/") { + // /hello/world -> [hello","world"] + const pathSegment = secretPath.split("/").filter(Boolean); + const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; + const folderName = pathSegment.at(-1); + const canCreateFolder = permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.SecretFolders, { + environment: env.slug, + secretPath: parentPath + }) + ); + + if (folderName && parentPath && canCreateFolder) { + await createFolder({ + projectId: workspaceId, + path: parentPath, + environment, + name: folderName + }); + } + } + + // TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination + + return { + ...(await createSecretV3({ + environment, + workspaceId, + secretPath, + secretKey: key, + secretValue: value || "", + secretComment: "", + type: SecretType.Shared, + tagIds: tags?.map((el) => el.value) + })), + environment + }; + }); + + const results = await Promise.allSettled(promises); + const forApprovalEnvs = results + .map((result) => + result.status === "fulfilled" && "approval" in result.value + ? result.value.environment + : undefined + ) + .filter(Boolean) as string[]; + + const updatedEnvs = results + .map((result) => + result.status === "fulfilled" && !("approval" in result.value) + ? result.value.environment + : undefined + ) + .filter(Boolean) as string[]; + + if (forApprovalEnvs.length) { + createNotification({ + type: "info", + text: `Change request submitted for ${ + forApprovalEnvs.length > 1 ? "environments" : "environment" + }: ${forApprovalEnvs.join(", ")}` + }); + } + + if (updatedEnvs.length) { + createNotification({ + type: "success", + text: `Secrets created in ${ + updatedEnvs.length > 1 ? "environments" : "environment" + }: ${updatedEnvs.join(", ")}` + }); + } + + if (!updatedEnvs.length && !forApprovalEnvs.length) { + createNotification({ + type: "error", + text: "Failed to create secrets" + }); + } else { + onClose(); + reset(); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + const delimitters = [":", "="]; + const pastedContent = e.clipboardData.getData("text"); + const { key, value } = getKeyValue(pastedContent, delimitters); + + const isWholeKeyHighlighted = + secretKeyInputRef.current && + secretKeyInputRef.current.selectionStart === 0 && + secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length; + + if (!secretKey || isWholeKeyHighlighted) { + e.preventDefault(); + + setValue("key", key); + if (value) { + setValue("value", value); + } + } + }; + + const createWsTag = useCreateWsTag(); + const slugSchema = z.string().trim().toLowerCase().min(1); + const createNewTag = async (slug: string) => { + // TODO: Replace with slugSchema generic + try { + const parsedSlug = slugSchema.parse(slug); + await createWsTag.mutateAsync({ + workspaceID: workspaceId, + tagSlug: parsedSlug, + tagColor: "" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to create new tag" + }); + } + }; + + return ( +
+ + { + setSecretKeyHookRef(e); + // @ts-expect-error this is for multiple ref single component + secretKeyInputRef.current = e; + }} + placeholder="Type your secret name" + onPaste={handlePaste} + autoCapitalization={currentWorkspace?.autoCapitalization} + /> + + ( + + + + )} + /> + ( + + + You do not have permission to read tags. +
+ ) : ( + "" + ) + } + > + slugSchema.safeParse(v).success} + name="tagIds" + isDisabled={!canReadTags} + isLoading={isTagsLoading && canReadTags} + options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))} + value={field.value} + onChange={field.onChange} + onCreateOption={createNewTag} + /> + + )} + /> + ( + + + permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: environment.slug, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ) + )} + value={value} + onChange={onChange} + placeholder="Select environments to create secret in..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> + + )} + name="environments" + /> +
+ + +
+ + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/index.tsx new file mode 100644 index 000000000..a7851ed77 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/index.tsx @@ -0,0 +1 @@ +export { CreateSecretForm } from "./CreateSecretForm"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx new file mode 100644 index 000000000..8f6908142 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx @@ -0,0 +1,53 @@ +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +type Props = { + secretPath: string; + onResetSearch: (path: string) => void; +}; + +export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => { + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + + const onFolderCrumbClick = (index: number) => { + const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`; + if (secretPath === newSecPath) return; + navigate({ + search: (prev) => ({ ...prev, secretPath: newSecPath }) + }).then(() => onResetSearch(newSecPath)); + }; + + return ( +
+
onFolderCrumbClick(0)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + +
+ {(secretPath || "") + .split("/") + .filter(Boolean) + .map((path, index, arr) => ( +
onFolderCrumbClick(index + 1)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + {path} +
+ ))} +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/index.tsx new file mode 100644 index 000000000..8224cdb25 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/FolderBreadCrumbs/index.tsx @@ -0,0 +1 @@ +export { FolderBreadCrumbs } from "./FolderBreadCrumbs"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx new file mode 100644 index 000000000..f814b336b --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx @@ -0,0 +1,50 @@ +import { faCheck, faFingerprint, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Td, Tr } from "@app/components/v2"; + +type Props = { + dynamicSecretName: string; + environments: { name: string; slug: string }[]; + isDynamicSecretInEnv: (name: string, env: string) => boolean; +}; + +export const SecretOverviewDynamicSecretRow = ({ + dynamicSecretName, + environments = [], + isDynamicSecretInEnv +}: Props) => { + return ( + + +
+
+ +
+
{dynamicSecretName}
+
+ + {environments.map(({ slug }, i) => { + const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug); + + return ( + +
+ +
+ + ); + })} + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/index.tsx new file mode 100644 index 000000000..f2375625b --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewDynamicSecretRow/index.tsx @@ -0,0 +1 @@ +export { SecretOverviewDynamicSecretRow } from "./SecretOverviewDynamicSecretRow"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx new file mode 100644 index 000000000..2625d14c6 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -0,0 +1,84 @@ +import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, IconButton, Td, Tr } from "@app/components/v2"; + +type Props = { + folderName: string; + environments: { name: string; slug: string }[]; + isFolderPresentInEnv: (name: string, env: string) => boolean; + onClick: (path: string) => void; + isSelected: boolean; + onToggleFolderSelect: (folderName: string) => void; + onToggleFolderEdit: (name: string) => void; +}; + +export const SecretOverviewFolderRow = ({ + folderName, + environments = [], + isFolderPresentInEnv, + isSelected, + onToggleFolderSelect, + onToggleFolderEdit, + onClick +}: Props) => { + return ( + onClick(folderName)}> + +
+
+ { + onToggleFolderSelect(folderName); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> + +
+
{folderName}
+ { + onToggleFolderEdit(folderName); + e.stopPropagation(); + }} + > + + +
+ + {environments.map(({ slug }, i) => { + const isPresent = isFolderPresentInEnv(folderName, slug); + + return ( + +
+ +
+ + ); + })} + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/index.tsx new file mode 100644 index 000000000..3b2b00ded --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/index.tsx @@ -0,0 +1 @@ +export { SecretOverviewFolderRow } from "./SecretOverviewFolderRow"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx new file mode 100644 index 000000000..d3dfd49e7 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -0,0 +1,295 @@ +import { useCallback, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { + faCheck, + faCopy, + faProjectDiagram, + faTrash, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + hasSecretReference, + SecretReferenceTree +} from "@app/components/secrets/SecretReferenceDetails"; +import { + DeleteActionModal, + IconButton, + Modal, + ModalContent, + ModalTrigger, + Tooltip +} from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { SecretType } from "@app/hooks/api/types"; + +type Props = { + defaultValue?: string | null; + secretName: string; + secretId?: string; + isOverride?: boolean; + isCreatable?: boolean; + isVisible?: boolean; + isImportedSecret: boolean; + environment: string; + secretPath: string; + onSecretCreate: (env: string, key: string, value: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; +}; + +export const SecretEditRow = ({ + defaultValue, + isCreatable, + isOverride, + isImportedSecret, + onSecretUpdate, + secretName, + onSecretCreate, + onSecretDelete, + environment, + secretPath, + isVisible, + secretId +}: Props) => { + const { + handleSubmit, + control, + reset, + getValues, + formState: { isDirty, isSubmitting } + } = useForm({ + values: { + value: defaultValue || null + } + }); + const [isDeleting, setIsDeleting] = useToggle(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const toggleModal = useCallback(() => { + setIsModalOpen((prev) => !prev); + }, []); + + const handleFormReset = () => { + reset(); + }; + + const handleCopySecretToClipboard = async () => { + const { value } = getValues(); + if (value) { + try { + await window.navigator.clipboard.writeText(value); + createNotification({ type: "success", text: "Copied secret to clipboard" }); + } catch (error) { + console.log(error); + createNotification({ type: "error", text: "Failed to copy secret to clipboard" }); + } + } + }; + + const handleFormSubmit = async ({ value }: { value?: string | null }) => { + if ((value || value === "") && secretName) { + if (isCreatable) { + await onSecretCreate(environment, secretName, value); + } else { + await onSecretUpdate( + environment, + secretName, + value, + isOverride ? SecretType.Personal : SecretType.Shared, + secretId + ); + } + } + reset({ value }); + }; + + const handleDeleteSecret = useCallback(async () => { + setIsDeleting.on(); + setIsModalOpen(false); + + try { + await onSecretDelete(environment, secretName, secretId); + reset({ value: null }); + } finally { + setIsDeleting.off(); + } + }, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]); + + return ( +
+ + +
+ ( + + )} + /> +
+
+ {isDirty ? ( + <> + + {(isAllowed) => ( +
+ + + + + +
+ )} +
+
+ + + + + +
+ + ) : ( + <> +
+ + + + + +
+ + {(isAllowed) => ( +
+ + +
+ + + + + +
+
+ e.preventDefault()} // prevents secret input from displaying value on open + > + + +
+
+ )} +
+ + {(isAllowed) => ( +
+ + + + + +
+ )} +
+ + )} +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx new file mode 100644 index 000000000..c6c32ea2e --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx @@ -0,0 +1,51 @@ +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; + +type Props = { + environments: { name: string; slug: string }[]; + count: number; +}; + +export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Props) => { + return ( + <> + {Array.from(Array(count)).map((_, j) => ( + + +
+ +
+
+ +
+
NO ACCESS
+
+
+
+ + {environments.map(({ slug }, i) => { + return ( + +
+
+ +
+
+ + ); + })} + + ))} + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx new file mode 100644 index 000000000..2ffee7f9c --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -0,0 +1,251 @@ +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faAngleDown, + faCheck, + faEye, + faEyeSlash, + faFileImport, + faKey, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; + +import { SecretEditRow } from "./SecretEditRow"; +import SecretRenameRow from "./SecretRenameRow"; + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + isSelected: boolean; + onToggleSecretSelect: (key: string) => void; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; + onSecretCreate: (env: string, key: string, value: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; + isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; + getImportedSecretByKey: ( + env: string, + secretName: string + ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; + scrollOffset: number; +}; + +export const SecretOverviewTableRow = ({ + secretKey, + environments = [], + secretPath, + getSecretByKey, + onSecretUpdate, + onSecretCreate, + onSecretDelete, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + scrollOffset, + onToggleSecretSelect, + isSelected +}: Props) => { + const [isFormExpanded, setIsFormExpanded] = useToggle(); + const totalCols = environments.length + 1; // secret key row + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + return ( + <> + setIsFormExpanded.toggle()} className="group"> + +
+
+
+ { + onToggleSecretSelect(secretKey); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> + +
+
{secretKey}
+
+
+ + {environments.map(({ slug }, i) => { + const secret = getSecretByKey(slug, secretKey); + + const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); + + const isSecretPresent = Boolean(secret); + const isSecretEmpty = secret?.value === ""; + return ( + +
+
+ {!isSecretEmpty && ( + + + + )} + {isSecretEmpty && ( + + + + )} +
+
+ + ); + })} + + {isFormExpanded && ( + + +
+ + + + + + + +
+ +
+ + + + {environments.map(({ name, slug }) => { + const secret = getSecretByKey(slug, secretKey); + const isCreatable = !secret; + + const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey); + const importedSecret = getImportedSecretByKey(slug, secretKey); + + return ( + + + + + ); + })} + +
+ Environment + + Value +
+
+ {name} + {isImportedSecret && ( + + + + )} +
+
+ +
+
+
+ + + )} + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx new file mode 100644 index 000000000..2295a963c --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx @@ -0,0 +1,259 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useUpdateSecretV3 } from "@app/hooks/api"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; + +enum SecretActionType { + Created = "created", + Modified = "modified", + Deleted = "deleted" +} + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; +}; + +export const formSchema = z.object({ + key: z.string().trim().min(1, { message: "Secret key is required" }) +}); + +type TFormSchema = z.infer; + +function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) { + const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); + + const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey)); + + const isReadOnly = environments.some((env) => { + const environment = env.slug; + const secretDetails = getSecretByKey(environment, secretKey); + const secretPermissionSubject = subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: secretKey, + secretTags: (secretDetails?.tags || []).map((i) => i.slug) + }); + const isSecretInEnvReadOnly = + permission.can(ProjectPermissionActions.Read, secretPermissionSubject) && + permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject); + if (isSecretInEnvReadOnly) { + return true; + } + return false; + }); + + const isOverriden = secrets.some( + (secret) => + secret?.overrideAction === SecretActionType.Created || + secret?.overrideAction === SecretActionType.Modified + ); + const workspaceId = currentWorkspace?.id || ""; + + const [isSecNameCopied, setIsSecNameCopied] = useToggle(false); + + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + + const { + handleSubmit, + control, + reset, + trigger, + getValues, + formState: { isDirty, isSubmitting, errors } + } = useForm({ + defaultValues: { key: secretKey }, + values: { key: secretKey }, + resolver: zodResolver(formSchema) + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isSecNameCopied) { + timer = setTimeout(() => setIsSecNameCopied.off(), 2000); + } + return () => clearTimeout(timer); + }, [isSecNameCopied]); + + const handleFormSubmit = async (data: TFormSchema) => { + if (!data.key) { + createNotification({ + type: "error", + text: "Secret name cannot be empty" + }); + return; + } + + const promises = secrets + .filter((secret) => !!secret) + .map((secret) => { + if (!secret) return null; + + return updateSecretV3({ + environment: secret?.env, + workspaceId, + secretPath, + secretKey: secret.key, + secretValue: secret.value || "", + type: SecretType.Shared, + tagIds: secret.tags?.map((tag) => tag.id), + secretComment: secret.comment, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderNote: secret.reminderNote, + skipMultilineEncoding: secret.skipMultilineEncoding, + newSecretName: data.key + }); + }); + + await Promise.all(promises) + .then(() => { + createNotification({ + type: "success", + text: "Successfully renamed the secret" + }); + }) + .catch(() => { + createNotification({ + type: "error", + text: "Error renaming the secret" + }); + }); + }; + + const copyTokenToClipboard = () => { + const [key] = getValues(["key"]); + navigator.clipboard.writeText(key as string); + setIsSecNameCopied.on(); + }; + + return ( +
+
+ + Key + + + ( + trigger("key")} + isError={Boolean(error)} + {...field} + className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent" + /> + )} + /> +
+ + {isReadOnly || isOverriden ? ( + Read Only + ) : ( +
+ + {!isDirty ? ( + + + + + + + + ) : ( + + + + {isSubmitting ? ( + + ) : ( + + )} + + + + reset()} + isDisabled={isSubmitting} + > + + + + + )} + +
+ )} +
+ ); +} + +export default SecretRenameRow; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/index.tsx new file mode 100644 index 000000000..5b7f3847f --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/index.tsx @@ -0,0 +1,2 @@ +export { SecretNoAccessOverviewTableRow } from "./SecretNoAccessOverviewTableRow"; +export { SecretOverviewTableRow } from "./SecretOverviewTableRow"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/SecretSearchInput.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/SecretSearchInput.tsx new file mode 100644 index 000000000..9c8562e06 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/SecretSearchInput.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Combobox, Transition } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, Tooltip } from "@app/components/v2"; + +import { QuickSearchModal, QuickSearchModalProps } from "./components"; + +type ModalProps = Omit< + QuickSearchModalProps, + "isOpen" | "onClose" | "onOpenChange" | "initialValue" +> & { + value: string; + onChange: (search: string) => void; + className?: string; +}; + +export const SecretSearchInput = ({ + value, + onChange, + className, + isSingleEnv, + ...props +}: ModalProps) => { + const [isOpen, setIsOpen] = useState(false); + const hasSearch = Boolean(value.trim()); + + return ( +
+ + {({ activeIndex }) => ( + <> +
+ + + + +
+ { + if (activeIndex === 0 && e.key === "Enter") setIsOpen(true); + }} + autoComplete="off" + className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pl-2.5 pr-8 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50" + placeholder="Search by secret/folder name..." + value={value} + onChange={(e) => onChange(e.target.value)} + /> + {hasSearch && ( + onChange("")} + className="absolute right-2 text-primary" + ariaLabel="Clear search" + > + + + )} +
+
+ + + setIsOpen(true)} + value={value} + className={({ active }) => + `flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${ + active ? "bg-mineshaft-500" : "" + }` + } + > + + {value.trim() + ? `Search for "${ + value.length > 10 ? `${value.substring(0, 10)}...` : value + }" in all folders` + : "Search in all folders"} + + + + + )} +
+ { + setIsOpen(false); + onChange(""); + }} + {...props} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx new file mode 100644 index 000000000..dcc0b40fa --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx @@ -0,0 +1,61 @@ +import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; + +type Props = { + dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string]; + onClose: () => void; +}; + +export const QuickSearchDynamicSecretItem = ({ + dynamicSecretGroup, + + onClose +}: Props) => { + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + + const [groupDynamicSecret] = dynamicSecretGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: groupDynamicSecret.path, + search: groupDynamicSecret.name + }) + }); + onClose(); + }; + + return ( + + +
+ + + {groupDynamicSecret.name} + + + {" "} + + {reverseTruncate(groupDynamicSecret.path)} + + +
+ + + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchFolderItem.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchFolderItem.tsx new file mode 100644 index 000000000..fa469500b --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchFolderItem.tsx @@ -0,0 +1,45 @@ +import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; + +type Props = { + folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string]; + onClose: () => void; +}; + +export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => { + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + + const [groupFolder] = folderGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ ...prev, secretPath: groupFolder.path }) + }); + onClose(); + }; + + return ( + + + + +
{reverseTruncate(groupFolder.path)}
+
+ + + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx new file mode 100644 index 000000000..f047eab74 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchModal.tsx @@ -0,0 +1,288 @@ +import { useState } from "react"; +import { + faCheckCircle, + faChevronLeft, + faFilter, + faFingerprint, + faFolder, + faKey, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + EmptyState, + IconButton, + Input, + Modal, + ModalContent, + Spinner, + Table, + TableContainer, + Tooltip +} from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; +import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard"; +import { WsTag } from "@app/hooks/api/tags/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +import { RowType } from "../../../../secrets.$envSlug/-components/SecretMainPage.types"; +import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem"; +import { QuickSearchFolderItem } from "./QuickSearchFolderItem"; +import { QuickSearchSecretItem } from "./QuickSearchSecretItem"; + +export type QuickSearchModalProps = { + environments: WorkspaceEnv[]; + projectId: string; + tags?: WsTag[]; + isSingleEnv?: boolean; + initialValue: string; + onClose: () => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder; + +const Content = ({ + environments, + projectId, + onClose, + tags, + initialValue = "", + isSingleEnv +}: Omit) => { + const [search, setSearch] = useState(initialValue); + const [debouncedSearch] = useDebounce(search); + const [filterTags, setFilterTags] = useState>({}); + const [showFilter, setShowFilter] = useState>({ + [RowType.Secret]: true, + [RowType.Folder]: true, + [RowType.DynamicSecret]: true + }); + const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length); + const { data, isLoading } = useGetProjectSecretsQuickSearch( + { + secretPath: "/", + environments: environments.map((env) => env.slug), + projectId, + search: debouncedSearch, + tags: filterTags + }, + { enabled: isEnabled } + ); + + const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {}; + + const isEmpty = + (!showFilter[RowType.Folder] || Object.values(folders).length === 0) && + (!showFilter[RowType.Secret] || Object.values(secrets).length === 0) && + (!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0); + + const handleToggleTag = (tag: string) => { + setFilterTags((prev) => { + const updated = { ...prev }; + if (prev[tag]) delete updated[tag]; + else updated[tag] = true; + return updated; + }); + }; + + const handleToggleShowType = (type: ResourceType) => { + setShowFilter((prev) => ({ + ...prev, + [type]: !prev[type] + })); + }; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={ + + } + /> + + + + !show)) && + "border-primary/50 text-primary" + )} + > + + + + + + Filter By + { + e.preventDefault(); + handleToggleShowType(RowType.Folder); + }} + icon={showFilter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleShowType(RowType.DynamicSecret); + }} + icon={showFilter[RowType.DynamicSecret] && } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleShowType(RowType.Secret); + }} + icon={showFilter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
+ {tags && tags.length > 0 && ( + + } + > + Tags + + + Filter Secrets by Tag(s) + {tags.map(({ id, slug, color }) => ( + { + evt.preventDefault(); + handleToggleTag(slug); + }} + key={id} + icon={filterTags[slug] && } + iconPos="right" + > +
+
+ {slug} +
+ + ))} + + + )} + + +
+ +
+ {/* eslint-disable-next-line no-nested-ternary */} + {isEnabled ? ( + // eslint-disable-next-line no-nested-ternary + isLoading ? ( + + ) : isEmpty ? ( + + ) : ( + + + {showFilter[RowType.Folder] && + Object.entries(folders).map(([key, folderGroup]) => ( + + ))} + {showFilter[RowType.DynamicSecret] && + Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => ( + + ))} + {showFilter[RowType.Secret] && + Object.entries(secrets).map(([key, secretGroup]) => ( + + ))} +
+
+ ) + ) : ( + + )} +
+
+ ); +}; + +export const QuickSearchModal = ({ + isOpen, + isSingleEnv, + onOpenChange, + ...props +}: QuickSearchModalProps) => { + return ( + + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx new file mode 100644 index 000000000..e901a4ff7 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx @@ -0,0 +1,215 @@ +import { + faCheck, + faChevronRight, + faCopy, + faEye, + faFolder, + faKey, + faTags +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { useTimedReset } from "@app/hooks"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +type Props = { + environments: WorkspaceEnv[]; + secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string]; + onClose: () => void; + isSingleEnv?: boolean; + tags: string[]; + search: string; +}; + +export const QuickSearchSecretItem = ({ + secretGroup, + environments, + onClose, + tags, + isSingleEnv, + search +}: Props) => { + const navigate = useNavigate({ from: "/secret-manager/$projectId/overview" }); + const envSlugMap = new Map(environments.map((env) => [env.slug, env])); + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false + }); + + const [groupSecret] = secretGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: groupSecret.path, + search: groupSecret.key, + tags: tags.length ? tags.join(",") : undefined + }) + }); + onClose(); + }; + + const handleCopy = (value: string, env: string) => { + navigator.clipboard.writeText(value); + createNotification({ + type: "info", + title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`, + text: "" + }); + setIsUrlCopied(true); + }; + + const secretGroupTags = secretGroup.flatMap((secret) => secret.tags); + + const tagMatch = + search.trim() && + secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase())); + + return ( + + +
+ + + {groupSecret.key} + + + {" "} + + {reverseTruncate(groupSecret.path ?? "")} + + +
+ + +
+ {tagMatch && ( + + + {tagMatch.slug} + + )} + {isSingleEnv ? ( + { + e.stopPropagation(); + const el = envSlugMap.get(groupSecret.env)?.name; + if (el) { + handleCopy(groupSecret.value!, el); + } + }} + > + + + ) : ( + + + + + + + + Copy Value From... + {secretGroup.map((secret) => ( + { + e.stopPropagation(); + const el = envSlugMap.get(secret.env)?.name; + if (el) { + handleCopy(secret.value!, el); + } + }} + key={secret.id} + > +

{envSlugMap.get(secret.env)?.name}

+
+ ))} +
+
+ )} + + + + + + + + Hover to Reveal... + {secretGroup.map((secret) => ( + { + e.stopPropagation(); + const el = envSlugMap.get(secret.env)?.name; + if (el) { + handleCopy(secret.value!, el); + } + }} + key={secret.id} + > + +
+ {!isSingleEnv && ( + + {envSlugMap.get(secret.env)?.name} + + )} +

+ {secret.value || "EMPTY"} +

+

+ *************************** +

+
+
+
+ ))} +
+
+
+ + + + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/index.tsx new file mode 100644 index 000000000..cc6805274 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/index.tsx @@ -0,0 +1,4 @@ +export * from "./QuickSearchDynamicSecretItem"; +export * from "./QuickSearchFolderItem"; +export * from "./QuickSearchModal"; +export * from "./QuickSearchSecretItem"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/index.tsx new file mode 100644 index 000000000..013b845ea --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/index.tsx @@ -0,0 +1 @@ +export { SecretSearchInput } from "./SecretSearchInput"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx new file mode 100644 index 000000000..4d6be2574 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx @@ -0,0 +1,87 @@ +import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2"; + +type Props = { + folderCount?: number; + importCount?: number; + secretCount?: number; + dynamicSecretCount?: number; +}; + +export const SecretTableResourceCount = ({ + folderCount = 0, + dynamicSecretCount = 0, + secretCount = 0, + importCount = 0 +}: Props) => { + return ( +
+ {importCount > 0 && ( + + Total import count{" "} + (matching filters) +

+ } + > +
+ + {importCount} +
+
+ )} + {folderCount > 0 && ( + + Total folder count{" "} + (matching filters) +

+ } + > +
+ + {folderCount} +
+
+ )} + {dynamicSecretCount > 0 && ( + + Total dynamic secret count{" "} + (matching filters) +

+ } + > +
+ + {dynamicSecretCount} +
+
+ )} + {secretCount > 0 && ( + + Total secret count{" "} + (matching filters) +

+ } + > +
+ + {secretCount} +
+
+ )} +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/index.tsx new file mode 100644 index 000000000..d2b5705fa --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretTableResourceCount/index.tsx @@ -0,0 +1 @@ +export { SecretTableResourceCount } from "./SecretTableResourceCount"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx new file mode 100644 index 000000000..8c0a0fadf --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx @@ -0,0 +1,198 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; + +enum ProjectUpgradeStatus { + InProgress = "IN_PROGRESS", + // Completed -> Will be null if completed. So a completed status is not needed + Failed = "FAILED" +} + +const formSchema = z.object({ + isCLIChecked: z.literal(true), + isOperatorChecked: z.literal(true), + shouldCloseOpenApprovals: z.literal(true) +}); + +export const SecretV2MigrationSection = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const); + const { currentWorkspace } = useWorkspace(); + const queryClient = useQueryClient(); + const { data: workspaceDetails, refetch } = useGetWorkspaceById( + // if v3 no need to fetch + currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "", + { + refetchInterval: + currentWorkspace?.upgradeStatus === ProjectUpgradeStatus.InProgress ? 2000 : false + } + ); + const { membership } = useProjectPermission(); + const migrateProjectToV3 = useMigrateProjectToV3(); + const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); + useEffect(() => { + if (!popUp.migrationInfo.isOpen) { + reset(); + } + }, [popUp.migrationInfo.isOpen]); + + const isProjectUpgraded = workspaceDetails?.version === ProjectVersion.V3; + + useEffect(() => { + if (isProjectUpgraded && migrateProjectToV3.data) { + createNotification({ type: "success", text: "Project upgrade completed successfully" }); + migrateProjectToV3.reset(); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager) + }); + } + }, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]); + + if (isProjectUpgraded || currentWorkspace?.version === ProjectVersion.V3) return null; + + const isUpgrading = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.InProgress; + const didProjectUpgradeFailed = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.Failed; + + const handleMigrationSecretV2 = async () => { + try { + handlePopUpToggle("migrationInfo"); + await migrateProjectToV3.mutateAsync({ workspaceId: currentWorkspace?.id || "" }); + refetch(); + createNotification({ + text: "Project upgrade started", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to upgrade project", + type: "error" + }); + } + }; + + const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin); + return ( +
+ {isUpgrading && ( +
+ +
+
Please wait
+ Upgrading secrets engine... +
+
+ )} +
+ +

Upgrade secrets engine version

+
+

+ Your existing workflows to fetch secrets will continue to work. However, viewing secrets on + the UI requires you to upgrade your project's secrets engine version. +

+

+ Upgrading is free and enables the use of Infisical's new secrets engine, which is 10x + faster and allows you to encrypt secrets with your own KMS provider. +

+

+ The upgrade takes only 1-2 minutes and will not cause any downtime. +

+ + {didProjectUpgradeFailed && ( +

+ + Secrets engine upgrade unsuccessful. For assistance, please contact the Infisical support + team. +

+ )} + handlePopUpToggle("migrationInfo", isOpen)} + > + +
+
+
+ ( + + Infisical CLI version is v0.25.0 or above. + + )} + /> + ( + + Infisical Kubernetes Operator version is v0.7.0 or above. + + )} + /> + ( + + Close/merge all open approval/access requests. + + )} + /> +
+
+ + +
+
+
+
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/index.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/index.tsx new file mode 100644 index 000000000..71c7bdc9c --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SecretV2MigrationSection/index.tsx @@ -0,0 +1 @@ +export { SecretV2MigrationSection } from "./SecretV2MigrationSection"; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx new file mode 100644 index 000000000..b2ea0e917 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -0,0 +1,206 @@ +import { subject } from "@casl/ability"; +import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { + SecretType, + SecretV3RawSanitized, + TDeleteSecretBatchDTO, + TSecretFolder +} from "@app/hooks/api/types"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +type Props = { + secretPath: string; + resetSelectedEntries: () => void; + selectedEntries: { + [EntryType.FOLDER]: Record>; + [EntryType.SECRET]: Record>; + }; +}; + +export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => { + const { permission } = useProjectPermission(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "bulkDeleteEntries" + ] as const); + + const selectedFolderCount = Object.keys(selectedEntries.folder).length; + const selectedKeysCount = Object.keys(selectedEntries.secret).length; + const selectedCount = selectedFolderCount + selectedKeysCount; + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const userAvailableEnvs = currentWorkspace?.environments || []; + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + + const isMultiSelectActive = selectedCount > 0; + + // user should have the ability to delete secrets/folders in at least one of the envs + const shouldShowDelete = userAvailableEnvs.some((env) => + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: env.slug, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ) + ); + + const getDeleteModalTitle = () => { + if (selectedFolderCount > 0 && selectedKeysCount > 0) { + return "Do you want to delete the selected secrets and folders across environments?"; + } + if (selectedKeysCount > 0) { + return "Do you want to delete the selected secrets across environments?"; + } + return "Do you want to delete the selected folders across environments?"; + }; + + const handleBulkDelete = async () => { + let processedEntries = 0; + + const promises = userAvailableEnvs.map(async (env) => { + // additional check: ensure that bulk delete is only executed on envs that user has access to + + if ( + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) + ) + ) { + await Promise.all( + Object.values(selectedEntries.folder).map(async (folderRecord) => { + const folder = folderRecord[env.slug]; + if (folder) { + processedEntries += 1; + await deleteFolder({ + folderId: folder?.id, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + } + }) + ); + } + + const secretsToDelete = Object.values(selectedEntries.secret).reduce( + (accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => { + const entry = secretRecord[env.slug]; + const canDeleteSecret = permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: env.slug, + secretPath, + secretName: entry.key, + secretTags: (entry?.tags || []).map((i) => i.slug) + }) + ); + + if (entry && canDeleteSecret) { + return [ + ...accum, + { + secretKey: entry.key, + type: SecretType.Shared + } + ]; + } + return accum; + }, + [] + ); + + if (secretsToDelete.length > 0) { + processedEntries += secretsToDelete.length; + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment: env.slug, + secrets: secretsToDelete + }); + } + }); + + const results = await Promise.allSettled(promises); + const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); + if (processedEntries === 0) { + handlePopUpClose("bulkDeleteEntries"); + createNotification({ + type: "info", + text: "You don't have access to delete selected items" + }); + } else if (areEntriesDeleted) { + handlePopUpClose("bulkDeleteEntries"); + resetSelectedEntries(); + createNotification({ + type: "success", + text: "Successfully deleted selected secrets and folders" + }); + } else { + createNotification({ + type: "error", + text: "Failed to delete selected secrets and folders" + }); + } + }; + + return ( + <> +
+
+ + + + + +
{selectedCount} Selected
+ {shouldShowDelete && ( + + )} +
+
+ handlePopUpToggle("bulkDeleteEntries", isOpen)} + onDeleteApproved={handleBulkDelete} + /> + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/OverviewPage/route.tsx b/frontend-v2/src/pages/secret-manager/OverviewPage/route.tsx new file mode 100644 index 000000000..c9891f696 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/OverviewPage/route.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { OverviewPage } from "./OverviewPage"; + +const SecretOverviewPageQuerySchema = z.object({ + search: z.string().catch(""), + secretPath: z.string().catch("/") +}); + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/" +)({ + component: OverviewPage, + validateSearch: zodValidator(SecretOverviewPageQuerySchema) +}); diff --git a/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/RoleDetailsBySlugPage.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/RoleDetailsBySlugPage.tsx new file mode 100644 index 000000000..3d2bbc3aa --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/RoleDetailsBySlugPage.tsx @@ -0,0 +1,198 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteProjectRole, useGetProjectRoleBySlug } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { ProjectAccessControlTabs } from "@app/types/project"; + +import { RoleDetailsSection } from "./components/RoleDetailsSection"; +import { RoleModal } from "./components/RoleModal"; +import { RolePermissionsSection } from "./components/RolePermissionsSection"; + +const Page = () => { + const navigate = useNavigate(); + const roleSlug = useParams({ + strict: false, + select: (el) => el.roleSlug + }); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data } = useGetProjectRoleBySlug(projectId, roleSlug as string); + + const { mutateAsync: deleteProjectRole } = useDeleteProjectRole(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); + + const onDeleteRoleSubmit = async () => { + try { + if (!currentWorkspace?.slug || !data?.id) return; + + await deleteProjectRole({ + projectId, + id: data.id + }); + + createNotification({ + text: "Successfully deleted project role", + type: "success" + }); + handlePopUpClose("deleteRole"); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/access` as const, + params: { + projectId + }, + search: { + selectedTab: ProjectAccessControlTabs.Roles + } + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to delete project role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return ( +
+ {data && ( +
+ +
+

{data.name}

+ {isCustomRole && ( + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("role", { + roleSlug + }) + } + disabled={!isAllowed} + > + Edit Role + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteRole")} + disabled={!isAllowed} + > + Delete Role + + )} + + +
+ )} +
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteRole", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onDeleteRoleSubmit()} + /> +
+ ); +}; + +export const RoleDetailsBySlugPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + + + ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionConditions.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionConditions.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/IdentityManagementPermissionConditions.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/IdentityManagementPermissionConditions.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/NewPermissionRule.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/NewPermissionRule.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/PermissionConditionHelpers.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/PermissionConditionHelpers.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/PermissionConditionHelpers.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/PermissionConditionHelpers.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/PermissionEmptyState.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/PermissionEmptyState.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx diff --git a/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleDetailsSection.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleDetailsSection.tsx new file mode 100644 index 000000000..47a33dd4f --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleDetailsSection.tsx @@ -0,0 +1,95 @@ +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?: object) => void; +}; + +export const RoleDetailsSection = ({ roleSlug, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentWorkspace } = useWorkspace(); + const { data } = useGetProjectRoleBySlug(currentWorkspace?.id ?? "", roleSlug as string); + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return data ? ( +
+
+

Project Role Details

+ {isCustomRole && ( + + {(isAllowed) => { + return ( + + { + handlePopUpOpen("role", { + roleSlug + }); + }} + > + + + + ); + }} + + )} +
+
+
+

Role ID

+
+

{data.id}

+
+ + { + navigator.clipboard.writeText(data.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{data.name}

+
+
+

Slug

+

{data.slug}

+
+
+

Description

+

+ {data.description?.length ? data.description : "-"} +

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleModal.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleModal.tsx new file mode 100644 index 000000000..25e33aed4 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RoleModal.tsx @@ -0,0 +1,204 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; + +const schema = z + .object({ + name: z.string(), + description: z.string(), + slug: slugSchema({ min: 1 }) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["role"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void; +}; + +export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { + const navigate = useNavigate(); + + const popupData = popUp?.role?.data as { + roleSlug: string; + }; + + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data: role } = useGetProjectRoleBySlug(projectId, popupData?.roleSlug ?? ""); + + const { mutateAsync: createProjectRole } = useCreateProjectRole(); + const { mutateAsync: updateProjectRole } = useUpdateProjectRole(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + description: "" + } + }); + + useEffect(() => { + if (role) { + reset({ + name: role.name, + description: role.description, + slug: role.slug + }); + } else { + reset({ + name: "", + description: "", + slug: "" + }); + } + }, [role]); + + const onFormSubmit = async ({ name, description, slug }: FormData) => { + try { + if (!projectId) return; + + if (role) { + // update + await updateProjectRole({ + id: role.id, + projectId, + name, + description, + slug + }); + + handlePopUpToggle("role", false); + } else { + // create + const newRole = await createProjectRole({ + projectId, + name, + description, + slug, + permissions: [] + }); + + navigate({ + to: `/${currentWorkspace.type}/$projectId/roles/$roleSlug` as const, + params: { + roleSlug: newRole.slug, + projectId + } + }); + handlePopUpToggle("role", false); + } + + createNotification({ + text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? + `Failed to ${popUp?.role?.data ? "update" : "create"} role`; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("role", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/SecretPermissionConditions.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx similarity index 100% rename from frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/SecretPermissionConditions.tsx rename to frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx diff --git a/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/route.tsx b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/route.tsx new file mode 100644 index 000000000..72233471d --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/RoleDetailsBySlugPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage"; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/" +)({ + component: RoleDetailsBySlugPage +}); diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx new file mode 100644 index 000000000..36dbd4c9d --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx @@ -0,0 +1,100 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { useWorkspace } from "@app/context"; +import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; + +import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; +import { ApprovalPolicyList } from "./components/ApprovalPolicyList"; +import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; + +enum TabSection { + SecretApprovalRequests = "approval-requests", + SecretPolicies = "approval-rules", + ResourcePolicies = "resource-rules", + ResourceApprovalRequests = "resource-requests", + Policies = "policies" +} + +export const SecretApprovalsPage = () => { + const { t } = useTranslation(); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ + workspaceId: projectId + }); + const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); + const defaultTab = + (accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0) + ? TabSection.ResourceApprovalRequests + : TabSection.SecretApprovalRequests; + + return ( +
+ + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+
+
+

Approval Workflows

+

+ Create approval policies for any modifications to secrets in sensitive environments + and folders. +

+
+ +
+ + + + Secret Requests + {Boolean(secretApprovalReqCount?.open) && ( + {secretApprovalReqCount?.open} + )} + + + Access Requests + {Boolean(accessApprovalRequestCount?.pendingCount) && ( + {accessApprovalRequestCount?.pendingCount} + )} + + Policies + + + + + + + + + + + +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx new file mode 100644 index 000000000..a84487c21 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -0,0 +1,464 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/jsx-no-useless-fragment */ +import { useMemo, useState } from "react"; +import { + faCheck, + faCheckCircle, + faChevronDown, + faLock, + faPlus +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Tooltip +} from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useUser, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { + accessApprovalKeys, + useGetAccessApprovalPolicies, + useGetAccessApprovalRequests, + useGetAccessRequestsCount +} from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; +import { queryClient } from "@app/hooks/api/reactQuery"; +import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; + +import { RequestAccessModal } from "./components/RequestAccessModal"; +import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; + +const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { + const { isTemporary } = request; + + return ( +
+
+ Requested {isTemporary ? "temporary" : "permanent"} access to{" "} + + {request.policy.secretPath} + + in + + {request.environmentName} + +
+
+ {request.requestedByUserId === userId && ( + + Requested By You + + )} +
+
+ ); +}; + +export const AccessApprovalRequest = ({ + projectSlug, + projectId +}: { + projectSlug: string; + projectId: string; +}) => { + const [selectedRequest, setSelectedRequest] = useState< + | (TAccessApprovalRequest & { + user: TWorkspaceUser["user"] | null; + isRequestedByCurrentUser: boolean; + isApprover: boolean; + }) + | null + >(null); + + const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([ + "requestAccess", + "reviewRequest", + "upgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { user } = useUser(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(projectId, true); + const membersGroupById = members?.reduce>( + (prev, curr) => ({ ...prev, [curr.user.id]: curr }), + {} + ); + + console.log("membersGroupById", membersGroupById); + + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [requestedByFilter, setRequestedByFilter] = useState(undefined); + const [envFilter, setEnvFilter] = useState(undefined); + + const { data: requestCount } = useGetAccessRequestsCount({ + projectSlug + }); + + const { data: policies, isPending: policiesLoading } = useGetAccessApprovalPolicies({ + projectSlug + }); + + const { data: requests } = useGetAccessApprovalRequests({ + projectSlug, + authorProjectMembershipId: requestedByFilter, + envSlug: envFilter + }); + + const filteredRequests = useMemo(() => { + if (statusFilter === "open") + return requests?.filter( + (request) => + !request.policy.deletedAt && + !request.isApproved && + !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); + if (statusFilter === "close") + return requests?.filter( + (request) => + request.policy.deletedAt || + request.isApproved || + request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); + + return requests; + }, [requests, statusFilter, requestedByFilter, envFilter]); + + const generateRequestDetails = (request: TAccessApprovalRequest) => { + const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; + const isRejectedByAnyone = request.reviewers.some( + ({ status }) => status === ApprovalStatus.REJECTED + ); + const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; + const isAccepted = request.isApproved; + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; + const isRequestedByCurrentUser = request.requestedByUserId === user.id; + + const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; + + let displayData: { label: string; type: "primary" | "danger" | "success" } = { + label: "", + type: "primary" + }; + + const isExpired = + request.privilege && + request.isApproved && + new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); + + if (isExpired) displayData = { label: "Access Expired", type: "danger" }; + else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; + else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; + else if (userReviewStatus === ApprovalStatus.APPROVED) { + displayData = { + label: `Pending ${request.policy.approvals - request.reviewers.length} review${ + request.policy.approvals - request.reviewers.length > 1 ? "s" : "" + }`, + type: "primary" + }; + } else if (!isReviewedByUser) + displayData = { + label: "Review Required", + type: "primary" + }; + + return { + displayData, + isReviewedByUser, + isRejectedByAnyone, + isApprover, + userReviewStatus, + isAccepted, + isSoftEnforcement, + isRequestedByCurrentUser + }; + }; + + return ( +
+
+
+ Access Requests +
+ Request access to secrets in sensitive environments and folders. +
+
+
+ + + +
+
+ + + +
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {!!requestCount && requestCount?.pendingCount} Pending +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {!!requestCount && requestCount.finalizedCount} Completed +
+
+ + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user: membershipUser, id }) => ( + + setRequestedByFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={requestedByFilter === id && } + iconPos="right" + > + {membershipUser.username} + + ))} + + + )} +
+
+
+ {filteredRequests?.length === 0 && ( +
+ +
+ )} + {!!filteredRequests?.length && + filteredRequests?.map((request) => { + const details = generateRequestDetails(request); + + return ( +
{ + if ( + (!details.isApprover || + details.isReviewedByUser || + details.isRejectedByAnyone || + details.isAccepted) && + !( + details.isSoftEnforcement && + details.isRequestedByCurrentUser && + !details.isAccepted + ) + ) + return; + if (membersGroupById?.[request.requestedByUserId].user) { + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedByUserId].user, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isApprover: details.isApprover + }); + } + + handlePopUpOpen("reviewRequest"); + }} + onKeyDown={(evt) => { + if ( + !details.isApprover || + details.isAccepted || + details.isReviewedByUser || + details.isRejectedByAnyone + ) + return; + if (evt.key === "Enter") { + if (membersGroupById?.[request.requestedByUserId].user) { + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedByUserId].user, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isApprover: details.isApprover + }); + } + + handlePopUpOpen("reviewRequest"); + } + }} + > +
+
+
+ + {generateRequestText(request, user.id)} +
+
+
+ {membersGroupById?.[request.requestedByUserId]?.user && ( + <> + Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} + ago by{" "} + {membersGroupById?.[request.requestedByUserId]?.user?.firstName}{" "} + {membersGroupById?.[request.requestedByUserId]?.user?.lastName} ( + {membersGroupById?.[request.requestedByUserId]?.user?.email}){" "} + + )} +
+
+ {details.isApprover && ( + + {details.displayData.label} + + )} +
+
+
+
+
+ ); + })} +
+
+
+ + {!!policies && ( + { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + }); + handlePopUpClose("requestAccess"); + }} + /> + )} + + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + }} + /> + )} + + handlePopUpClose("upgradePlan")} + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx new file mode 100644 index 000000000..486ab5be6 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -0,0 +1,26 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; + +import { SpecificPrivilegeSecretForm } from "../../../../access/-components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection"; + +export const RequestAccessModal = ({ + isOpen, + onOpenChange, + policies +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + policies: TAccessApprovalPolicy[]; +}) => { + return ( + + + onOpenChange(false)} policies={policies} /> + + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx new file mode 100644 index 000000000..e2b135f2a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -0,0 +1,183 @@ +import { useCallback, useMemo, useState } from "react"; +import ms from "ms"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { ProjectPermissionActions } from "@app/context"; +import { useReviewAccessRequest } from "@app/hooks/api"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +export const ReviewAccessRequestModal = ({ + isOpen, + onOpenChange, + request, + projectSlug, + selectedRequester, + selectedEnvSlug +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + request: TAccessApprovalRequest & { + user: TWorkspaceUser["user"] | null; + isRequestedByCurrentUser: boolean; + isApprover: boolean; + }; + projectSlug: string; + selectedRequester: string | undefined; + selectedEnvSlug: string | undefined; +}) => { + const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); + const [byPassApproval, setByPassApproval] = useState(false); + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; + + const accessDetails = { + env: request.environmentName, + // secret path will be inside $glob operator + secretPath: request.policy.secretPath, + read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), + edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), + create: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + + temporaryAccess: { + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange + } + }; + + const requestedAccess = useMemo(() => { + const access: string[] = []; + if (accessDetails.read) access.push("Read"); + if (accessDetails.edit) access.push("Edit"); + if (accessDetails.create) access.push("Create"); + if (accessDetails.delete) access.push("Delete"); + + return access.join(", "); + }, [accessDetails]); + + const getAccessLabel = () => { + if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) + return "Permanent"; + + // convert the range to human readable format + ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); + + return ( + + {`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), { + long: true + })} after approval`} + + ); + }; + + const reviewAccessRequest = useReviewAccessRequest(); + + const handleReview = useCallback(async (status: "approved" | "rejected") => { + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + + setIsLoading(null); + onOpenChange(false); + }, []); + + return ( + + +
+ + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + {" "} + is requesting access to the following resource: + + +
+
+ Requested path: + {accessDetails.env + accessDetails.secretPath || ""} +
+ +
+ Permissions: + {requestedAccess} +
+ +
+ Access Type: + {getAccessLabel()} +
+
+ +
+ + +
+ {isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && ( +
+ setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""} + > + + Approve without waiting for requirements to be met (bypass policy protection) + + +
+ )} +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/index.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/index.tsx new file mode 100644 index 000000000..ec0a8d744 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalRequest } from "./AccessApprovalRequest"; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx new file mode 100644 index 000000000..8e03c9974 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -0,0 +1,292 @@ +import { useMemo, useState } from "react"; +import { + faCheckCircle, + faChevronDown, + faFileShield, + faPlus +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + TProjectPermission, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteAccessApprovalPolicy, + useDeleteSecretApprovalPolicy, + useGetSecretApprovalPolicies, + useGetWorkspaceUsers, + useListWorkspaceGroups +} from "@app/hooks/api"; +import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; +import { PolicyType } from "@app/hooks/api/policies/enums"; +import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; + +import { AccessPolicyForm } from "./components/AccessPolicyModal"; +import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow"; + +interface IProps { + workspaceId: string; +} + +const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { + const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( + { + projectSlug: currentWorkspace?.slug as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug + } + } + ); + const { data: secretPolicies, isPending: isSecretPoliciesLoading } = useGetSecretApprovalPolicies( + { + workspaceId: currentWorkspace?.id as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.id + } + } + ); + + // merge data sorted by updatedAt + const policies = [ + ...(accessPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.AccessPolicy })) || + []), + ...(secretPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.ChangePolicy })) || []) + ].sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + + return { + policies, + isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading + }; +}; + +export const ApprovalPolicyList = ({ workspaceId }: IProps) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "policyForm", + "deletePolicy", + "upgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(workspaceId, true); + const { data: groups } = useListWorkspaceGroups(currentWorkspace?.id || ""); + + const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies( + permission, + currentWorkspace + ); + + const [filterType, setFilterType] = useState(null); + + const filteredPolicies = useMemo(() => { + return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; + }, [policies, filterType]); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); + const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy; + if (!currentWorkspace?.slug) return; + + try { + if (policyType === PolicyType.ChangePolicy) { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + } else { + await deleteAccessApprovalPolicy({ + projectSlug: currentWorkspace?.slug, + id + }); + } + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Policies +
+ Implement granular policies for access requests and secrets management. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ + + + + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !filteredPolicies?.length && ( + + + + )} + {!!currentWorkspace && + filteredPolicies?.map((policy) => ( + handlePopUpOpen("policyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
NameEnvironmentSecret PathEligible ApproversEligible Group ApproversApproval Required + + + + + + Select a type + setFilterType(null)} + icon={!filterType && } + iconPos="right" + > + All + + setFilterType(PolicyType.AccessPolicy)} + icon={ + filterType === PolicyType.AccessPolicy && ( + + ) + } + iconPos="right" + > + Access Policy + + setFilterType(PolicyType.ChangePolicy)} + icon={ + filterType === PolicyType.ChangePolicy && ( + + ) + } + iconPos="right" + > + Change Policy + + + + +
+ +
+
+ handlePopUpToggle("policyForm", isOpen)} + members={members} + editValues={popUp.policyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx new file mode 100644 index 000000000..9f5aeec5a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -0,0 +1,457 @@ +import { useEffect, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { policyDetails } from "@app/helpers/policies"; +import { + useCreateSecretApprovalPolicy, + useListWorkspaceGroups, + useUpdateSecretApprovalPolicy +} from "@app/hooks/api"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { ApproverType, TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectId: string; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.object({ slug: z.string(), name: z.string() }), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + userApprovers: z + .object({ type: z.literal(ApproverType.User), id: z.string() }) + .array() + .default([]), + groupApprovers: z + .object({ type: z.literal(ApproverType.Group), id: z.string() }) + .array() + .default([]), + policyType: z.nativeEnum(PolicyType), + enforcementLevel: z.nativeEnum(EnforcementLevel) + }) + .superRefine((data, ctx) => { + if (!(data.groupApprovers.length || data.userApprovers.length)) { + ctx.addIssue({ + path: ["userApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + ctx.addIssue({ + path: ["groupApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + } + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectId, + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues + ? { + ...editValues, + environment: editValues.environment, + userApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [], + groupApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [], + approvals: editValues?.approvals + } + : undefined + }); + const { currentWorkspace } = useWorkspace(); + const { data: groups } = useListWorkspaceGroups(projectId); + + const environments = currentWorkspace?.environments || []; + const isEditMode = Boolean(editValues); + + useEffect(() => { + if (!isOpen || !isEditMode) reset({}); + }, [isOpen, isEditMode]); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const policyName = policyDetails[watch("policyType")]?.name || "Policy"; + + const approversRequired = watch("approvals") || 1; + + const handleCreatePolicy = async ({ + environment, + groupApprovers, + userApprovers, + ...data + }: TFormSchema) => { + if (!projectId) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await createSecretApprovalPolicy({ + ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, + workspaceId: currentWorkspace?.id || "" + }); + } else { + await createAccessApprovalPolicy({ + ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, + projectSlug + }); + } + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async ({ + environment, + userApprovers, + groupApprovers, + ...data + }: TFormSchema) => { + if (!projectId || !projectSlug) return; + if (!editValues?.id) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await updateSecretApprovalPolicy({ + id: editValues?.id, + ...data, + approvers: [...userApprovers, ...groupApprovers], + workspaceId: currentWorkspace?.id || "" + }); + } else { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, + projectSlug + }); + } + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + const memberOptions = useMemo( + () => + members.map((member) => ({ + id: member.user.id, + type: ApproverType.User + })), + [members] + ); + + const groupOptions = useMemo( + () => + groups?.map(({ group }) => ({ + id: group.id, + type: ApproverType.Group + })), + [groups] + ); + + return ( + + +
+
+
+ ( + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + + + + )} + /> + ( + +

+ Determines the level of enforcement for required approvers of a request: +

+

+ Hard enforcement requires at least{" "} + {approversRequired} approver(s) to + approve the request.` +

+

+ Soft enforcement At least{" "} + {approversRequired} approver(s) must + approve the request; however, the requester can bypass approval + requirements in emergencies. +

+ + } + > + +
+ )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + + + )} + /> +
+
+

Approvers

+

+ Select members or groups that are allowed to approve requests from this policy. +

+
+ ( + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + + if (!member) return option.id; + + return getMemberLabel(member); + }} + value={value} + onChange={onChange} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ + +
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx new file mode 100644 index 000000000..865e92994 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -0,0 +1,164 @@ +import { useMemo } from "react"; +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { policyDetails } from "@app/helpers/policies"; +import { Approver } from "@app/hooks/api/accessApproval/types"; +import { TGroupMembership } from "@app/hooks/api/groups/types"; +import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; +import { ApproverType } from "@app/hooks/api/secretApproval/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +interface IPolicy { + id: string; + name: string; + environment: WorkspaceEnv; + projectId?: string; + secretPath?: string; + approvals: number; + approvers?: Approver[]; + updatedAt: Date; + policyType: PolicyType; + enforcementLevel: EnforcementLevel; +} + +type Props = { + policy: IPolicy; + members?: TWorkspaceUser[]; + groups?: TGroupMembership[]; + onEdit: () => void; + onDelete: () => void; +}; + +export const ApprovalPolicyRow = ({ + policy, + members = [], + groups = [], + onEdit, + onDelete +}: Props) => { + const labels = useMemo(() => { + const usersInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id); + + const groupsInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map((approver) => approver.id); + + const memberLabels = usersInPolicy?.length + ? members + .filter((member) => usersInPolicy?.includes(member.user.id)) + .map((member) => getMemberLabel(member)) + .join(", ") + : null; + + const groupLabels = groupsInPolicy?.length + ? groups + .filter(({ group }) => groupsInPolicy?.includes(group.id)) + .map(({ group }) => group.name) + .join(", ") + : null; + + return { + members: memberLabels, + groups: groupLabels + }; + }, [policy, members, groups]); + + return ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + +

{labels.members ?? "-"}

+
+ + + +

{labels.groups ?? "-"}

+
+ + {policy.approvals} + + + {policyDetails[policy.policyType].name} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + onEdit(); + }} + disabled={!isAllowed} + > + Edit Policy + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + disabled={!isAllowed} + > + Delete Policy + + )} + + +
+ + + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/index.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/index.tsx new file mode 100644 index 000000000..2817ec627 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { ApprovalPolicyList } from "./ApprovalPolicyList"; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx new file mode 100644 index 000000000..618ff15f4 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -0,0 +1,291 @@ +import { Fragment, useEffect, useState } from "react"; +import { + faCheck, + faCheckCircle, + faChevronDown, + faCodeBranch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useSearch } from "@tanstack/react-router"; +import { formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Skeleton +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useUser, + useWorkspace +} from "@app/context"; +import { + useGetSecretApprovalRequestCount, + useGetSecretApprovalRequests, + useGetWorkspaceUsers +} from "@app/hooks/api"; +import { ApprovalStatus } from "@app/hooks/api/types"; +import { ProjectType } from "@app/hooks/api/workspace/types"; + +import { + generateCommitText, + SecretApprovalRequestChanges +} from "./components/SecretApprovalRequestChanges"; + +export const SecretApprovalRequest = () => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const [selectedApprovalId, setSelectedApprovalId] = useState(null); + + // filters + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [envFilter, setEnvFilter] = useState(); + const [committerFilter, setCommitterFilter] = useState(); + const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); + + const { + data: secretApprovalRequests, + isFetchingNextPage: isFetchingNextApprovalRequest, + fetchNextPage: fetchNextApprovalRequest, + hasNextPage: hasNextApprovalPage, + isPending: isApprovalRequestLoading, + refetch + } = useGetSecretApprovalRequests({ + workspaceId, + status: statusFilter, + environment: envFilter, + committer: committerFilter + }); + const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = + useGetSecretApprovalRequestCount({ workspaceId }); + const { user: userSession } = useUser(); + const search = useSearch({ + from: `/_authenticate/_ctx-org-details/${ProjectType.SecretManager}/$projectId/_layout-secret-manager/approval/` as const + }); + + const { permission } = useProjectPermission(); + const { data: members } = useGetWorkspaceUsers(workspaceId); + const isSecretApprovalScreen = Boolean(selectedApprovalId); + const { requestId } = search; + + useEffect(() => { + if (!requestId || usingUrlRequestId) return; + + setSelectedApprovalId(requestId as string); + setUsingUrlRequestId(true); + }, [requestId]); + + const handleGoBackSecretRequestDetail = () => { + setSelectedApprovalId(null); + refetch(); + }; + + const isRequestListEmpty = + !isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0; + + return ( + + {isSecretApprovalScreen ? ( + + + + ) : ( + +
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed +
+
+ + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === user.id ? undefined : user.id)) + } + key={`request-filter-member-${id}`} + icon={ + committerFilter === user.id && + } + iconPos="right" + > + {user.username} + + ))} + + + )} +
+
+
+ {isRequestListEmpty && ( +
+ +
+ )} + {secretApprovalRequests?.pages?.map((group, i) => ( + + {group?.map((secretApproval) => { + const { + id: reqId, + commits, + createdAt, + reviewers, + status, + committerUser, + isReplicated: isReplication + } = secretApproval; + const isReviewed = reviewers.some( + ({ status: reviewStatus, userId }) => + userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED + ); + return ( +
setSelectedApprovalId(secretApproval.id)} + onKeyDown={(evt) => { + if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); + }} + > +
+ + {generateCommitText(commits)} + #{secretApproval.slug} +
+ + Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} + {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( + {committerUser?.email}){isReplication && " via replication"} + {!isReviewed && status === "open" && " - Review required"} + +
+ ); + })} +
+ ))} + {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( +
+ {Array.apply(0, Array(3)).map((_x, index) => ( +
+
+ + +
+ +
+ ))} +
+ )} +
+ {hasNextApprovalPage && ( + + )} +
+ )} +
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx new file mode 100644 index 000000000..35b8bf2e0 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import { + faCheck, + faClose, + faLandMineOn, + faLockOpen, + faSquareCheck, + faSquareXmark, + faTriangleExclamation, + faUserLock +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, FormControl, Input } from "@app/components/v2"; +import { + usePerformSecretApprovalRequestMerge, + useUpdateSecretApprovalRequestStatus +} from "@app/hooks/api"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; + +type Props = { + approvalRequestId: string; + hasMerged?: boolean; + isMergable?: boolean; + status: "close" | "open"; + approvals: number; + canApprove?: boolean; + statusChangeByEmail?: string; + workspaceId: string; + enforcementLevel: EnforcementLevel; +}; + +export const SecretApprovalRequestAction = ({ + approvalRequestId, + hasMerged, + status, + isMergable, + approvals, + statusChangeByEmail, + workspaceId, + enforcementLevel, + canApprove +}: Props) => { + const { mutateAsync: performSecretApprovalMerge, isPending: isMerging } = + usePerformSecretApprovalRequestMerge(); + + const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } = + useUpdateSecretApprovalRequestStatus(); + + const [byPassApproval, setByPassApproval] = useState(false); + const [bypassReason, setBypassReason] = useState(""); + + const isValidBypassReason = (value: string) => { + const trimmedValue = value.trim(); + return trimmedValue.length >= 10; + }; + + const handleSecretApprovalRequestMerge = async () => { + try { + await performSecretApprovalMerge({ + id: approvalRequestId, + workspaceId, + bypassReason: byPassApproval ? bypassReason : undefined + }); + createNotification({ + type: "success", + text: "Successfully merged the request" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + const handleSecretApprovalStatusChange = async (reqState: "open" | "close") => { + try { + await updateSecretStatusChange({ + id: approvalRequestId, + status: reqState, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated the request" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + const isSoftEnforcement = enforcementLevel === EnforcementLevel.Soft; + + if (!hasMerged && status === "open") { + return ( +
+
+ + + {isMergable ? "Good to merge" : "Review required"} + + At least {approvals} approving review required + {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + + {isSoftEnforcement && !isMergable && ( +
+ setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + byPassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Merge without waiting for approval (bypass secret change policy) + + + {byPassApproval && ( + + setBypassReason(e.target.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )} +
+
+
+ {canApprove || isSoftEnforcement ? ( + <> + + + + ) : ( +
Only approvers can merge
+ )} +
+
+ ); + } + + if (hasMerged && status === "close") + return ( +
+
+ + + Secret approval merged + + Merged by {statusChangeByEmail} + + +
+
+ ); + + return ( +
+
+ + + Secret approval has been closed + + Closed by {statusChangeByEmail} + + +
+
+ +
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx new file mode 100644 index 000000000..b663109a8 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -0,0 +1,183 @@ +import { faExclamationTriangle, faInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + SecretInput, + Table, + TableContainer, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types"; + +export type Props = { + op: CommitType; + secretVersion?: SecretV3Raw; + newVersion?: Omit & { tags?: WsTag[] }; + presentSecretVersionNumber: number; + hasMerged?: boolean; + conflicts: Array<{ secretId: string; op: CommitType }>; +}; + +const generateItemTitle = (op: CommitType) => { + let text = { label: "", color: "" }; + if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" }; + else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" }; + else text = { label: "deletion", color: "#b91c1c" }; + + return ( + + Request for secret {text.label} + + ); +}; + +const generateConflictText = (op: CommitType) => { + if (op === CommitType.CREATE) return
Secret already exist
; + if (op === CommitType.UPDATE) return
Secret not found
; + return null; +}; + +export const SecretApprovalRequestChangeItem = ({ + op, + secretVersion, + newVersion, + presentSecretVersionNumber, + hasMerged, + conflicts +}: Props) => { + // meaning request has changed + const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber; + const itemConflict = + hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?.id); + const hasConflict = Boolean(itemConflict); + + return ( +
+
+
{generateItemTitle(op)}
+ {!hasMerged && isStale && ( +
+ + Secret has been changed(stale) +
+ )} + {hasMerged && hasConflict && ( +
+ + + +
{generateConflictText(op)}
+
+ )} +
+ + + + + {op === CommitType.UPDATE && + + + + + + {op === CommitType.UPDATE ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + + + + )} +
} + SecretValueCommentTags
OLD{secretVersion?.secretKey} + + {secretVersion?.secretComment} + {secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + ))} +
NEW{newVersion?.secretKey} + + {newVersion?.secretComment} + {newVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + ))} +
+ {op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey} + + + + {op === CommitType.CREATE + ? newVersion?.secretComment + : secretVersion?.secretComment} + + {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( + ({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + ) + )} +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx new file mode 100644 index 000000000..04bee3042 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -0,0 +1,317 @@ +import { ReactNode } from "react"; +import { + faArrowLeft, + faCheck, + faCheckCircle, + faCircle, + faCodeBranch, + faFolder, + faXmarkCircle +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2"; +import { useUser } from "@app/context"; +import { + useGetSecretApprovalRequestDetails, + useUpdateSecretApprovalReviewStatus +} from "@app/hooks/api"; +import { ApprovalStatus, CommitType } from "@app/hooks/api/types"; +import { formatReservedPaths } from "@app/lib/fn/string"; + +import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; +import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; + +export const generateCommitText = (commits: { op: CommitType }[] = []) => { + const score: Record = {}; + commits.forEach(({ op }) => { + score[op] = (score?.[op] || 0) + 1; + }); + const text: ReactNode[] = []; + if (score[CommitType.CREATE]) + text.push( + + {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} + created + + ); + if (score[CommitType.UPDATE]) + text.push( + + {Boolean(text.length) && ","} + {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + + {" "} + updated + + + ); + if (score[CommitType.DELETE]) + text.push( + + {Boolean(text.length) && "and"} + {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + deleted + + ); + + return text; +}; + +const getReviewedStatusSymbol = (status?: ApprovalStatus) => { + if (status === ApprovalStatus.APPROVED) + return ; + if (status === ApprovalStatus.REJECTED) + return ; + return ; +}; + +type Props = { + workspaceId: string; + approvalRequestId: string; + onGoBack: () => void; +}; + +export const SecretApprovalRequestChanges = ({ + approvalRequestId, + onGoBack, + workspaceId +}: Props) => { + const { user: userSession } = useUser(); + const { + data: secretApprovalRequestDetails, + isSuccess: isSecretApprovalRequestSuccess, + isPending: isSecretApprovalRequestLoading + } = useGetSecretApprovalRequestDetails({ + id: approvalRequestId + }); + + const { + mutateAsync: updateSecretApprovalRequestStatus, + isPending: isUpdatingRequestStatus, + variables + } = useUpdateSecretApprovalReviewStatus(); + + const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; + const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; + + // membership of present user + const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some( + ({ userId }) => userId === userSession.id + ); + const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce< + Record + >( + (prev, curr) => ({ + ...prev, + [curr.userId]: curr.status + }), + {} + ); + const hasApproved = reviewedUsers?.[userSession.id] === ApprovalStatus.APPROVED; + const hasRejected = reviewedUsers?.[userSession.id] === ApprovalStatus.REJECTED; + + const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => { + try { + await updateSecretApprovalRequestStatus({ + id: approvalRequestId, + status + }); + createNotification({ + type: "success", + text: `Successfully ${status} the request` + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update the request status" + }); + } + }; + + if (isSecretApprovalRequestLoading) { + return ( +
+ +
+ ); + } + + if (!isSecretApprovalRequestSuccess) + return ( +
+ +
+ ); + + const isMergable = + secretApprovalRequestDetails?.policy?.approvals <= + secretApprovalRequestDetails?.policy?.approvers?.filter( + ({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED + ).length; + const hasMerged = secretApprovalRequestDetails?.hasMerged; + + return ( +
+
+
+ + + +
+ + + {secretApprovalRequestDetails.status === "close" + ? "closed" + : secretApprovalRequestDetails.status} + +
+
+
+ {generateCommitText(secretApprovalRequestDetails.commits)} + {secretApprovalRequestDetails.isReplicated && ( + (replication) + )} +
+
+ {secretApprovalRequestDetails?.committerUser?.firstName || ""} + {secretApprovalRequestDetails?.committerUser?.lastName || ""} ( + {secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "} + {secretApprovalRequestDetails.commits.length} secret values in + + {secretApprovalRequestDetails.environment} + +
+
+ +
+ +
+ {formatReservedPaths(secretApprovalRequestDetails.secretPath)} +
+
+
+
+
+ {!hasMerged && secretApprovalRequestDetails.status === "open" && ( + <> + + + + )} +
+
+ {secretApprovalRequestDetails.commits.map( + ({ op, secretVersion, secret, ...newVersion }, index) => ( + + ) + )} +
+
+ +
+
+
+
Reviewers
+
+ {secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => { + const status = reviewedUsers?.[requiredApprover.userId]; + return ( +
+
+ + {requiredApprover?.email} + + * +
+
+ + {getReviewedStatusSymbol(status)} + +
+
+ ); + })} + {secretApprovalRequestDetails?.reviewers + .filter( + (reviewer) => + !secretApprovalRequestDetails?.policy?.approvers?.some( + ({ userId }) => userId === reviewer.userId + ) + ) + .map((reviewer) => { + const status = reviewedUsers?.[reviewer.userId]; + return ( +
+
+ + {reviewer?.email} + + * +
+
+ + {getReviewedStatusSymbol(status)} + +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/index.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/index.tsx new file mode 100644 index 000000000..5752309ca --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalRequest } from "./SecretApprovalRequest"; diff --git a/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/route.tsx b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/route.tsx new file mode 100644 index 000000000..1aff87f37 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretApprovalsPage/route.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { SecretApprovalsPage } from "./SecretApprovalsPage"; + +const SecretApprovalPageQueryParams = z.object({ + requestId: z.string().catch("") +}); + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/" +)({ + component: SecretApprovalsPage, + validateSearch: zodValidator(SecretApprovalPageQueryParams) +}); diff --git a/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx new file mode 100644 index 000000000..6fa659c5f --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -0,0 +1,618 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { subject } from "@casl/ability"; +import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import NavHeader from "@app/components/navigation/NavHeader"; +import { PermissionDeniedBanner } from "@app/components/permissions"; +import { + Checkbox, + ContentLoader, + Modal, + ModalContent, + Pagination, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { + useGetImportedSecretsSingleEnv, + useGetSecretApprovalPolicyOfABoard, + useGetWorkspaceSnapshotList, + useGetWsSnapshotCount, + useGetWsTags +} from "@app/hooks/api"; +import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; + +import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; +import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection"; +import { ActionBar } from "./components/ActionBar"; +import { CreateSecretForm } from "./components/CreateSecretForm"; +import { DynamicSecretListView } from "./components/DynamicSecretListView"; +import { FolderListView } from "./components/FolderListView"; +import { PitDrawer } from "./components/PitDrawer"; +import { SecretDropzone } from "./components/SecretDropzone"; +import { SecretImportListView } from "./components/SecretImportListView"; +import { SecretListView, SecretNoAccessListView } from "./components/SecretListView"; +import { SnapshotView } from "./components/SnapshotView"; +import { + PopUpNames, + StoreProvider, + usePopUpAction, + usePopUpState, + useSelectedSecretActions, + useSelectedSecrets +} from "./SecretMainPage.store"; +import { Filter, RowType } from "./SecretMainPage.types"; + +const LOADER_TEXT = [ + "Retrieving your encrypted secrets...", + "Fetching folders...", + "Getting secret import links..." +]; + +const Page = () => { + const { t } = useTranslation(); + const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate({ + from: ROUTE_PATHS.SecretDashboardPage.full + }); + const routerQueryParams = useSearch({ + from: ROUTE_PATHS.SecretDashboardPage.id + }); + const environment = useParams({ + from: ROUTE_PATHS.SecretDashboardPage.id, + select: (el) => el.envSlug + }); + + const { permission } = useProjectPermission(); + + const [isVisible, setIsVisible] = useState(false); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name); + + const [snapshotId, setSnapshotId] = useState(null); + const isRollbackMode = Boolean(snapshotId); + const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const); + + // env slug + const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + const secretPath = (routerQueryParams.secretPath as string) || "/"; + const canReadSecret = permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ); + + const canReadSecretImports = permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ); + + const canReadDynamicSecret = permission.can( + ProjectPermissionDynamicSecretActions.ReadRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) + ); + + const canDoReadRollback = permission.can( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretRollback + ); + + const defaultFilterState = { + tags: {}, + searchFilter: (routerQueryParams.searchFilter as string) || "", + // these should always be on by default for the UI, they will be disabled for the query below based off permissions + include: { + [RowType.Folder]: true, + [RowType.Import]: true, + [RowType.DynamicSecret]: true, + [RowType.Secret]: true + } + }; + + const [filter, setFilter] = useState(defaultFilterState); + const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter); + const [filterHistory, setFilterHistory] = useState>(new Map()); + + const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm); + const { togglePopUp } = usePopUpAction(); + + // TODO(rbr): move this to before load + // useEffect(() => { + // if (!currentWorkspace?.environments.find((env) => env.slug === environment)) { + // navigate(`/${ProjectType.SecretManager}/${workspaceId}/secrets/overview`); + // createNotification({ + // text: "No environment found with given slug", + // type: "error" + // }); + // } + // }, [currentWorkspace, environment]); + + const { + data, + isPending: isDetailsLoading, + isFetching: isDetailsFetching + } = useGetProjectSecretsDetails({ + environment, + projectId: workspaceId, + secretPath, + offset, + limit, + orderBy, + search: debouncedSearchFilter, + orderDirection, + includeImports: canReadSecretImports && filter.include.import, + includeFolders: filter.include.folder, + includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic, + includeSecrets: canReadSecret && filter.include.secret, + tags: filter.tags + }); + + const { + imports, + folders, + dynamicSecrets, + secrets, + totalImportCount = 0, + totalFolderCount = 0, + totalDynamicSecretCount = 0, + totalSecretCount = 0, + totalCount = 0 + } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + // fetch imported secrets to show user the overriden ones + const { data: importedSecrets } = useGetImportedSecretsSingleEnv({ + projectId: workspaceId, + environment, + path: secretPath, + options: { + enabled: canReadSecret + } + }); + + // fetch tags + const { data: tags } = useGetWsTags( + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" + ); + + const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ + workspaceId, + environment, + secretPath + }); + const isProtectedBranch = Boolean(boardPolicy); + + const { + data: snapshotList, + isFetchingNextPage: isFetchingNextSnapshotList, + fetchNextPage: fetchNextSnapshotList, + hasNextPage: hasNextSnapshotListPage + } = useGetWorkspaceSnapshotList({ + workspaceId, + directory: secretPath, + environment, + isPaused: !popUp.snapshots.isOpen || !canDoReadRollback, + limit: 10 + }); + + const { data: snapshotCount, isPending: isSnapshotCountLoading } = useGetWsSnapshotCount({ + workspaceId, + environment, + directory: secretPath, + isPaused: !canDoReadRollback + }); + + const noAccessSecretCount = Math.max( + (page * perPage > totalCount ? totalCount % perPage : perPage) - + (imports?.length || 0) - + (folders?.length || 0) - + (secrets?.length || 0) - + (dynamicSecrets?.length || 0), + 0 + ); + const isNotEmpty = Boolean( + secrets?.length || + folders?.length || + imports?.length || + dynamicSecrets?.length || + noAccessSecretCount + ); + + const handleSortToggle = () => + setOrderDirection((state) => + state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC + ); + + const handleEnvChange = (slug: string) => { + navigate({ + search: (state) => { + const newState = { ...state, secretPath: undefined, env: slug }; + return newState; + } + }); + }; + + const handleTagToggle = useCallback( + (tagSlug: string) => + setFilter((state) => { + const isTagPresent = Boolean(state.tags?.[tagSlug]); + const newTagFilter = { ...state.tags }; + if (isTagPresent) delete newTagFilter[tagSlug]; + else newTagFilter[tagSlug] = true; + return { ...state, tags: newTagFilter }; + }), + [] + ); + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + include: { + ...state.include, + [rowType]: !state.include[rowType] + } + }; + }), + [] + ); + + const handleSearchChange = useCallback( + (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), + [] + ); + + const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []); + + // snapshot functions + const handleSelectSnapshot = useCallback((snapId: string) => { + setSnapshotId(snapId); + }, []); + + const handleResetSnapshot = useCallback(() => { + setSnapshotId(null); + handlePopUpClose("snapshots"); + }, []); + + useEffect(() => { + // restore filters for path if set + const restore = filterHistory.get(secretPath); + setFilter(restore ?? defaultFilterState); + setDebouncedSearchFilter(restore?.searchFilter ?? ""); + + // this is a temp work around until we fully transition state to query params, + // setting the initial search filter by query and then moving it to internal state + if (routerQueryParams.searchFilter) { + navigate({ + search: (state) => { + const { searchFilter, ...query } = state; + return query; + } + }); + } + }, [secretPath]); + + useEffect(() => { + if (!routerQueryParams.search && !routerQueryParams.tags) return; + + const queryTags = routerQueryParams.tags + ? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim())) + : []; + const updatedTags: Record = {}; + queryTags.forEach((tag) => { + updatedTags[tag] = true; + }); + + setFilter((prev) => ({ + ...prev, + ...defaultFilterState, + searchFilter: (routerQueryParams.search as string) ?? "", + tags: updatedTags + })); + setDebouncedSearchFilter(routerQueryParams.search as string); + // this is a temp workaround until we fully transition state to query params, + navigate({ + search: (state) => { + const { search, tags: qTags, ...query } = state; + return query; + } + }); + }, [routerQueryParams.search, routerQueryParams.tags]); + + const selectedSecrets = useSelectedSecrets(); + const selectedSecretActions = useSelectedSecretActions(); + + const allRowsSelectedOnPage = useMemo(() => { + if (secrets?.every((secret) => selectedSecrets[secret.id])) + return { isChecked: true, isIndeterminate: false }; + + if (secrets?.some((secret) => selectedSecrets[secret.id])) + return { isChecked: true, isIndeterminate: true }; + + return { isChecked: false, isIndeterminate: false }; + }, [selectedSecrets, secrets]); + + const toggleSelectAllRows = () => { + const newChecks = { ...selectedSecrets }; + + secrets?.forEach((secret) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[secret.id]; + } else { + newChecks[secret.id] = secret; + } + }); + + selectedSecretActions.set(newChecks); + }; + + if (isDetailsLoading) { + return ; + } + + const handleResetFilter = () => { + // store for breadcrumb nav to restore previously used filters + setFilterHistory((prev) => { + const curr = new Map(prev); + curr.set(secretPath, filter); + return curr; + }); + + setFilter(defaultFilterState); + setDebouncedSearchFilter(""); + }; + return ( +
+ +
+ +
+ {!isRollbackMode ? ( + <> + handlePopUpToggle("snapshots", true)} + /> +
+
+ {isNotEmpty && ( +
+ 0 + ? `${ + !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" + } all secrets on page` + : "" + } + > +
+ e.stopPropagation()} + isChecked={allRowsSelectedOnPage.isChecked} + isIndeterminate={allRowsSelectedOnPage.isIndeterminate} + onCheckedChange={toggleSelectAllRows} + /> +
+
+
{ + if (evt.key === "Enter") handleSortToggle(); + }} + > + Key + +
+
Value
+
+ )} + {canReadSecretImports && Boolean(imports?.length) && ( + + )} + {Boolean(folders?.length) && ( + + )} + {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( + + )} + {canReadSecret && Boolean(secrets?.length) && ( + + )} + {canReadSecret && } + {!canReadSecret && + !canReadDynamicSecret && + !canReadSecretImports && + folders?.length === 0 && } +
+
+ {!isDetailsLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + togglePopUp(PopUpNames.CreateSecretForm, state)} + > + + + + + + handlePopUpToggle("snapshots", isOpen)} + hasNextPage={hasNextSnapshotListPage} + fetchNextPage={fetchNextSnapshotList} + onSelectSnapshot={handleSelectSnapshot} + isFetchingNextPage={isFetchingNextSnapshotList} + /> + + ) : ( + handlePopUpToggle("snapshots", true)} + /> + )} +
+ ); +}; + +export const SecretDashboardPage = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("dashboard.title") })} + + + + + +
+ + + +
+ + ); +}; diff --git a/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx new file mode 100644 index 000000000..6a8746d18 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -0,0 +1,100 @@ +import { createContext, ReactNode, useContext, useEffect, useRef } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { createStore, StateCreator, StoreApi, useStore } from "zustand"; + +import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; + +// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state +// This is first step and the whole secret crud will be moved to this global page scope state +// this will allow more stuff like undo grouping stuffs etc +type SelectedSecretState = { + selectedSecret: Record; + action: { + toggle: (secret: SecretV3RawSanitized) => void; + reset: () => void; + set: (secrets: Record) => void; + }; +}; +const createSelectedSecretStore: StateCreator = (set) => ({ + selectedSecret: {}, + action: { + toggle: (secret) => + set((state) => { + const isChecked = Boolean(state.selectedSecret?.[secret.id]); + const newChecks = { ...state.selectedSecret }; + // remove selection if its present else add it + if (isChecked) delete newChecks[secret.id]; + else newChecks[secret.id] = secret; + return { selectedSecret: newChecks }; + }), + reset: () => set({ selectedSecret: {} }), + set: (secrets) => set({ selectedSecret: secrets }) + } +}); + +export enum PopUpNames { + CreateSecretForm = "create-secret-form" +} + +type PopUpState = { + popUp: Record; + popUpActions: { + togglePopUp: (id: PopUpNames, isOpen?: boolean) => void; + closePopUp: (id: PopUpNames) => void; + openPopUp: (id: PopUpNames, data?: any) => void; + }; +}; +const createPopUpStore: StateCreator = (set) => ({ + popUp: {}, + popUpActions: { + closePopUp: (id) => set((state) => ({ popUp: { ...state.popUp, [id]: { isOpen: false } } })), + openPopUp: (id, data) => + set((state) => ({ popUp: { ...state.popUp, [id]: { isOpen: true, data } } })), + togglePopUp: (id, isOpen) => + set((state) => ({ + popUp: { ...state.popUp, [id]: { isOpen: isOpen ?? !state.popUp[id].isOpen } } + })) + } +}); + +type CombinedState = SelectedSecretState & PopUpState; +const StoreContext = createContext | null>(null); +export const StoreProvider = ({ children }: { children: ReactNode }) => { + const storeRef = useRef>(); + const router = useRouter(); + if (!storeRef.current) { + storeRef.current = createStore((...a) => ({ + ...createSelectedSecretStore(...a), + ...createPopUpStore(...a) + })); + } + + useEffect(() => { + const onRouteChangeStart = () => { + const state = storeRef.current?.getState(); + state?.action.reset(); + }; + + const unsubscribe = router.subscribe("onBeforeLoad", onRouteChangeStart); + return () => { + unsubscribe(); + }; + }, []); + + return {children}; +}; + +const useStoreContext = (selector: (state: CombinedState) => T): T => { + const ctx = useContext(StoreContext); + if (!ctx) throw new Error("Missing "); + return useStore(ctx, selector); +}; + +// selected secret context +export const useSelectedSecrets = () => useStoreContext((state) => state.selectedSecret); +export const useSelectedSecretActions = () => useStoreContext((state) => state.action); + +// popup context +export const usePopUpState = (id: PopUpNames) => + useStoreContext((state) => state.popUp?.[id] || { isOpen: false }); +export const usePopUpAction = () => useStoreContext((state) => state.popUpActions); diff --git a/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts new file mode 100644 index 000000000..848e8b91a --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.types.ts @@ -0,0 +1,14 @@ +export type Filter = { + tags: Record; + searchFilter: string; + include: { + [key in RowType]: boolean; + }; +}; + +export enum RowType { + Folder = "folder", + Import = "import", + DynamicSecret = "dynamic", + Secret = "secret" +} diff --git a/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx new file mode 100644 index 000000000..960eea6a4 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -0,0 +1,662 @@ +import { TypeOptions } from "react-toastify"; +import { subject } from "@casl/ability"; +import { + faAngleDown, + faAnglesRight, + faCheckCircle, + faChevronRight, + faCodeCommit, + faDownload, + faEye, + faEyeSlash, + faFileImport, + faFilter, + faFingerprint, + faFolder, + faFolderPlus, + faKey, + faMinusSquare, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; +import { twMerge } from "tailwind-merge"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + IconButton, + Modal, + ModalContent, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; +import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; +import { SecretType, WsTag } from "@app/hooks/api/types"; + +import { SecretSearchInput } from "../../../overview/-components/SecretSearchInput"; +import { + PopUpNames, + usePopUpAction, + useSelectedSecretActions, + useSelectedSecrets +} from "../SecretMainPage.store"; +import { Filter, RowType } from "../SecretMainPage.types"; +import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; +import { CreateSecretImportForm } from "./CreateSecretImportForm"; +import { FolderForm } from "./FolderForm"; +import { MoveSecretsModal } from "./MoveSecretsModal"; + +type Props = { + // switch the secrets type as it gets decrypted after api call + environment: string; + // @depreciated will be moving all these details to zustand + workspaceId: string; + projectSlug: string; + secretPath?: string; + filter: Filter; + tags?: WsTag[]; + isVisible?: boolean; + snapshotCount: number; + isSnapshotCountLoading?: boolean; + onSearchChange: (term: string) => void; + onToggleTagFilter: (tagId: string) => void; + onVisibilityToggle: () => void; + onToggleRowType: (rowType: RowType) => void; + onClickRollbackMode: () => void; +}; + +export const ActionBar = ({ + environment, + workspaceId, + projectSlug, + secretPath = "/", + filter, + tags = [], + isVisible, + snapshotCount, + isSnapshotCountLoading, + onSearchChange, + onToggleTagFilter, + onVisibilityToggle, + onClickRollbackMode, + onToggleRowType +}: Props) => { + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "addFolder", + "addDynamicSecret", + "addSecretImport", + "bulkDeleteSecrets", + "moveSecrets", + "misc", + "upgradePlan" + ] as const); + const { subscription } = useSubscription(); + const { openPopUp } = usePopUpAction(); + + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: moveSecrets } = useMoveSecrets(); + + const selectedSecrets = useSelectedSecrets(); + const { reset: resetSelectedSecret } = useSelectedSecretActions(); + const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); + + const { currentWorkspace } = useWorkspace(); + + const handleFolderCreate = async (folderName: string) => { + try { + await createFolder({ + name: folderName, + path: secretPath, + environment, + projectId: workspaceId + }); + handlePopUpClose("addFolder"); + createNotification({ + type: "success", + text: "Successfully created folder" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create folder" + }); + } + }; + + const handleSecretDownload = async () => { + const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({ + workspaceId, + expandSecretReferences: true, + includeImports: true, + environment, + secretPath + }); + const secretsPicked = new Set(); + const secretsToDownload: { key: string; value?: string; comment?: string }[] = []; + localSecrets.forEach((el) => { + secretsPicked.add(el.secretKey); + secretsToDownload.push({ + key: el.secretKey, + value: el.secretValue, + comment: el.secretComment + }); + }); + + for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) { + const secret = localImportedSecrets[i].secrets[j]; + if (!secretsPicked.has(secret.secretKey)) { + secretsToDownload.push({ + key: secret.secretKey, + value: secret.secretValue, + comment: secret.secretComment + }); + } + secretsPicked.add(secret.secretKey); + } + } + + const file = secretsToDownload + .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) + .reduce( + (prev, { key, comment, value }, index) => + prev + + (comment + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` + : `${key}=${value}\n`), + "" + ); + + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, `${environment}.env`); + }; + + const handleSecretBulkDelete = async () => { + const bulkDeletedSecrets = Object.values(selectedSecrets); + try { + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment, + secrets: bulkDeletedSecrets.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })) + }); + resetSelectedSecret(); + handlePopUpClose("bulkDeleteSecrets"); + createNotification({ + type: "success", + text: "Successfully deleted secrets" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to delete secrets" + }); + } + }; + + const handleSecretsMove = async ({ + destinationEnvironment, + destinationSecretPath, + shouldOverwrite + }: { + destinationEnvironment: string; + destinationSecretPath: string; + shouldOverwrite: boolean; + }) => { + try { + const secretsToMove = Object.values(selectedSecrets); + const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({ + projectSlug, + shouldOverwrite, + sourceEnvironment: environment, + sourceSecretPath: secretPath, + destinationEnvironment, + destinationSecretPath, + projectId: workspaceId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + let notificationMessage = ""; + let notificationType: TypeOptions = "info"; + + if (isDestinationUpdated && isSourceUpdated) { + notificationMessage = "Successfully moved selected secrets"; + notificationType = "success"; + } else if (isDestinationUpdated) { + notificationMessage = + "Successfully created secrets in destination. A secret approval request has been generated for the source."; + } else if (isSourceUpdated) { + notificationMessage = "A secret approval request has been generated in the destination"; + } else { + notificationMessage = + "A secret approval request has been generated in both the source and the destination."; + } + + createNotification({ + type: notificationType, + text: notificationMessage + }); + + resetSelectedSecret(); + } catch (error) { + console.error(error); + } + }; + + return ( + <> +
+ env.slug === environment)!]} + projectId={workspaceId} + tags={tags} + /> +
+ + + !include).length) && + "border-primary/50 text-primary" + )} + > + + + + + Filter By + { + e.preventDefault(); + onToggleRowType(RowType.Import); + }} + icon={filter?.include[RowType.Import] && } + iconPos="right" + > +
+ + Imports +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Folder); + }} + icon={filter?.include[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter?.include[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Secret); + }} + icon={filter?.include[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
+ + } + > + Tags + + + + Apply Tags to Filter Secrets + + {tags.map(({ id, slug, color }) => ( + { + evt.preventDefault(); + onToggleTagFilter(slug); + }} + key={id} + icon={filter?.tags[slug] && } + iconPos="right" + > +
+
+ {slug} +
+ + ))} + + + + +
+
+
+ + + +
+
+ + + +
+
+ + {(isAllowed) => ( + + )} + +
+
+ + {(isAllowed) => ( + + )} + + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
+ + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
+
+
+
+
+
+
+ + + + + +
+ {Object.keys(selectedSecrets).length} Selected +
+ + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
+
+ {/* all the side triggers from actions like modals etc */} + handlePopUpOpen("upgradePlan")} + isOpen={popUp.addSecretImport.isOpen} + onClose={() => handlePopUpClose("addSecretImport")} + onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} + /> + handlePopUpToggle("addDynamicSecret", isOpen)} + projectSlug={projectSlug} + environment={environment} + secretPath={secretPath} + /> + handlePopUpToggle("addFolder", isOpen)} + > + + + + + handlePopUpToggle("bulkDeleteSecrets", isOpen)} + onDeleteApproved={handleSecretBulkDelete} + /> + + {subscription && ( + handlePopUpToggle("upgradePlan", isOpen)} + text={ + subscription.slug === null + ? "You can perform this action under an Enterprise license" + : "You can perform this action if you switch to Infisical's Team plan" + } + /> + )} + + ); +}; + +ActionBar.displayName = "ActionBar"; diff --git a/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx new file mode 100644 index 000000000..2716910b1 --- /dev/null +++ b/frontend-v2/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx @@ -0,0 +1,318 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + SecretInput, + TextArea +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + clusterName: z.string().trim().min(1), + accessKeyId: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + + region: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const AwsElastiCacheInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting, errors }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + creationStatement: `{ + "UserId": "{{username}}", + "UserName": "{{username}}", + "Engine": "redis", + "Passwords": ["{{password}}"], + "AccessString": "on ~* +@all" +}`, + revocationStatement: `{ + "UserId": "{{username}}" +}` + } + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + console.log("formState", errors); + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isPending) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.AwsElastiCache, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + + + Modify ElastiCache Statements + + ( + +