mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 18:15:53 +00:00
Merge remote-tracking branch 'origin/main' into feat/gitlabSecretSync
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync";
|
||||
|
||||
import { getSecretSyncDestinationColValues } from "../helpers";
|
||||
import { SecretSyncTableCell } from "../SecretSyncTableCell";
|
||||
|
||||
type Props = {
|
||||
secretSync: TCloudflarePagesSync;
|
||||
};
|
||||
|
||||
export const CloudflarePagesSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
};
|
||||
+3
@@ -7,6 +7,7 @@ import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfiguration
|
||||
import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol";
|
||||
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
|
||||
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
|
||||
import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol";
|
||||
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
|
||||
import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol";
|
||||
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
|
||||
@@ -70,6 +71,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
return <FlyioSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.GitLab:
|
||||
return <GitLabSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.CloudflarePages:
|
||||
return <CloudflarePagesSyncDestinationCol secretSync={secretSync} />;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
|
||||
|
||||
+4
@@ -140,6 +140,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
|
||||
throw new Error(`Unhandled GitLab Scope Destination Col Values ${destination}`);
|
||||
}
|
||||
break;
|
||||
case SecretSync.CloudflarePages:
|
||||
primaryText = destinationConfig.projectName;
|
||||
secondaryText = destinationConfig.environment;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Destination Col Values ${destination}`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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 { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
|
||||
<PageHeader
|
||||
title="Approval Workflows"
|
||||
description="Create approval policies for any modifications to secrets in sensitive environments and folders."
|
||||
>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/pr-workflows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</PageHeader>
|
||||
/>
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<TabList>
|
||||
<Tab value={TabSection.SecretApprovalRequests}>
|
||||
|
||||
+288
-136
@@ -2,15 +2,25 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBookOpen,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faClipboardCheck,
|
||||
faLock,
|
||||
faPlus
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faStopwatch,
|
||||
faUser,
|
||||
IconDefinition
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import {
|
||||
@@ -21,6 +31,8 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Pagination,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
@@ -32,7 +44,12 @@ import {
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import {
|
||||
accessApprovalKeys,
|
||||
@@ -48,28 +65,21 @@ 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 generateRequestText = (request: TAccessApprovalRequest) => {
|
||||
const { isTemporary } = request;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
|
||||
{request.policy.secretPath}
|
||||
</code>
|
||||
in
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
</code>{" "}
|
||||
in{" "}
|
||||
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
|
||||
{request.environmentName}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
{request.requestedByUserId === userId && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Badge className="ml-1">Requested By You</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
|
||||
const {
|
||||
data: requests,
|
||||
refetch: refetchRequests,
|
||||
isPending: areRequestsPending
|
||||
} = useGetAccessApprovalRequests({
|
||||
projectSlug,
|
||||
authorProjectMembershipId: requestedByFilter,
|
||||
authorUserId: requestedByFilter,
|
||||
envSlug: envFilter
|
||||
});
|
||||
|
||||
const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", {
|
||||
initPerPage: getUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
let accessRequests: typeof requests;
|
||||
|
||||
if (statusFilter === "open")
|
||||
return requests?.filter(
|
||||
accessRequests = requests?.filter(
|
||||
(request) =>
|
||||
!request.policy.deletedAt &&
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
accessRequests = requests?.filter(
|
||||
(request) =>
|
||||
request.policy.deletedAt ||
|
||||
request.isApproved ||
|
||||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
|
||||
return requests;
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
return (
|
||||
accessRequests?.filter((request) => {
|
||||
const { environmentName, requestedByUser } = request;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
environmentName?.toLowerCase().includes(searchValue) ||
|
||||
`${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(searchValue)
|
||||
);
|
||||
}) ?? []
|
||||
);
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter, search]);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredRequests.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const generateRequestDetails = useCallback(
|
||||
(request: TAccessApprovalRequest) => {
|
||||
@@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({
|
||||
const canBypass =
|
||||
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
|
||||
|
||||
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
|
||||
let displayData: {
|
||||
label: string;
|
||||
type: "primary" | "danger" | "success";
|
||||
tooltipContent?: string;
|
||||
icon: IconDefinition | null;
|
||||
} = {
|
||||
label: "",
|
||||
type: "primary"
|
||||
type: "primary",
|
||||
icon: null
|
||||
};
|
||||
|
||||
const isExpired =
|
||||
@@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({
|
||||
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" };
|
||||
if (isExpired)
|
||||
displayData = {
|
||||
label: "Access Expired",
|
||||
type: "danger",
|
||||
icon: faStopwatch,
|
||||
tooltipContent: request.privilege?.temporaryAccessEndTime
|
||||
? `Expired ${format(request.privilege.temporaryAccessEndTime, "M/d/yyyy h:mm aa")}`
|
||||
: undefined
|
||||
};
|
||||
else if (isAccepted)
|
||||
displayData = {
|
||||
label: "Access Granted",
|
||||
type: "success",
|
||||
icon: faCheck,
|
||||
tooltipContent: `Granted ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
|
||||
};
|
||||
else if (isRejectedByAnyone)
|
||||
displayData = {
|
||||
label: "Rejected",
|
||||
type: "danger",
|
||||
icon: faBan,
|
||||
tooltipContent: `Rejected ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
|
||||
};
|
||||
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"
|
||||
type: "primary",
|
||||
icon: faClipboardCheck
|
||||
};
|
||||
} else if (!isReviewedByUser)
|
||||
displayData = {
|
||||
label: "Review Required",
|
||||
type: "primary"
|
||||
type: "primary",
|
||||
icon: faClipboardCheck
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -225,47 +297,71 @@ export const AccessApprovalRequest = ({
|
||||
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Request access to secrets in sensitive environments and folders.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
const isFiltered = Boolean(search || envFilter || requestedByFilter);
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Request and review access to secrets in sensitive environments and folders
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request Access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search approval requests by requesting user or environment..."
|
||||
className="flex-1"
|
||||
containerClassName="mb-4"
|
||||
/>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -273,17 +369,19 @@ export const AccessApprovalRequest = ({
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{!!requestCount && requestCount?.pendingCount} Pending
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
@@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{!!requestCount && requestCount.finalizedCount} Completed
|
||||
{!!requestCount && requestCount.finalizedCount} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-bunker-300"
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Select an Environment
|
||||
</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
@@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({
|
||||
Requested By
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Select Requesting User
|
||||
</DropdownMenuLabel>
|
||||
{members?.map(({ user: membershipUser, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRequestedByFilter((state) => (state === id ? undefined : id))
|
||||
setRequestedByFilter((state) =>
|
||||
state === membershipUser.id ? undefined : membershipUser.id
|
||||
)
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
icon={
|
||||
requestedByFilter === membershipUser.id && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{membershipUser.username}
|
||||
@@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{filteredRequests?.length === 0 && (
|
||||
{filteredRequests?.length === 0 && !isFiltered && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more access requests pending." />
|
||||
<EmptyState
|
||||
title={`No ${statusFilter === "open" ? "Pending" : "Closed"} Access Requests`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(!filteredRequests?.length && isFiltered && !areRequestsPending) && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No Requests Match Filters" icon={faSearch} />
|
||||
</div>
|
||||
)}
|
||||
{!!filteredRequests?.length &&
|
||||
filteredRequests?.map((request) => {
|
||||
filteredRequests?.slice(offset, perPage * page).map((request) => {
|
||||
const details = generateRequestDetails(request);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||
className="flex w-full cursor-pointer border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectRequest(request)}
|
||||
@@ -379,14 +502,18 @@ export const AccessApprovalRequest = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full flex-col justify-between">
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{generateRequestText(request, user.id)}
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
size="xs"
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
/>
|
||||
{generateRequestText(request)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs leading-3 text-gray-500">
|
||||
{membersGroupById?.[request.requestedByUserId]?.user && (
|
||||
<>
|
||||
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
|
||||
@@ -397,61 +524,86 @@ export const AccessApprovalRequest = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{request.requestedByUserId === user.id && (
|
||||
<div className="flex items-center gap-1.5 whitespace-nowrap text-xs text-bunker-300">
|
||||
<FontAwesomeIcon icon={faUser} size="sm" />
|
||||
<span>Requested By You</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={details.displayData.tooltipContent}>
|
||||
<div>
|
||||
<Badge variant={details.displayData.type}>
|
||||
{details.displayData.label}
|
||||
<Badge
|
||||
className="flex items-center gap-1.5 whitespace-nowrap"
|
||||
variant={details.displayData.type}
|
||||
>
|
||||
{details.displayData.icon && (
|
||||
<FontAwesomeIcon icon={details.displayData.icon} />
|
||||
)}
|
||||
<span>{details.displayData.label}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Boolean(filteredRequests.length) && (
|
||||
<Pagination
|
||||
className="border-none"
|
||||
count={filteredRequests.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
});
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
});
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
policies={policies || []}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
members={members || []}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
refetchRequests();
|
||||
}}
|
||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
policies={policies || []}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
members={members || []}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
refetchRequests();
|
||||
}}
|
||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ export const ReviewAccessRequestModal = ({
|
||||
return acc;
|
||||
}
|
||||
|
||||
const approvals = curr.approvals || policy.approvals;
|
||||
const approvals = curr.approvalsRequired || policy.approvals;
|
||||
const sequence = curr.sequence || 1;
|
||||
|
||||
acc.push(
|
||||
|
||||
+373
-117
@@ -1,11 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faFileShield,
|
||||
faPlus
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -19,6 +27,9 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -36,7 +47,12 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useDeleteAccessApprovalPolicy,
|
||||
useDeleteSecretApprovalPolicy,
|
||||
@@ -45,6 +61,7 @@ import {
|
||||
useListWorkspaceGroups
|
||||
} from "@app/hooks/api";
|
||||
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
|
||||
|
||||
@@ -55,6 +72,18 @@ interface IProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
enum PolicyOrderBy {
|
||||
Name = "name",
|
||||
Environment = "environment",
|
||||
SecretPath = "secret-path",
|
||||
Type = "type"
|
||||
}
|
||||
|
||||
type PolicyFilters = {
|
||||
type: null | PolicyType;
|
||||
environmentIds: string[];
|
||||
};
|
||||
|
||||
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
|
||||
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
|
||||
{
|
||||
@@ -110,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
currentWorkspace
|
||||
);
|
||||
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<PolicyFilters>({
|
||||
type: null,
|
||||
environmentIds: []
|
||||
});
|
||||
|
||||
const filteredPolicies = useMemo(() => {
|
||||
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
|
||||
}, [policies, filterType]);
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<PolicyOrderBy>(PolicyOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredPolicies = useMemo(
|
||||
() =>
|
||||
policies
|
||||
.filter(({ policyType, environment, name, secretPath }) => {
|
||||
if (filters.type && policyType !== filters.type) return false;
|
||||
|
||||
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
|
||||
return false;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
name.toLowerCase().includes(searchValue) ||
|
||||
environment.name.toLowerCase().includes(searchValue) ||
|
||||
(secretPath ?? "*").toLowerCase().includes(searchValue)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case PolicyOrderBy.Type:
|
||||
return policyOne.policyType
|
||||
.toLowerCase()
|
||||
.localeCompare(policyTwo.policyType.toLowerCase());
|
||||
case PolicyOrderBy.Environment:
|
||||
return policyOne.environment.name
|
||||
.toLowerCase()
|
||||
.localeCompare(policyTwo.environment.name.toLowerCase());
|
||||
case PolicyOrderBy.SecretPath:
|
||||
return (policyOne.secretPath ?? "*")
|
||||
.toLowerCase()
|
||||
.localeCompare((policyTwo.secretPath ?? "*").toLowerCase());
|
||||
case PolicyOrderBy.Name:
|
||||
default:
|
||||
return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[policies, filters, search, orderBy, orderDirection]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredPolicies.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
|
||||
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
|
||||
@@ -149,121 +246,280 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length);
|
||||
|
||||
const handleSort = (column: PolicyOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: PolicyOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Implement granular policies for access requests and secrets management.
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Policies</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/pr-workflows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Implement granular policies for access requests and secrets management
|
||||
</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("policyForm");
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("policyForm");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search policies by name, type, environment or secret path..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter findings"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
|
||||
align="end"
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-xs font-semibold uppercase text-bunker-300"
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: null
|
||||
}))
|
||||
}
|
||||
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: PolicyType.AccessPolicy
|
||||
}))
|
||||
}
|
||||
icon={
|
||||
filters.type === PolicyType.AccessPolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Access Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: PolicyType.ChangePolicy
|
||||
}))
|
||||
}
|
||||
icon={
|
||||
filters.type === PolicyType.ChangePolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Change Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>Environment</DropdownMenuLabel>
|
||||
{currentWorkspace.environments.map((env) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
environmentIds: prev.environmentIds.includes(env.id)
|
||||
? prev.environmentIds.filter((i) => i !== env.id)
|
||||
: [...prev.environmentIds, env.id]
|
||||
}));
|
||||
}}
|
||||
key={env.id}
|
||||
icon={
|
||||
filters.environmentIds.includes(env.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<span className="capitalize">{env.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(PolicyOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(PolicyOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Environment
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(PolicyOrderBy.Environment)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(PolicyOrderBy.Environment)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Environment)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Secret Path
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(PolicyOrderBy.SecretPath)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(PolicyOrderBy.SecretPath)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.SecretPath)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Type
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(null)}
|
||||
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.AccessPolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.AccessPolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Access Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.ChangePolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.ChangePolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Change Policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && (
|
||||
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(PolicyOrderBy.Type)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(PolicyOrderBy.Type)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Type)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && (
|
||||
<TableSkeleton
|
||||
columns={5}
|
||||
innerKey="secret-policies"
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
)}
|
||||
{!isPoliciesLoading && !policies?.length && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState title="No Policies Found" icon={faFileShield} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies
|
||||
?.slice(offset, perPage * page)
|
||||
.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
policy={policy}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && (
|
||||
<EmptyState title="No Policies Match Search" icon={faSearch} />
|
||||
)}
|
||||
{!isPoliciesLoading && !filteredPolicies?.length && (
|
||||
<Tr>
|
||||
<Td colSpan={6}>
|
||||
<EmptyState title="No policies found" icon={faFileShield} />
|
||||
</Td>
|
||||
</Tr>
|
||||
{Boolean(filteredPolicies.length) && (
|
||||
<Pagination
|
||||
count={filteredPolicies.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies?.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
policy={policy}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
<AccessPolicyForm
|
||||
projectId={currentWorkspace.id}
|
||||
projectSlug={currentWorkspace.slug}
|
||||
@@ -284,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
+471
-470
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RefObject, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -112,21 +112,20 @@ const formSchema = z
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const AccessPolicyForm = ({
|
||||
isOpen,
|
||||
const Form = ({
|
||||
onToggle,
|
||||
members = [],
|
||||
projectId,
|
||||
projectSlug,
|
||||
editValues
|
||||
}: Props) => {
|
||||
editValues,
|
||||
modalContainer,
|
||||
isEditMode
|
||||
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
|
||||
const modalContainer = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
@@ -153,34 +152,34 @@ export const AccessPolicyForm = ({
|
||||
.map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [],
|
||||
approvals: editValues?.approvals,
|
||||
allowedSelfApprovals: editValues?.allowedSelfApprovals,
|
||||
sequenceApprovers: editValues.approvers
|
||||
?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0))
|
||||
.reduce(
|
||||
(acc, curr) => {
|
||||
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
|
||||
acc[acc.length - 1][curr.type]?.push(curr);
|
||||
return acc;
|
||||
}
|
||||
const approvals = curr.approvals || editValues.approvals;
|
||||
acc.push(
|
||||
curr.type === ApproverType.User
|
||||
? {
|
||||
user: [curr],
|
||||
group: [],
|
||||
sequence: 1,
|
||||
approvals
|
||||
}
|
||||
: { group: [curr], user: [], sequence: 1, approvals }
|
||||
);
|
||||
sequenceApprovers: editValues.approvers?.reduce(
|
||||
(acc, curr) => {
|
||||
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
|
||||
acc[acc.length - 1][curr.type]?.push(curr);
|
||||
return acc;
|
||||
},
|
||||
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
|
||||
)
|
||||
}
|
||||
const approvals = curr.approvalsRequired || editValues.approvals;
|
||||
acc.push(
|
||||
curr.type === ApproverType.User
|
||||
? {
|
||||
user: [curr],
|
||||
group: [],
|
||||
sequence: 1,
|
||||
approvals
|
||||
}
|
||||
: { group: [curr], user: [], sequence: 1, approvals }
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
|
||||
)
|
||||
} as TFormSchema)
|
||||
: undefined,
|
||||
defaultValues: {
|
||||
sequenceApprovers: [{ approvals: 1 }]
|
||||
}
|
||||
defaultValues: !editValues
|
||||
? {
|
||||
sequenceApprovers: [{ approvals: 1 }]
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
const sequenceApproversFieldArray = useFieldArray({
|
||||
control,
|
||||
@@ -191,20 +190,14 @@ export const AccessPolicyForm = ({
|
||||
const { data: groups } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const isEditMode = Boolean(editValues);
|
||||
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
|
||||
|
||||
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 enforcementLevel = watch("enforcementLevel");
|
||||
|
||||
const formUserBypassers = watch("userBypassers");
|
||||
@@ -392,444 +385,452 @@ export const AccessPolicyForm = ({
|
||||
setDragOverItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isAccessPolicyType && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Min. Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
Select members or groups that are allowed to approve requests from this policy.
|
||||
</p>
|
||||
</div>
|
||||
{isAccessPolicyType ? (
|
||||
<>
|
||||
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
|
||||
{sequenceApproversFieldArray.fields.map((el, index) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
|
||||
dragOverItem === index ? "border-2 border-blue-400" : "",
|
||||
draggedItem === index ? "opacity-50" : ""
|
||||
)}
|
||||
key={el.id}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Tag>Step {index + 1}</Tag>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
|
||||
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.approvals` as const}
|
||||
defaultValue={1}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
size="xs"
|
||||
min={1}
|
||||
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip content="Remove step">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
variant="plain"
|
||||
onClick={() => sequenceApproversFieldArray.remove(index)}
|
||||
className="text-red-500 hover:text-gray-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Drag to reorder permission">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGripVertical} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.user` as const}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.group` as const}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ??
|
||||
option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() =>
|
||||
sequenceApproversFieldArray.append({
|
||||
approvals: 1,
|
||||
user: [],
|
||||
group: []
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="userApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="allowedSelfApprovals"
|
||||
defaultValue
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl label="Self Approvals" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Switch
|
||||
id="self-approvals"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Allow approvers to review their own requests
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bypass Approvals"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-3"
|
||||
>
|
||||
<Switch
|
||||
id="bypass-approvals"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value === EnforcementLevel.Soft}
|
||||
onCheckedChange={(v) => onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)}
|
||||
>
|
||||
Allow certain users to bypass policy in break-glass situations
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{enforcementLevel === EnforcementLevel.Soft && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="userBypassers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Bypassers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2 w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={bypasserMemberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupBypassers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Bypassers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2 w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={bypasserGroupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bypasserCount <= 0 && (
|
||||
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
|
||||
Not selecting specific users or groups will allow anyone to bypass this policy.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => {
|
||||
const modalContainer = useRef<HTMLDivElement>(null);
|
||||
const isEditMode = Boolean(editValues);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
<ModalContent
|
||||
className="max-w-3xl"
|
||||
ref={modalContainer}
|
||||
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
|
||||
title={isEditMode ? "Edit Policy" : "Create Policy"}
|
||||
>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isAccessPolicyType && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Min. Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
Select members or groups that are allowed to approve requests from this policy.
|
||||
</p>
|
||||
</div>
|
||||
{isAccessPolicyType ? (
|
||||
<>
|
||||
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
|
||||
{sequenceApproversFieldArray.fields.map((el, index) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
|
||||
dragOverItem === index ? "border-2 border-blue-400" : "",
|
||||
draggedItem === index ? "opacity-50" : ""
|
||||
)}
|
||||
key={el.id}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Tag>Step {index + 1}</Tag>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
|
||||
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.approvals` as const}
|
||||
defaultValue={1}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
size="xs"
|
||||
min={1}
|
||||
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip content="Remove step">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
variant="plain"
|
||||
onClick={() => sequenceApproversFieldArray.remove(index)}
|
||||
className="text-red-500 hover:text-gray-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Drag to reorder permission">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGripVertical} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.user` as const}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`sequenceApprovers.${index}.group` as const}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ??
|
||||
option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() =>
|
||||
sequenceApproversFieldArray.append({
|
||||
approvals: 1,
|
||||
user: [],
|
||||
group: []
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="userApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ??
|
||||
option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="allowedSelfApprovals"
|
||||
defaultValue
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Self Approvals"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Switch
|
||||
id="self-approvals"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Allow approvers to review their own requests
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bypass Approvals"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-3"
|
||||
>
|
||||
<Switch
|
||||
id="bypass-approvals"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value === EnforcementLevel.Soft}
|
||||
onCheckedChange={(v) =>
|
||||
onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)
|
||||
}
|
||||
>
|
||||
Allow certain users to bypass policy in break-glass situations
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{enforcementLevel === EnforcementLevel.Soft && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="userBypassers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Bypassers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2 w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={bypasserMemberOptions}
|
||||
getOptionValue={(option) => 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}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupBypassers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Bypassers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2 w-1/2"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={bypasserGroupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ??
|
||||
option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bypasserCount <= 0 && (
|
||||
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
|
||||
Not selecting specific users or groups will allow anyone to bypass this policy.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Form
|
||||
{...props}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
editValues={editValues}
|
||||
modalContainer={modalContainer}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+60
-55
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Td,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
@@ -59,11 +61,11 @@ export const ApprovalPolicyRow = ({
|
||||
const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0));
|
||||
const entityInSameSequence = sortedSteps?.reduce(
|
||||
(acc, curr) => {
|
||||
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
|
||||
if (acc.length && acc[acc.length - 1].sequence === (curr.sequence || 1)) {
|
||||
acc[acc.length - 1][curr.type]?.push(curr);
|
||||
return acc;
|
||||
}
|
||||
const approvals = curr.approvals || policy.approvals;
|
||||
const approvals = curr.approvalsRequired || policy.approvals;
|
||||
acc.push(
|
||||
curr.type === ApproverType.User
|
||||
? { user: [curr], group: [], sequence: 1, approvals }
|
||||
@@ -80,11 +82,11 @@ export const ApprovalPolicyRow = ({
|
||||
userLabels: members
|
||||
?.filter((member) => el.user.find((i) => i.id === member.user.id))
|
||||
.map((member) => getMemberLabel(member))
|
||||
.join(","),
|
||||
.join(", "),
|
||||
groupLabels: groups
|
||||
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
|
||||
.map(({ group }) => group.name)
|
||||
.join(","),
|
||||
.join(", "),
|
||||
approvals: el.approvals
|
||||
};
|
||||
});
|
||||
@@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({
|
||||
}}
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
|
||||
<Td>{policy.environment.name}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td>
|
||||
<Badge className={policyDetails[policy.policyType].className}>
|
||||
{policyDetails[policy.policyType].name}
|
||||
<Badge
|
||||
className={twMerge(
|
||||
policyDetails[policy.policyType].className,
|
||||
"flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={policyDetails[policy.policyType].icon} />
|
||||
<span>{policyDetails[policy.policyType].name}</span>
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
|
||||
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit Policy
|
||||
</DropdownMenuItem>
|
||||
@@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Policy
|
||||
</DropdownMenuItem>
|
||||
@@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isExpanded && (
|
||||
<Tr>
|
||||
<Td colSpan={5} className="rounded bg-mineshaft-900">
|
||||
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
|
||||
{labels?.map((el, index) => (
|
||||
<div
|
||||
key={`approval-list-${index + 1}`}
|
||||
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
|
||||
<div className="text-lg">{index + 1}</div>
|
||||
<Tr>
|
||||
<Td colSpan={6} className="!border-none p-0">
|
||||
<div
|
||||
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ease-in-out ${
|
||||
isExpanded ? "thin-scrollbar max-h-[26rem] !overflow-y-auto opacity-100" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
|
||||
{labels?.map((el, index) => (
|
||||
<div
|
||||
key={`approval-list-${index + 1}`}
|
||||
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
|
||||
<div>{index + 1}</div>
|
||||
</div>
|
||||
{index !== labels.length - 1 && (
|
||||
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
|
||||
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
|
||||
)}
|
||||
{index !== 0 && (
|
||||
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
|
||||
<div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid flex-grow grid-cols-3">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
|
||||
<div>{el.userLabels || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
|
||||
<div>{el.groupLabels || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
|
||||
<div>{el.approvals || "-"}</div>
|
||||
|
||||
<div className="grid flex-grow grid-cols-3">
|
||||
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+252
-160
@@ -1,14 +1,19 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faCodeBranch
|
||||
faCodeBranch,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} 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 { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -18,6 +23,8 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Pagination,
|
||||
Skeleton
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
@@ -28,6 +35,12 @@ import {
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import {
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetSecretApprovalRequests,
|
||||
@@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => {
|
||||
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
|
||||
|
||||
const {
|
||||
data: secretApprovalRequests,
|
||||
isFetchingNextPage: isFetchingNextApprovalRequest,
|
||||
fetchNextPage: fetchNextApprovalRequest,
|
||||
hasNextPage: hasNextApprovalPage,
|
||||
debouncedSearch: debouncedSearchFilter,
|
||||
search: searchFilter,
|
||||
setSearch: setSearchFilter,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
limit
|
||||
} = usePagination("", {
|
||||
initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
isPending: isApprovalRequestLoading,
|
||||
refetch
|
||||
} = useGetSecretApprovalRequests({
|
||||
workspaceId,
|
||||
status: statusFilter,
|
||||
environment: envFilter,
|
||||
committer: committerFilter
|
||||
committer: committerFilter,
|
||||
search: debouncedSearchFilter,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
const totalApprovalCount = data?.totalCount ?? 0;
|
||||
const secretApprovalRequests = data?.approvals ?? [];
|
||||
|
||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||
useGetSecretApprovalRequestCount({ workspaceId });
|
||||
const { user: userSession } = useUser();
|
||||
@@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
const isRequestListEmpty =
|
||||
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
|
||||
const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
|
||||
|
||||
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -116,178 +153,233 @@ export const SecretApprovalRequest = () => {
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Change Requests</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/pr-workflows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(
|
||||
ProjectPermissionMemberActions.Read,
|
||||
ProjectPermissionSub.Member
|
||||
) && (
|
||||
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">Review pending and closed change requests</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search change requests by author, environment or policy path..."
|
||||
className="flex-1"
|
||||
containerClassName="mb-4"
|
||||
/>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Author
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Select an Environment
|
||||
</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={
|
||||
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(
|
||||
ProjectPermissionMemberActions.Read,
|
||||
ProjectPermissionSub.Member
|
||||
) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Author
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Select an Author
|
||||
</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={
|
||||
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && !isFiltered && (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{secretApprovalRequests.map((secretApproval) => {
|
||||
const {
|
||||
id: reqId,
|
||||
commits,
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 text-sm">
|
||||
<FontAwesomeIcon
|
||||
icon={faCodeBranch}
|
||||
size="sm"
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
/>
|
||||
{secretApproval.isReplicated
|
||||
? `${commits.length} secret pending import`
|
||||
: generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs leading-3 text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email})
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Boolean(
|
||||
!secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading
|
||||
) && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No Requests Match Filters" icon={faSearch} />
|
||||
</div>
|
||||
)}
|
||||
{Boolean(totalApprovalCount) && (
|
||||
<Pagination
|
||||
className="border-none"
|
||||
count={totalApprovalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{isApprovalRequestLoading && (
|
||||
<div>
|
||||
{Array.apply(0, Array(3)).map((_x, index) => (
|
||||
<div
|
||||
key={`approval-request-loading-${index + 1}`}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
<Skeleton className="w-1/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more requests pending." />
|
||||
</div>
|
||||
)}
|
||||
{secretApprovalRequests?.pages?.map((group, i) => (
|
||||
<Fragment key={`secret-approval-request-${i + 1}`}>
|
||||
{group?.map((secretApproval) => {
|
||||
const {
|
||||
id: reqId,
|
||||
commits,
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{secretApproval.isReplicated
|
||||
? `${commits.length} secret pending import`
|
||||
: generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email})
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
|
||||
<div>
|
||||
{Array.apply(0, Array(3)).map((_x, index) => (
|
||||
<div
|
||||
key={`approval-request-loading-${index + 1}`}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
<Skeleton className="w-1/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasNextApprovalPage && (
|
||||
<Button
|
||||
className="mt-4 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
isLoading={isFetchingNextApprovalRequest}
|
||||
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
|
||||
onClick={() => fetchNextApprovalRequest()}
|
||||
>
|
||||
{hasNextApprovalPage ? "Load More" : "End of history"}
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
+7
-10
@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
|
||||
if (score[CommitType.CREATE])
|
||||
text.push(
|
||||
<span key="created-commit">
|
||||
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
|
||||
<span style={{ color: "#60DD00" }}> created</span>
|
||||
{score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
|
||||
<span className="text-green-600"> Created</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.UPDATE])
|
||||
text.push(
|
||||
<span key="updated-commit">
|
||||
{Boolean(text.length) && ","}
|
||||
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#F8EB30" }} className="text-orange-600">
|
||||
{" "}
|
||||
updated
|
||||
</span>
|
||||
{Boolean(text.length) && ", "}
|
||||
{score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span className="text-yellow-600"> Updated</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.DELETE])
|
||||
text.push(
|
||||
<span className="deleted-commit">
|
||||
{Boolean(text.length) && "and"}
|
||||
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#F83030" }}> deleted</span>
|
||||
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
|
||||
<span className="text-red-600"> Deleted</span>
|
||||
</span>
|
||||
);
|
||||
return text;
|
||||
|
||||
+21
-2
@@ -21,6 +21,8 @@ import {
|
||||
} from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
|
||||
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
@@ -32,7 +34,15 @@ const formSchema = z.object({
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(128),
|
||||
value: z.string().trim().min(1).max(256)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
@@ -42,7 +52,15 @@ const formSchema = z.object({
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(128),
|
||||
value: z.string().trim().min(1).max(256)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
@@ -398,6 +416,7 @@ export const AwsIamInputForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<MetadataForm control={control} name="provider.tags" title="Tags" isValueRequired />
|
||||
{!isSingleEnvironmentMode && (
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+26
-1
@@ -11,7 +11,7 @@ import {
|
||||
SiSnowflake
|
||||
} from "react-icons/si";
|
||||
import { VscAzure } from "react-icons/vsc";
|
||||
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faAws, faGithub, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -26,6 +26,7 @@ import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { GcpIamInputForm } from "./GcpIamInputForm";
|
||||
import { GithubInputForm } from "./GithubInputForm";
|
||||
import { KubernetesInputForm } from "./KubernetesInputForm";
|
||||
import { LdapInputForm } from "./LdapInputForm";
|
||||
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
|
||||
@@ -143,6 +144,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <FontAwesomeIcon icon={faGoogle} size="lg" />,
|
||||
provider: DynamicSecretProviders.GcpIam,
|
||||
title: "GCP IAM"
|
||||
},
|
||||
{
|
||||
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
|
||||
provider: DynamicSecretProviders.Github,
|
||||
title: "GitHub"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -548,6 +554,25 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.Github && (
|
||||
<motion.div
|
||||
key="dynamic-github-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<GithubInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environments={environments}
|
||||
isSingleEnvironmentMode={isSingleEnvironmentMode}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } 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,
|
||||
FormLabel,
|
||||
Input,
|
||||
SecretInput,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
appId: z.coerce.number().min(1, "Required"),
|
||||
installationId: z.coerce.number().min(1, "Required"),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Required")
|
||||
.refine(
|
||||
(val) =>
|
||||
/^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test(
|
||||
val
|
||||
),
|
||||
"Invalid PEM format for private key"
|
||||
)
|
||||
}),
|
||||
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
|
||||
environment: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environments: WorkspaceEnv[];
|
||||
isSingleEnvironmentMode?: boolean;
|
||||
};
|
||||
|
||||
export const GithubInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
environments,
|
||||
secretPath,
|
||||
projectSlug,
|
||||
isSingleEnvironmentMode
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
environment: isSingleEnvironmentMode && environments.length > 0 ? environments[0] : undefined
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const handleCreateDynamicSecret = async ({ name, provider, environment }: TForm) => {
|
||||
if (createDynamicSecret.isPending) return;
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: {
|
||||
type: DynamicSecretProviders.Github,
|
||||
inputs: {
|
||||
...provider
|
||||
}
|
||||
},
|
||||
defaultTTL: "1h", // Github is limited to 1 hour tokens
|
||||
name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment.slug
|
||||
});
|
||||
onCompleted();
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<FormControl
|
||||
label={
|
||||
<FormLabel
|
||||
label="Default TTL"
|
||||
icon={
|
||||
<Tooltip content="Github token TTL is fixed to 1 hour">
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
className="relative bottom-px right-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Input disabled value="1h" className="pointer-events-none opacity-50" />
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.appId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="App ID"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input placeholder="0000000" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.installationId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Installation ID"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input placeholder="00000000" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.privateKey"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="App Private Key PEM"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isSingleEnvironmentMode && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select the environment to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
menuPlacement="top"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+23
-1
@@ -368,6 +368,22 @@ const renderOutputForm = (
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.Github) {
|
||||
const { TOKEN } = data as {
|
||||
TOKEN: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay
|
||||
label="Token"
|
||||
value={TOKEN}
|
||||
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -608,6 +624,9 @@ export const CreateDynamicSecretLease = ({
|
||||
return <Spinner className="mx-auto h-40 text-mineshaft-700" />;
|
||||
}
|
||||
|
||||
// Github tokens are fixed to 1 hour
|
||||
const fixedTtl = provider === DynamicSecretProviders.Github;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnimatePresence>
|
||||
@@ -629,8 +648,11 @@ export const CreateDynamicSecretLease = ({
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
fixedTtl ? `This provider has a fixed TTL of ${field.value}` : undefined
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
<Input {...field} isDisabled={fixedTtl} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+30
-24
@@ -29,7 +29,7 @@ import {
|
||||
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { DynamicSecretProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
|
||||
|
||||
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
|
||||
@@ -44,6 +44,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const DYNAMIC_SECRETS_WITHOUT_RENEWAL = [DynamicSecretProviders.Github];
|
||||
|
||||
export const DynamicSecretLease = ({
|
||||
projectSlug,
|
||||
dynamicSecretName,
|
||||
@@ -94,6 +96,8 @@ export const DynamicSecretLease = ({
|
||||
}
|
||||
};
|
||||
|
||||
const canRenew = !DYNAMIC_SECRETS_WITHOUT_RENEWAL.includes(dynamicSecret.type);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer>
|
||||
@@ -141,29 +145,31 @@ export const DynamicSecretLease = ({
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center space-x-4">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Renew"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="p-0"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRepeat} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{canRenew && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Renew"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="renew-lease"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="p-0"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRepeat} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
|
||||
+11
-2
@@ -10,6 +10,8 @@ import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretAwsIamAuth, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
import { MetadataForm } from "../MetadataForm";
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
@@ -21,7 +23,10 @@ const formSchema = z.object({
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
@@ -31,7 +36,10 @@ const formSchema = z.object({
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional()
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
@@ -380,6 +388,7 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<MetadataForm control={control} name="inputs.tags" title="Tags" isValueRequired />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
|
||||
+18
@@ -10,6 +10,7 @@ import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntra
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
|
||||
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
|
||||
import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm";
|
||||
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
|
||||
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
||||
@@ -366,6 +367,23 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.Github && (
|
||||
<motion.div
|
||||
key="github-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretGithubForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } 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, FormControl, FormLabel, Input, SecretInput, Tooltip } from "@app/components/v2";
|
||||
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z.object({
|
||||
appId: z.coerce.number().min(1, "Required"),
|
||||
installationId: z.coerce.number().min(1, "Required"),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Required")
|
||||
.refine(
|
||||
(val) =>
|
||||
/^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test(
|
||||
val
|
||||
),
|
||||
"Invalid PEM format for private key"
|
||||
)
|
||||
}),
|
||||
newName: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
export const EditDynamicSecretGithubForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
secretPath,
|
||||
environment,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
newName: dynamicSecret.name,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"])
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => {
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
inputs,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="newName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<FormControl
|
||||
label={
|
||||
<FormLabel
|
||||
label="Default TTL"
|
||||
icon={
|
||||
<Tooltip content="Github token TTL is fixed to 1 hour">
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
className="relative bottom-px right-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Input disabled value="1h" className="pointer-events-none opacity-50" />
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.appId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="App ID"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input placeholder="0000000" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.installationId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Installation ID"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input placeholder="00000000" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.privateKey"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="App Private Key PEM"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
-6
@@ -4,14 +4,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
|
||||
|
||||
export const MetadataForm = ({ control }: { control: Control<any> }) => {
|
||||
export const MetadataForm = ({
|
||||
control,
|
||||
name = "metadata",
|
||||
title = "Metadata",
|
||||
isValueRequired = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
name?: string;
|
||||
title?: string;
|
||||
isValueRequired?: boolean;
|
||||
}) => {
|
||||
const metadataFormFields = useFieldArray({
|
||||
control,
|
||||
name: "metadata"
|
||||
name
|
||||
});
|
||||
|
||||
return (
|
||||
<FormControl label="Metadata">
|
||||
<FormControl label={title}>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
|
||||
<div key={metadataFieldId} className="flex items-end space-x-2">
|
||||
@@ -19,7 +29,7 @@ export const MetadataForm = ({ control }: { control: Control<any> }) => {
|
||||
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.key`}
|
||||
name={`${name}.${i}.key`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
@@ -33,11 +43,15 @@ export const MetadataForm = ({ control }: { control: Control<any> }) => {
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
{i === 0 && (
|
||||
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
|
||||
<FormLabel
|
||||
label="Value"
|
||||
className="text-xs text-mineshaft-400"
|
||||
isOptional={!isValueRequired}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.value`}
|
||||
name={`${name}.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ export const RenewDynamicSecretLease = ({
|
||||
environment,
|
||||
dynamicSecret
|
||||
}: Props) => {
|
||||
const maxTtlMs = ms(dynamicSecret.maxTTL);
|
||||
const maxTtlMs = dynamicSecret.maxTTL ? ms(dynamicSecret.maxTTL) : undefined;
|
||||
|
||||
const formSchema = z.object({
|
||||
ttl: z.string().superRefine((val, ctx) => {
|
||||
@@ -39,7 +39,7 @@ export const RenewDynamicSecretLease = ({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "TTL must be greater than 1 second"
|
||||
});
|
||||
if (valMs > maxTtlMs)
|
||||
if (maxTtlMs && valMs > maxTtlMs)
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `TTL must be less than ${dynamicSecret.maxTTL}`
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync";
|
||||
|
||||
type Props = {
|
||||
secretSync: TCloudflarePagesSync;
|
||||
};
|
||||
|
||||
export const CloudflarePagesSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
const {
|
||||
destinationConfig: { projectName, environment }
|
||||
} = secretSync;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Project">{projectName}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Environment">{environment}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+4
@@ -18,6 +18,7 @@ import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigura
|
||||
import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationSection";
|
||||
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
|
||||
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
|
||||
import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection";
|
||||
import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection";
|
||||
import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection";
|
||||
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
|
||||
@@ -110,6 +111,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
|
||||
case SecretSync.GitLab:
|
||||
DestinationComponents = <GitLabSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
case SecretSync.CloudflarePages:
|
||||
DestinationComponents = <CloudflarePagesSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Destination Section components: ${destination}`);
|
||||
}
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
|
||||
case SecretSync.Render:
|
||||
case SecretSync.Flyio:
|
||||
case SecretSync.GitLab:
|
||||
case SecretSync.CloudflarePages:
|
||||
AdditionalSyncOptionsComponent = null;
|
||||
break;
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user