mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 11:29:42 +00:00
merge deconflict
This commit is contained in:
Generated
+4
-4
@@ -129,7 +129,7 @@
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^5.4.11",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
@@ -13907,9 +13907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
|
||||
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
|
||||
"version": "5.4.18",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
|
||||
"integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^5.4.11",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
|
||||
@@ -10,6 +10,7 @@ import { SecretSyncSelect } from "./SecretSyncSelect";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
selectSync?: SecretSync | null;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
@@ -32,8 +33,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) =>
|
||||
return <SecretSyncSelect onSelect={setSelectedSync} />;
|
||||
};
|
||||
|
||||
export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => {
|
||||
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(null);
|
||||
export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => {
|
||||
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(selectSync);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSync(selectSync);
|
||||
}, [selectSync]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
+3
@@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields";
|
||||
import { GcpSyncFields } from "./GcpSyncFields";
|
||||
import { GitHubSyncFields } from "./GitHubSyncFields";
|
||||
import { HumanitecSyncFields } from "./HumanitecSyncFields";
|
||||
import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields";
|
||||
import { VercelSyncFields } from "./VercelSyncFields";
|
||||
|
||||
export const SecretSyncDestinationFields = () => {
|
||||
@@ -36,6 +37,8 @@ export const SecretSyncDestinationFields = () => {
|
||||
return <DatabricksSyncFields />;
|
||||
case SecretSync.Humanitec:
|
||||
return <HumanitecSyncFields />;
|
||||
case SecretSync.TerraformCloud:
|
||||
return <TerraformCloudSyncFields />;
|
||||
case SecretSync.Camunda:
|
||||
return <CamundaSyncFields />;
|
||||
case SecretSync.Vercel:
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
|
||||
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
|
||||
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
|
||||
import {
|
||||
TERRAFORM_CLOUD_SYNC_SCOPES,
|
||||
TerraformCloudSyncCategory,
|
||||
TerraformCloudSyncScope,
|
||||
TTerraformCloudConnectionOrganization,
|
||||
TTerraformCloudConnectionVariableSet,
|
||||
TTerraformCloudConnectionWorkspace,
|
||||
useTerraformCloudConnectionListOrganizations
|
||||
} from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
import { TSecretSyncForm } from "../schemas";
|
||||
|
||||
export const TerraformCloudSyncFields = () => {
|
||||
const { control, watch, setValue } = useFormContext<
|
||||
TSecretSyncForm & { destination: SecretSync.TerraformCloud }
|
||||
>();
|
||||
|
||||
const connectionId = useWatch({ name: "connection.id", control });
|
||||
const currentOrg = watch("destinationConfig.org");
|
||||
const currentScope = watch("destinationConfig.scope");
|
||||
|
||||
const { data: organizations = [], isPending: isOrganizationsPending } =
|
||||
useTerraformCloudConnectionListOrganizations(connectionId, {
|
||||
enabled: Boolean(connectionId)
|
||||
});
|
||||
|
||||
const selectedOrg = organizations?.find((org) => org.id === currentOrg);
|
||||
const variableSets = selectedOrg?.variableSets || [];
|
||||
const workspaces = selectedOrg?.workspaces || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncConnectionField
|
||||
onChange={() => {
|
||||
setValue("destinationConfig.org", "");
|
||||
setValue("destinationConfig.variableSetId", "");
|
||||
setValue("destinationConfig.workspaceId", "");
|
||||
setValue("destinationConfig.variableSetName", "");
|
||||
setValue("destinationConfig.workspaceName", "");
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationConfig.org"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Organization"
|
||||
>
|
||||
<FilterableSelect
|
||||
isLoading={isOrganizationsPending && Boolean(connectionId)}
|
||||
isDisabled={!connectionId}
|
||||
value={organizations ? (organizations.find((org) => org.id === value) ?? null) : null}
|
||||
onChange={(option) => {
|
||||
onChange(
|
||||
(option as SingleValue<TTerraformCloudConnectionOrganization>)?.id ?? null
|
||||
);
|
||||
setValue("destinationConfig.variableSetId", "");
|
||||
setValue("destinationConfig.workspaceId", "");
|
||||
setValue("destinationConfig.variableSetName", "");
|
||||
setValue("destinationConfig.workspaceName", "");
|
||||
}}
|
||||
options={organizations}
|
||||
placeholder="Select an organization..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationConfig.category"
|
||||
control={control}
|
||||
defaultValue={TerraformCloudSyncCategory.Environment}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Category"
|
||||
tooltipClassName="max-w-lg py-3"
|
||||
tooltipText={
|
||||
<div className="flex flex-col gap-3">
|
||||
<ul className="flex list-disc flex-col gap-3 pl-4">
|
||||
<li>
|
||||
<p className="text-mineshaft-300">
|
||||
<span className="font-medium text-bunker-200">
|
||||
Environment variables configure Terraform's behavior (e.g.,
|
||||
credentials).
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p className="text-mineshaft-300">
|
||||
<span className="font-medium text-bunker-200">
|
||||
Terraform variables are used as input values in your configuration.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
defaultValue={TerraformCloudSyncCategory.Environment}
|
||||
placeholder="Select category..."
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.entries(TerraformCloudSyncCategory).map(([envKey, envValue]) => (
|
||||
<SelectItem className="capitalize" value={envValue} key={envValue}>
|
||||
{envKey.replace("-", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationConfig.scope"
|
||||
control={control}
|
||||
defaultValue={TerraformCloudSyncScope.VariableSet}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Scope"
|
||||
tooltipClassName="max-w-lg py-3"
|
||||
tooltipText={
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Specify how Infisical should manage secrets from Terraform Cloud. The following
|
||||
options are available:
|
||||
</p>
|
||||
<ul className="flex list-disc flex-col gap-3 pl-4">
|
||||
{Object.values(TERRAFORM_CLOUD_SYNC_SCOPES).map(({ name, description }) => {
|
||||
return (
|
||||
<li key={name}>
|
||||
<p className="text-mineshaft-300">
|
||||
<span className="font-medium text-bunker-200">{name}</span>: {description}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
onChange(val);
|
||||
setValue("destinationConfig.variableSetId", "");
|
||||
setValue("destinationConfig.workspaceId", "");
|
||||
setValue("destinationConfig.variableSetName", "");
|
||||
setValue("destinationConfig.workspaceName", "");
|
||||
}}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
placeholder="Select a scope..."
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(TerraformCloudSyncScope).map((scope) => (
|
||||
<SelectItem className="capitalize" value={scope} key={scope}>
|
||||
{scope.replace("-", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{currentScope === TerraformCloudSyncScope.VariableSet && (
|
||||
<Controller
|
||||
name="destinationConfig.variableSetId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Variable Set">
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isLoading={isOrganizationsPending && Boolean(connectionId) && Boolean(currentOrg)}
|
||||
isDisabled={!connectionId || !currentOrg}
|
||||
value={variableSets.find((variableSet) => variableSet.id === value) ?? null}
|
||||
onChange={(option) => {
|
||||
const selectedOption =
|
||||
option as SingleValue<TTerraformCloudConnectionVariableSet>;
|
||||
onChange(selectedOption?.id ?? null);
|
||||
|
||||
if (selectedOption) {
|
||||
setValue("destinationConfig.variableSetName", selectedOption.name);
|
||||
} else {
|
||||
setValue("destinationConfig.variableSetName", "");
|
||||
}
|
||||
}}
|
||||
options={variableSets}
|
||||
placeholder="Select a variable set..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{currentScope === TerraformCloudSyncScope.Workspace && (
|
||||
<Controller
|
||||
name="destinationConfig.workspaceId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Workspace">
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isLoading={isOrganizationsPending && Boolean(connectionId) && Boolean(currentOrg)}
|
||||
isDisabled={!connectionId || !currentOrg}
|
||||
value={workspaces.find((workspace) => workspace.id === value) ?? null}
|
||||
onChange={(option) => {
|
||||
const selectedOption = option as SingleValue<TTerraformCloudConnectionWorkspace>;
|
||||
onChange(selectedOption?.id ?? null);
|
||||
|
||||
if (selectedOption) {
|
||||
setValue("destinationConfig.workspaceName", selectedOption.name);
|
||||
} else {
|
||||
setValue("destinationConfig.workspaceName", "");
|
||||
}
|
||||
}}
|
||||
options={workspaces}
|
||||
placeholder="Select a workspace..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -39,6 +39,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
|
||||
case SecretSync.AzureAppConfiguration:
|
||||
case SecretSync.Databricks:
|
||||
case SecretSync.Humanitec:
|
||||
case SecretSync.TerraformCloud:
|
||||
case SecretSync.Camunda:
|
||||
case SecretSync.Vercel:
|
||||
AdditionalSyncOptionsFieldsComponent = null;
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
|
||||
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
|
||||
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
|
||||
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
|
||||
import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields";
|
||||
import { VercelSyncReviewFields } from "./VercelSyncReviewFields";
|
||||
|
||||
export const SecretSyncReviewFields = () => {
|
||||
@@ -74,6 +75,9 @@ export const SecretSyncReviewFields = () => {
|
||||
case SecretSync.Humanitec:
|
||||
DestinationFieldsComponent = <HumanitecSyncReviewFields />;
|
||||
break;
|
||||
case SecretSync.TerraformCloud:
|
||||
DestinationFieldsComponent = <TerraformCloudSyncReviewFields />;
|
||||
break;
|
||||
case SecretSync.Camunda:
|
||||
DestinationFieldsComponent = <CamundaSyncReviewFields />;
|
||||
break;
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
|
||||
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
export const TerraformCloudSyncReviewFields = () => {
|
||||
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.TerraformCloud }>();
|
||||
const orgId = watch("destinationConfig.org");
|
||||
const variableSetName = watch("destinationConfig.variableSetName");
|
||||
const workspaceName = watch("destinationConfig.workspaceName");
|
||||
const scope = watch("destinationConfig.scope");
|
||||
const category = watch("destinationConfig.category");
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Organization">{orgId}</GenericFieldLabel>
|
||||
{scope === TerraformCloudSyncScope.VariableSet && (
|
||||
<GenericFieldLabel label="Variable Set">{variableSetName}</GenericFieldLabel>
|
||||
)}
|
||||
{scope === TerraformCloudSyncScope.Workspace && (
|
||||
<GenericFieldLabel label="Workspace">{workspaceName}</GenericFieldLabel>
|
||||
)}
|
||||
<GenericFieldLabel label="Category">{category}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti
|
||||
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
|
||||
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
|
||||
import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema";
|
||||
import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema";
|
||||
import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema";
|
||||
|
||||
const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
|
||||
@@ -21,6 +22,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
|
||||
AzureAppConfigurationSyncDestinationSchema,
|
||||
DatabricksSyncDestinationSchema,
|
||||
HumanitecSyncDestinationSchema,
|
||||
TerraformCloudSyncDestinationSchema,
|
||||
CamundaSyncDestinationSchema,
|
||||
VercelSyncDestinationSchema
|
||||
]);
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
|
||||
import {
|
||||
TerraformCloudSyncCategory,
|
||||
TerraformCloudSyncScope
|
||||
} from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
export const TerraformCloudSyncDestinationSchema = BaseSecretSyncSchema().merge(
|
||||
z.object({
|
||||
destination: z.literal(SecretSync.TerraformCloud),
|
||||
destinationConfig: z.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal(TerraformCloudSyncScope.VariableSet),
|
||||
org: z.string().trim().min(1, "Organization required"),
|
||||
variableSetId: z.string().trim().min(1, "Variable Set required"),
|
||||
variableSetName: z.string().trim().min(1, "Variable set name required"),
|
||||
category: z.nativeEnum(TerraformCloudSyncCategory)
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal(TerraformCloudSyncScope.Workspace),
|
||||
org: z.string().trim().min(1, "Organization required"),
|
||||
workspaceId: z.string().trim().min(1, "Workspace required"),
|
||||
workspaceName: z.string().trim().min(1, "Workspace name required"),
|
||||
category: z.nativeEnum(TerraformCloudSyncCategory)
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
@@ -16,7 +16,9 @@ type Props = {
|
||||
subTitle?: string;
|
||||
onDeleteApproved: () => Promise<void>;
|
||||
buttonText?: string;
|
||||
formContent?: ReactNode;
|
||||
children?: ReactNode;
|
||||
deletionMessage?: ReactNode;
|
||||
};
|
||||
|
||||
export const DeleteActionModal = ({
|
||||
@@ -28,6 +30,8 @@ export const DeleteActionModal = ({
|
||||
title,
|
||||
subTitle = "This action is irreversible.",
|
||||
buttonText = "Delete",
|
||||
formContent,
|
||||
deletionMessage,
|
||||
children
|
||||
}: Props): JSX.Element => {
|
||||
const [inputData, setInputData] = useState("");
|
||||
@@ -79,6 +83,7 @@ export const DeleteActionModal = ({
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formContent}
|
||||
<form
|
||||
onSubmit={(evt) => {
|
||||
evt.preventDefault();
|
||||
@@ -88,7 +93,11 @@ export const DeleteActionModal = ({
|
||||
<FormControl
|
||||
label={
|
||||
<div className="break-words pb-2 text-sm">
|
||||
Type <span className="font-bold">{deleteKey}</span> to perform this action
|
||||
{deletionMessage || (
|
||||
<>
|
||||
Type <span className="font-bold">{deleteKey}</span> to perform this action
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="mb-0"
|
||||
|
||||
@@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions {
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
Encrypt = "encrypt",
|
||||
Decrypt = "decrypt"
|
||||
Decrypt = "decrypt",
|
||||
Sign = "sign",
|
||||
Verify = "verify"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionKmipActions {
|
||||
@@ -98,7 +100,8 @@ export enum PermissionConditionOperators {
|
||||
$REGEX = "$regex",
|
||||
$EQ = "$eq",
|
||||
$NEQ = "$ne",
|
||||
$GLOB = "$glob"
|
||||
$GLOB = "$glob",
|
||||
$ELEMENTMATCH = "$elemMatch"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
@@ -111,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato
|
||||
[PermissionConditionOperators.$ALL]: "contains all",
|
||||
[PermissionConditionOperators.$NEQ]: "not equal to",
|
||||
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
|
||||
[PermissionConditionOperators.$REGEX]: "matches regex pattern"
|
||||
[PermissionConditionOperators.$REGEX]: "matches regex pattern",
|
||||
[PermissionConditionOperators.$ELEMENTMATCH]: "element matches"
|
||||
};
|
||||
|
||||
export type TPermissionConditionOperators = {
|
||||
@@ -121,12 +125,24 @@ export type TPermissionConditionOperators = {
|
||||
[PermissionConditionOperators.$NEQ]: string;
|
||||
[PermissionConditionOperators.$REGEX]: string;
|
||||
[PermissionConditionOperators.$GLOB]: string;
|
||||
[PermissionConditionOperators.$ELEMENTMATCH]: Record<
|
||||
string,
|
||||
Partial<TPermissionConditionOperators>
|
||||
>;
|
||||
};
|
||||
|
||||
export type TPermissionCondition = Record<
|
||||
string,
|
||||
| string
|
||||
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string }
|
||||
| {
|
||||
$in: string[];
|
||||
$all: string[];
|
||||
$regex: string;
|
||||
$eq: string;
|
||||
$ne: string;
|
||||
$glob: string;
|
||||
$elemMatch: Partial<TPermissionCondition>;
|
||||
}
|
||||
>;
|
||||
|
||||
export enum ProjectPermissionSub {
|
||||
@@ -180,6 +196,7 @@ export type SecretFolderSubjectFields = {
|
||||
export type DynamicSecretSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
metadata?: (string | { key: string; value: string })[];
|
||||
};
|
||||
|
||||
export type SecretImportSubjectFields = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MsSqlConnectionMethod,
|
||||
PostgresConnectionMethod,
|
||||
TAppConnection,
|
||||
TerraformCloudConnectionMethod,
|
||||
VercelConnectionMethod
|
||||
} from "@app/hooks/api/appConnections/types";
|
||||
|
||||
@@ -35,6 +36,7 @@ export const APP_CONNECTION_MAP: Record<
|
||||
},
|
||||
[AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" },
|
||||
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" },
|
||||
[AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" },
|
||||
[AppConnection.Vercel]: { name: "Vercel", image: "Vercel.png" },
|
||||
[AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" },
|
||||
[AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" },
|
||||
@@ -61,6 +63,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case CamundaConnectionMethod.ClientCredentials:
|
||||
return { name: "Client Credentials", icon: faKey };
|
||||
case HumanitecConnectionMethod.ApiToken:
|
||||
case TerraformCloudConnectionMethod.ApiToken:
|
||||
case VercelConnectionMethod.ApiToken:
|
||||
return { name: "API Token", icon: faKey };
|
||||
case PostgresConnectionMethod.UsernameAndPassword:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AsymmetricKeyAlgorithm, KmsKeyUsage, SymmetricKeyAlgorithm } from "@app/hooks/api/cmeks";
|
||||
|
||||
export const kmsKeyUsageOptions: Record<
|
||||
KmsKeyUsage,
|
||||
{
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}
|
||||
> = {
|
||||
[KmsKeyUsage.ENCRYPT_DECRYPT]: {
|
||||
label: "Encrypt/Decrypt",
|
||||
tooltip: "Use the key only to encrypt and decrypt data."
|
||||
},
|
||||
[KmsKeyUsage.SIGN_VERIFY]: {
|
||||
label: "Sign/Verify",
|
||||
tooltip:
|
||||
"Key pairs for digital signing. Uses the private key for signing and the public key for verification."
|
||||
}
|
||||
};
|
||||
|
||||
export const keyUsageDefaultOption: Record<
|
||||
KmsKeyUsage,
|
||||
SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm
|
||||
> = {
|
||||
[KmsKeyUsage.ENCRYPT_DECRYPT]: SymmetricKeyAlgorithm.AES_GCM_256,
|
||||
[KmsKeyUsage.SIGN_VERIFY]: AsymmetricKeyAlgorithm.RSA_4096
|
||||
};
|
||||
@@ -24,6 +24,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
|
||||
name: "Humanitec",
|
||||
image: "Humanitec.png"
|
||||
},
|
||||
[SecretSync.TerraformCloud]: {
|
||||
name: "Terraform Cloud",
|
||||
image: "Terraform Cloud.png"
|
||||
},
|
||||
[SecretSync.Camunda]: {
|
||||
name: "Camunda",
|
||||
image: "Camunda.png"
|
||||
@@ -43,6 +47,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
|
||||
[SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration,
|
||||
[SecretSync.Databricks]: AppConnection.Databricks,
|
||||
[SecretSync.Humanitec]: AppConnection.Humanitec,
|
||||
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud,
|
||||
[SecretSync.Camunda]: AppConnection.Camunda,
|
||||
[SecretSync.Vercel]: AppConnection.Vercel
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum AppConnection {
|
||||
AzureAppConfiguration = "azure-app-configuration",
|
||||
Databricks = "databricks",
|
||||
Humanitec = "humanitec",
|
||||
TerraformCloud = "terraform-cloud",
|
||||
Vercel = "vercel",
|
||||
Postgres = "postgres",
|
||||
MsSql = "mssql",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { appConnectionKeys } from "../queries";
|
||||
import { TTerraformCloudOrganization } from "./types";
|
||||
|
||||
const terraformCloudConnectionKeys = {
|
||||
all: [...appConnectionKeys.all, "terraform-cloud"] as const,
|
||||
listOrganizations: (connectionId: string) =>
|
||||
[...terraformCloudConnectionKeys.all, "organizations", connectionId] as const
|
||||
};
|
||||
|
||||
export const useTerraformCloudConnectionListOrganizations = (
|
||||
connectionId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TTerraformCloudOrganization[],
|
||||
unknown,
|
||||
TTerraformCloudOrganization[],
|
||||
ReturnType<typeof terraformCloudConnectionKeys.listOrganizations>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: terraformCloudConnectionKeys.listOrganizations(connectionId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TTerraformCloudOrganization[]>(
|
||||
`/api/v1/app-connections/terraform-cloud/${connectionId}/organizations`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
export type TTerraformCloudOrganization = {
|
||||
name: string;
|
||||
id: string;
|
||||
variableSets: TTerraformCloudVariableSet[];
|
||||
workspaces: TTerraformCloudWorkspace[];
|
||||
};
|
||||
|
||||
export type TTerraformCloudVariableSet = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TTerraformCloudWorkspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TTerraformCloudConnectionOrganization = {
|
||||
id: string;
|
||||
name: string;
|
||||
variableSets: TTerraformCloudConnectionVariableSet[];
|
||||
workspaces: TTerraformCloudConnectionWorkspace[];
|
||||
};
|
||||
|
||||
export type TTerraformCloudConnectionVariableSet = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
global: boolean;
|
||||
};
|
||||
|
||||
export type TTerraformCloudConnectionWorkspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export enum TerraformCloudSyncScope {
|
||||
VariableSet = "variable-set",
|
||||
Workspace = "workspace"
|
||||
}
|
||||
|
||||
export enum TerraformCloudSyncCategory {
|
||||
Environment = "env",
|
||||
Terraform = "terraform"
|
||||
}
|
||||
|
||||
export const TERRAFORM_CLOUD_SYNC_SCOPES = {
|
||||
[TerraformCloudSyncScope.VariableSet]: {
|
||||
name: "Variable Set",
|
||||
description: "Sync secrets to a specific variable set in Terraform Cloud."
|
||||
},
|
||||
[TerraformCloudSyncScope.Workspace]: {
|
||||
name: "Workspace",
|
||||
description: "Sync secrets to a specific workspace in Terraform Cloud."
|
||||
}
|
||||
};
|
||||
@@ -39,6 +39,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.Humanitec;
|
||||
};
|
||||
|
||||
export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.TerraformCloud;
|
||||
};
|
||||
|
||||
export type TVercelConnectionOption = TAppConnectionOptionBase & {
|
||||
app: AppConnection.Vercel;
|
||||
};
|
||||
@@ -67,6 +71,7 @@ export type TAppConnectionOption =
|
||||
| TAzureKeyVaultConnectionOption
|
||||
| TDatabricksConnectionOption
|
||||
| THumanitecConnectionOption
|
||||
| TTerraformCloudConnectionOption
|
||||
| TVercelConnectionOption
|
||||
| TPostgresConnectionOption
|
||||
| TMsSqlConnectionOption
|
||||
@@ -81,6 +86,7 @@ export type TAppConnectionOptionMap = {
|
||||
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption;
|
||||
[AppConnection.Databricks]: TDatabricksConnectionOption;
|
||||
[AppConnection.Humanitec]: THumanitecConnectionOption;
|
||||
[AppConnection.TerraformCloud]: TTerraformCloudConnectionOption;
|
||||
[AppConnection.Vercel]: TVercelConnectionOption;
|
||||
[AppConnection.Postgres]: TPostgresConnectionOption;
|
||||
[AppConnection.MsSql]: TMsSqlConnectionOption;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TGitHubConnection } from "./github-connection";
|
||||
import { THumanitecConnection } from "./humanitec-connection";
|
||||
import { TMsSqlConnection } from "./mssql-connection";
|
||||
import { TPostgresConnection } from "./postgres-connection";
|
||||
import { TTerraformCloudConnection } from "./terraform-cloud-connection";
|
||||
import { TVercelConnection } from "./vercel-connection";
|
||||
|
||||
export * from "./auth0-connection";
|
||||
@@ -24,6 +25,7 @@ export * from "./github-connection";
|
||||
export * from "./humanitec-connection";
|
||||
export * from "./mssql-connection";
|
||||
export * from "./postgres-connection";
|
||||
export * from "./terraform-cloud-connection";
|
||||
export * from "./vercel-connection";
|
||||
|
||||
export type TAppConnection =
|
||||
@@ -34,6 +36,7 @@ export type TAppConnection =
|
||||
| TAzureAppConfigurationConnection
|
||||
| TDatabricksConnection
|
||||
| THumanitecConnection
|
||||
| TTerraformCloudConnection
|
||||
| TVercelConnection
|
||||
| TPostgresConnection
|
||||
| TMsSqlConnection
|
||||
@@ -73,6 +76,7 @@ export type TAppConnectionMap = {
|
||||
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection;
|
||||
[AppConnection.Databricks]: TDatabricksConnection;
|
||||
[AppConnection.Humanitec]: THumanitecConnection;
|
||||
[AppConnection.TerraformCloud]: TTerraformCloudConnection;
|
||||
[AppConnection.Vercel]: TVercelConnection;
|
||||
[AppConnection.Postgres]: TPostgresConnection;
|
||||
[AppConnection.MsSql]: TMsSqlConnection;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
|
||||
|
||||
export enum TerraformCloudConnectionMethod {
|
||||
ApiToken = "api-token"
|
||||
}
|
||||
|
||||
export type TTerraformCloudConnection = TRootAppConnection & {
|
||||
app: AppConnection.TerraformCloud;
|
||||
} & {
|
||||
method: TerraformCloudConnectionMethod.ApiToken;
|
||||
credentials: {
|
||||
apiToken: string;
|
||||
};
|
||||
};
|
||||
@@ -106,6 +106,10 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.GET_CMEK]: "Get KMS key",
|
||||
[EventType.CMEK_ENCRYPT]: "Encrypt with KMS key",
|
||||
[EventType.CMEK_DECRYPT]: "Decrypt with KMS key",
|
||||
[EventType.CMEK_SIGN]: "Sign with KMS key",
|
||||
[EventType.CMEK_VERIFY]: "Verify with KMS key",
|
||||
[EventType.CMEK_LIST_SIGNING_ALGORITHMS]: "List signing algorithms for KMS key",
|
||||
[EventType.CMEK_GET_PUBLIC_KEY]: "Get public key for KMS key",
|
||||
[EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]:
|
||||
"Update SSO group to organization role mapping",
|
||||
[EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping",
|
||||
|
||||
@@ -110,6 +110,10 @@ export enum EventType {
|
||||
GET_CMEK = "get-cmek",
|
||||
CMEK_ENCRYPT = "cmek-encrypt",
|
||||
CMEK_DECRYPT = "cmek-decrypt",
|
||||
CMEK_SIGN = "cmek-sign",
|
||||
CMEK_VERIFY = "cmek-verify",
|
||||
CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms",
|
||||
CMEK_GET_PUBLIC_KEY = "cmek-get-public-key",
|
||||
UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
|
||||
GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping",
|
||||
GET_PROJECT_TEMPLATES = "get-project-templates",
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
TCmekDecryptResponse,
|
||||
TCmekEncrypt,
|
||||
TCmekEncryptResponse,
|
||||
TCmekSign,
|
||||
TCmekSignResponse,
|
||||
TCmekVerify,
|
||||
TCmekVerifyResponse,
|
||||
TCreateCmek,
|
||||
TDeleteCmek,
|
||||
TUpdateCmek
|
||||
@@ -74,6 +78,44 @@ export const useCmekEncrypt = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useCmekSign = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
keyId,
|
||||
data,
|
||||
signingAlgorithm,
|
||||
isBase64Encoded
|
||||
}: TCmekSign & { isBase64Encoded: boolean }) => {
|
||||
const res = await apiRequest.post<TCmekSignResponse>(`/api/v1/kms/keys/${keyId}/sign`, {
|
||||
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
|
||||
signingAlgorithm
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCmekVerify = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
keyId,
|
||||
data,
|
||||
signature,
|
||||
signingAlgorithm,
|
||||
isBase64Encoded
|
||||
}: TCmekVerify & { isBase64Encoded: boolean }) => {
|
||||
const res = await apiRequest.post<TCmekVerifyResponse>(`/api/v1/kms/keys/${keyId}/verify`, {
|
||||
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
|
||||
signature,
|
||||
signingAlgorithm
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCmekDecrypt = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
|
||||
export enum KmsKeyUsage {
|
||||
ENCRYPT_DECRYPT = "encrypt-decrypt",
|
||||
SIGN_VERIFY = "sign-verify"
|
||||
}
|
||||
|
||||
export type TCmek = {
|
||||
id: string;
|
||||
keyUsage: KmsKeyUsage;
|
||||
name: string;
|
||||
description?: string;
|
||||
encryptionAlgorithm: EncryptionAlgorithm;
|
||||
encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm;
|
||||
projectId: string;
|
||||
isDisabled: boolean;
|
||||
isReserved: boolean;
|
||||
@@ -17,7 +25,8 @@ export type TCmek = {
|
||||
type ProjectRef = { projectId: string };
|
||||
type KeyRef = { keyId: string };
|
||||
|
||||
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm"> & ProjectRef;
|
||||
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm" | "keyUsage"> &
|
||||
ProjectRef;
|
||||
export type TUpdateCmek = KeyRef &
|
||||
Partial<Pick<TCmek, "name" | "description" | "isDisabled">> &
|
||||
ProjectRef;
|
||||
@@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef;
|
||||
export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean };
|
||||
export type TCmekDecrypt = KeyRef & { ciphertext: string };
|
||||
|
||||
export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm };
|
||||
export type TCmekVerify = KeyRef & {
|
||||
data: string;
|
||||
signature: string;
|
||||
signingAlgorithm: SigningAlgorithm;
|
||||
};
|
||||
|
||||
export type TProjectCmeksList = {
|
||||
keys: TCmek[];
|
||||
totalCount: number;
|
||||
@@ -44,6 +60,18 @@ export type TCmekEncryptResponse = {
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
export type TCmekSignResponse = {
|
||||
signature: string;
|
||||
keyId: string;
|
||||
signingAlgorithm: SigningAlgorithm;
|
||||
};
|
||||
|
||||
export type TCmekVerifyResponse = {
|
||||
signatureValid: boolean;
|
||||
keyId: string;
|
||||
signingAlgorithm: SigningAlgorithm;
|
||||
};
|
||||
|
||||
export type TCmekDecryptResponse = {
|
||||
plaintext: string;
|
||||
};
|
||||
@@ -52,7 +80,35 @@ export enum CmekOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum EncryptionAlgorithm {
|
||||
export enum AsymmetricKeyAlgorithm {
|
||||
RSA_4096 = "RSA_4096",
|
||||
ECC_NIST_P256 = "ECC_NIST_P256"
|
||||
}
|
||||
|
||||
// Supported symmetric encrypt/decrypt algorithms
|
||||
export enum SymmetricKeyAlgorithm {
|
||||
AES_GCM_256 = "aes-256-gcm",
|
||||
AES_GCM_128 = "aes-128-gcm"
|
||||
}
|
||||
|
||||
export const AllowedEncryptionKeyAlgorithms = z.enum([
|
||||
...Object.values(SymmetricKeyAlgorithm),
|
||||
...Object.values(AsymmetricKeyAlgorithm)
|
||||
] as [string, ...string[]]).options;
|
||||
|
||||
export enum SigningAlgorithm {
|
||||
// RSA PSS algorithms
|
||||
RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256",
|
||||
RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384",
|
||||
RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512",
|
||||
|
||||
// RSA PKCS#1 v1.5 algorithms
|
||||
RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256",
|
||||
RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384",
|
||||
RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512",
|
||||
|
||||
// ECDSA algorithms
|
||||
ECDSA_SHA_256 = "ECDSA_SHA_256",
|
||||
ECDSA_SHA_384 = "ECDSA_SHA_384",
|
||||
ECDSA_SHA_512 = "ECDSA_SHA_512"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type DashboardProjectSecretsOverviewResponse = {
|
||||
totalUniqueDynamicSecretsInPage: number;
|
||||
totalUniqueFoldersInPage: number;
|
||||
totalUniqueSecretImportsInPage: number;
|
||||
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
|
||||
totalUniqueSecretRotationsInPage: number;
|
||||
};
|
||||
|
||||
@@ -41,6 +42,16 @@ export type DashboardProjectSecretsDetailsResponse = {
|
||||
totalSecretCount?: number;
|
||||
totalSecretRotationCount?: number;
|
||||
totalCount: number;
|
||||
importedBy?: ProjectSecretsImportedBy[];
|
||||
};
|
||||
|
||||
export type ProjectSecretsImportedBy = {
|
||||
environment: { name: string; slug: string };
|
||||
folders: {
|
||||
name: string;
|
||||
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||
isImported: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DashboardProjectSecretsByKeys = {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TDynamicSecret = {
|
||||
status?: DynamicSecretStatus;
|
||||
statusDetails?: string;
|
||||
maxTTL: string;
|
||||
metadata?: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
export enum DynamicSecretProviders {
|
||||
@@ -261,6 +262,7 @@ export type TDynamicSecretProvider =
|
||||
digits?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCreateDynamicSecretDTO = {
|
||||
projectSlug: string;
|
||||
provider: TDynamicSecretProvider;
|
||||
@@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = {
|
||||
path: string;
|
||||
environmentSlug: string;
|
||||
name: string;
|
||||
metadata?: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
export type TUpdateDynamicSecretDTO = {
|
||||
@@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = {
|
||||
environmentSlug: string;
|
||||
data: {
|
||||
newName?: string;
|
||||
metadata?: { key: string; value: string }[];
|
||||
defaultTTL?: string;
|
||||
maxTTL?: string | null;
|
||||
inputs?: unknown;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type TCloudIntegration = {
|
||||
name: string;
|
||||
slug: string;
|
||||
syncSlug?: string;
|
||||
image: string;
|
||||
isAvailable: boolean;
|
||||
type: string;
|
||||
|
||||
@@ -45,6 +45,7 @@ export type TSecretRotationProviderTemplate = {
|
||||
image?: string;
|
||||
description?: string;
|
||||
template: THttpProviderTemplate | TDbProviderTemplate;
|
||||
isDeprecated?: boolean;
|
||||
};
|
||||
|
||||
export type THttpProviderTemplate = {
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum SecretSync {
|
||||
AzureAppConfiguration = "azure-app-configuration",
|
||||
Databricks = "databricks",
|
||||
Humanitec = "humanitec",
|
||||
TerraformCloud = "terraform-cloud",
|
||||
Camunda = "camunda",
|
||||
Vercel = "vercel"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync";
|
||||
import { TCamundaSync } from "./camunda-sync";
|
||||
import { TGcpSync } from "./gcp-sync";
|
||||
import { THumanitecSync } from "./humanitec-sync";
|
||||
import { TTerraformCloudSync } from "./terraform-cloud-sync";
|
||||
import { TVercelSync } from "./vercel-sync";
|
||||
|
||||
export type TSecretSyncOption = {
|
||||
@@ -27,6 +28,7 @@ export type TSecretSync =
|
||||
| TAzureAppConfigurationSync
|
||||
| TDatabricksSync
|
||||
| THumanitecSync
|
||||
| TTerraformCloudSync
|
||||
| TCamundaSync
|
||||
| TVercelSync;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
|
||||
|
||||
import { TerraformCloudSyncCategory } from "../../appConnections/terraform-cloud";
|
||||
|
||||
export type TTerraformCloudSync = TRootSecretSync & {
|
||||
destination: SecretSync.TerraformCloud;
|
||||
destinationConfig:
|
||||
| {
|
||||
scope: TerraformCloudSyncScope.VariableSet;
|
||||
org: string;
|
||||
category: TerraformCloudSyncCategory;
|
||||
variableSetId: string;
|
||||
variableSetName: string;
|
||||
}
|
||||
| {
|
||||
scope: TerraformCloudSyncScope.Workspace;
|
||||
org: string;
|
||||
category: TerraformCloudSyncCategory;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
connection: {
|
||||
app: AppConnection.TerraformCloud;
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export enum TerraformCloudSyncScope {
|
||||
VariableSet = "variable-set",
|
||||
Workspace = "workspace"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const base64WithPadding =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/;
|
||||
|
||||
export const isBase64 = (str: string): boolean => {
|
||||
if (typeof str !== "string") {
|
||||
throw new TypeError("Expected a string");
|
||||
}
|
||||
|
||||
if (str === "") return true;
|
||||
|
||||
const regex = base64WithPadding;
|
||||
|
||||
return regex.test(str);
|
||||
};
|
||||
@@ -15,13 +15,23 @@ import {
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms";
|
||||
import {
|
||||
AllowedEncryptionKeyAlgorithms,
|
||||
AsymmetricKeyAlgorithm,
|
||||
KmsKeyUsage,
|
||||
SymmetricKeyAlgorithm,
|
||||
TCmek,
|
||||
useCreateCmek,
|
||||
useUpdateCmek
|
||||
} from "@app/hooks/api/cmeks";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 32, field: "Name" }),
|
||||
description: z.string().max(500).optional(),
|
||||
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
|
||||
encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms),
|
||||
keyUsage: z.nativeEnum(KmsKeyUsage)
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
@@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: cmek?.name,
|
||||
description: cmek?.description,
|
||||
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
|
||||
encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256,
|
||||
keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
|
||||
const handleCreateCmek = async ({
|
||||
encryptionAlgorithm,
|
||||
name,
|
||||
description,
|
||||
keyUsage
|
||||
}: FormData) => {
|
||||
const mutation = isUpdate
|
||||
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
|
||||
: createCmek.mutateAsync({
|
||||
projectId,
|
||||
encryptionAlgorithm,
|
||||
name,
|
||||
description
|
||||
description,
|
||||
keyUsage,
|
||||
encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectedKeyUsage = watch("keyUsage");
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateCmek)}>
|
||||
<FormControl
|
||||
@@ -93,23 +114,97 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
|
||||
>
|
||||
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
|
||||
</FormControl>
|
||||
{!isUpdate && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="encryptionAlgorithm"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
|
||||
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
|
||||
<SelectItem value={value} key={`source-environment-${key}`}>
|
||||
{key.replaceAll("_", "-")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{!isUpdate && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyUsage"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
tooltipText={
|
||||
<div className="space-y-4">
|
||||
{Object.entries(KmsKeyUsage).map(([key, value]) => (
|
||||
<div key={`key-usage-${key}`}>
|
||||
<p className="font-bold">{kmsKeyUsageOptions[value].label}</p>
|
||||
<p>{kmsKeyUsageOptions[value].tooltip}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
label="Key Usage"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(e) => {
|
||||
if (keyUsageDefaultOption[e as KmsKeyUsage]) {
|
||||
setValue("encryptionAlgorithm", keyUsageDefaultOption[e as KmsKeyUsage], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{Object.entries(KmsKeyUsage)?.map(([key, value]) => (
|
||||
<SelectItem value={value} key={`key-usage-${key}`}>
|
||||
{kmsKeyUsageOptions[value].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="encryptionAlgorithm"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
label="Algorithm"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
onValueChange={onChange}
|
||||
className="w-full"
|
||||
>
|
||||
{Object.entries(AllowedEncryptionKeyAlgorithms)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
?.filter(([_, value]) => {
|
||||
if (selectedKeyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) {
|
||||
return Object.values(SymmetricKeyAlgorithm).includes(
|
||||
value as unknown as SymmetricKeyAlgorithm
|
||||
);
|
||||
}
|
||||
if (selectedKeyUsage === KmsKeyUsage.SIGN_VERIFY) {
|
||||
return Object.values(AsymmetricKeyAlgorithm).includes(
|
||||
value as unknown as AsymmetricKeyAlgorithm
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(([_, value]) => (
|
||||
<SelectItem value={value} key={`encryption-algorithm-${value}`}>
|
||||
<span className="uppercase">{value.replaceAll("-", " ")}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FormControl
|
||||
label="Description (optional)"
|
||||
errorText={errors.description?.message}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle, faFileSignature, faInfoCircle } 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,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { SigningAlgorithm, TCmek, useCmekSign } from "@app/hooks/api/cmeks";
|
||||
|
||||
const formSchema = z.object({
|
||||
data: z.string(),
|
||||
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
|
||||
isBase64Encoded: z.boolean()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
cmek: TCmek;
|
||||
};
|
||||
|
||||
type FormProps = Pick<Props, "cmek">;
|
||||
|
||||
const SignForm = ({ cmek }: FormProps) => {
|
||||
const cmekSign = useCmekSign();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
|
||||
? SigningAlgorithm.RSASSA_PSS_SHA_512
|
||||
: SigningAlgorithm.ECDSA_SHA_256,
|
||||
isBase64Encoded: false
|
||||
}
|
||||
});
|
||||
|
||||
const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset<string>({
|
||||
initialState: "Copy to Clipboard"
|
||||
});
|
||||
|
||||
const handleSignData = async (formData: FormData) => {
|
||||
try {
|
||||
await cmekSign.mutateAsync({ ...formData, keyId: cmek.id });
|
||||
createNotification({
|
||||
text: "Successfully signed data",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to sign data",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signature = cmekSign.data?.signature;
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(signature ?? "");
|
||||
|
||||
setCopySignature("Copied to Clipboard");
|
||||
};
|
||||
|
||||
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
|
||||
cmek?.encryptionAlgorithm?.startsWith("RSA")
|
||||
? a.toLowerCase().startsWith("rsa")
|
||||
: a.toLowerCase().startsWith("ecdsa")
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleSignData)}>
|
||||
{signature ? (
|
||||
<FormControl label="Data Signature">
|
||||
<TextArea
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
isDisabled
|
||||
value={signature}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<>
|
||||
<FormControl
|
||||
label="Data to Sign"
|
||||
errorText={errors.data?.message}
|
||||
isError={Boolean(errors.data)}
|
||||
>
|
||||
<TextArea
|
||||
{...register("data")}
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="mb-6 flex w-full items-center justify-between gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="signingAlgorithm"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormControl label="Signing Algorithm">
|
||||
<Select onValueChange={onChange} value={value} className="w-full">
|
||||
{allowedSigningAlgorithms.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{a.replaceAll("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="isBase64Encoded"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
|
||||
Data is Base64 encoded{" "}
|
||||
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className={`mr-4 ${signature ? "w-44" : ""}`}
|
||||
size="sm"
|
||||
leftIcon={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
signature ? (
|
||||
isCopyingSignature ? (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faFileSignature} />
|
||||
)
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faFileSignature} />
|
||||
)
|
||||
}
|
||||
onClick={signature ? handleCopyToClipboard : undefined}
|
||||
type={signature ? "button" : "submit"}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{signature ? copySignature : "Sign"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
{signature ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CmekSignModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Sign Data"
|
||||
subTitle={
|
||||
<>
|
||||
Sign data using <span className="font-bold">{cmek?.name}</span>. Returns a Base64
|
||||
encoded signature.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SignForm cmek={cmek} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
faFileSignature,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faLock,
|
||||
@@ -51,14 +52,17 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { kmsKeyUsageOptions } from "@app/helpers/kms";
|
||||
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
|
||||
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
|
||||
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
|
||||
import { CmekDecryptModal } from "./CmekDecryptModal";
|
||||
import { CmekEncryptModal } from "./CmekEncryptModal";
|
||||
import { CmekModal } from "./CmekModal";
|
||||
import { CmekSignModal } from "./CmekSignModal";
|
||||
import { CmekVerifyModal } from "./CmekVerifyModal";
|
||||
import { DeleteCmekModal } from "./DeleteCmekModal";
|
||||
|
||||
const getStatusBadgeProps = (
|
||||
@@ -123,7 +127,9 @@ export const CmekTable = () => {
|
||||
"upsertKey",
|
||||
"deleteKey",
|
||||
"encryptData",
|
||||
"decryptData"
|
||||
"decryptData",
|
||||
"signData",
|
||||
"verifyData"
|
||||
] as const);
|
||||
|
||||
const handleSort = () => {
|
||||
@@ -179,6 +185,15 @@ export const CmekTable = () => {
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
const cannotSignData = permission.cannot(
|
||||
ProjectPermissionCmekActions.Sign,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
const cannotVerifyData = permission.cannot(
|
||||
ProjectPermissionCmekActions.Verify,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
return (
|
||||
<motion.div
|
||||
key="kms-keys-tab"
|
||||
@@ -246,6 +261,7 @@ export const CmekTable = () => {
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Key ID</Th>
|
||||
<Th>Key Usage</Th>
|
||||
<Th>Algorithm</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Version</Th>
|
||||
@@ -257,7 +273,15 @@ export const CmekTable = () => {
|
||||
{!isPending &&
|
||||
keys.length > 0 &&
|
||||
keys.map((cmek) => {
|
||||
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
|
||||
const {
|
||||
name,
|
||||
id,
|
||||
version,
|
||||
description,
|
||||
encryptionAlgorithm,
|
||||
isDisabled,
|
||||
keyUsage
|
||||
} = cmek;
|
||||
const { variant, label } = getStatusBadgeProps(isDisabled);
|
||||
|
||||
return (
|
||||
@@ -295,6 +319,14 @@ export const CmekTable = () => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{kmsKeyUsageOptions[keyUsage].label}
|
||||
<Tooltip content={kmsKeyUsageOptions[keyUsage].tooltip}>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="uppercase">{encryptionAlgorithm}</Td>
|
||||
<Td>
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
@@ -314,50 +346,104 @@ export const CmekTable = () => {
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[160px]">
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotEncryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("encryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLock} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotEncryptData || isDisabled}
|
||||
{keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotEncryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
Encrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotDecryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("decryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotDecryptData || isDisabled}
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("encryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLock} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotEncryptData || isDisabled}
|
||||
>
|
||||
Encrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotDecryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
Decrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("decryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotDecryptData || isDisabled}
|
||||
>
|
||||
Decrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{keyUsage === KmsKeyUsage.SIGN_VERIFY && (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotSignData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("signData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faFileSignature} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotSignData || isDisabled}
|
||||
>
|
||||
Sign Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotVerifyData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("verifyData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotVerifyData || isDisabled}
|
||||
>
|
||||
Verify Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
content={cannotEditKey ? "Access Restricted" : ""}
|
||||
position="left"
|
||||
@@ -456,6 +542,16 @@ export const CmekTable = () => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
|
||||
cmek={popUp.decryptData.data as TCmek}
|
||||
/>
|
||||
<CmekSignModal
|
||||
isOpen={popUp.signData.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("signData", isOpen)}
|
||||
cmek={popUp.signData.data as TCmek}
|
||||
/>
|
||||
<CmekVerifyModal
|
||||
isOpen={popUp.verifyData.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("verifyData", isOpen)}
|
||||
cmek={popUp.verifyData.data as TCmek}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { decodeBase64 } from "tweetnacl-util";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { SigningAlgorithm, TCmek, useCmekVerify } from "@app/hooks/api/cmeks";
|
||||
import { isBase64 } from "@app/lib/fn/base64";
|
||||
|
||||
const formSchema = z.object({
|
||||
data: z.string().min(1, { message: "Data cannot be empty" }),
|
||||
signature: z
|
||||
.string()
|
||||
.min(1, { message: "Signature cannot be empty" })
|
||||
.superRefine((val, ctx) => {
|
||||
if (!isBase64(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Signature must be base64-encoded"
|
||||
});
|
||||
}
|
||||
}),
|
||||
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
|
||||
isBase64Encoded: z.boolean()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
cmek: TCmek;
|
||||
};
|
||||
|
||||
type FormProps = Pick<Props, "cmek">;
|
||||
|
||||
const VerifyForm = ({ cmek }: FormProps) => {
|
||||
const cmekVerify = useCmekVerify();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
control,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
|
||||
? SigningAlgorithm.RSASSA_PSS_SHA_512
|
||||
: SigningAlgorithm.ECDSA_SHA_256,
|
||||
isBase64Encoded: false
|
||||
}
|
||||
});
|
||||
|
||||
const handleVerifyData = async (formData: FormData) => {
|
||||
try {
|
||||
const result = await cmekVerify.mutateAsync({ ...formData, keyId: cmek.id });
|
||||
|
||||
if (result.signatureValid) {
|
||||
createNotification({
|
||||
text: "Successfully verified signature",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
title: "Signature Verification Failed",
|
||||
text: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to sign data",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signature = watch("signature");
|
||||
const data = watch("data");
|
||||
const isBase64Encoded = watch("isBase64Encoded");
|
||||
|
||||
const signatureValid = cmekVerify.data?.signatureValid;
|
||||
const signingAlgorithm = cmekVerify.data?.signingAlgorithm;
|
||||
|
||||
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
|
||||
cmek?.encryptionAlgorithm?.startsWith("RSA")
|
||||
? a.toLowerCase().startsWith("rsa")
|
||||
: a.toLowerCase().startsWith("ecdsa")
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleVerifyData)}>
|
||||
{signatureValid !== undefined ? (
|
||||
<div className="mb-6 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<span className="text-sm opacity-60">Signature Status:</span>
|
||||
<Badge variant={signatureValid ? "success" : "danger"}>
|
||||
<Tooltip
|
||||
content={
|
||||
signatureValid
|
||||
? "The signature is valid. signature was created using the same signing algorithm and key as the one used to sign the data."
|
||||
: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with."
|
||||
}
|
||||
>
|
||||
{signatureValid ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p>Valid</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p>Invalid</p>
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm opacity-60">Signing Algorithm:</span>
|
||||
<Badge variant="primary">{signingAlgorithm}</Badge>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-sm opacity-60">Signature:</span>{" "}
|
||||
<div className="whitespace-pre-wrap break-words rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
|
||||
{signature}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm opacity-60">Data:</span>{" "}
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
|
||||
{isBase64Encoded ? decodeBase64(data).toString() : data}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormControl
|
||||
label="Data to Verify"
|
||||
errorText={errors.data?.message}
|
||||
isError={Boolean(errors.data)}
|
||||
>
|
||||
<TextArea
|
||||
{...register("data")}
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Signature of Data"
|
||||
tooltipText="Must be base64-encoded, like the signature you received when you signed the data."
|
||||
errorText={errors.signature?.message}
|
||||
isError={Boolean(errors.signature)}
|
||||
>
|
||||
<TextArea
|
||||
{...register("signature")}
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="mb-6 flex w-full items-center justify-between gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="signingAlgorithm"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormControl label="Signing Algorithm">
|
||||
<Select onValueChange={onChange} value={value} className="w-full">
|
||||
{allowedSigningAlgorithms.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{a.replaceAll("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="isBase64Encoded"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
|
||||
Data is Base64 encoded{" "}
|
||||
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{signatureValid === undefined && (
|
||||
<Button
|
||||
className="mr-4 w-44"
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faFileSignature} />}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
colorSchema={signatureValid === undefined ? "secondary" : "primary"}
|
||||
variant={signatureValid === undefined ? "plain" : undefined}
|
||||
>
|
||||
{signatureValid !== undefined ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CmekVerifyModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Verify Signature"
|
||||
subTitle={
|
||||
<>
|
||||
Verify a signature using <span className="font-bold">{cmek?.name}</span>.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VerifyForm cmek={cmek} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+5
@@ -20,6 +20,7 @@ import { GitHubConnectionForm } from "./GitHubConnectionForm";
|
||||
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
|
||||
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
|
||||
import { PostgresConnectionForm } from "./PostgresConnectionForm";
|
||||
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
|
||||
import { VercelConnectionForm } from "./VercelConnectionForm";
|
||||
|
||||
type FormProps = {
|
||||
@@ -73,6 +74,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Humanitec:
|
||||
return <HumanitecConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.TerraformCloud:
|
||||
return <TerraformCloudConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Vercel:
|
||||
return <VercelConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Postgres:
|
||||
@@ -133,6 +136,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Humanitec:
|
||||
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.TerraformCloud:
|
||||
return <TerraformCloudConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Vercel:
|
||||
return <VercelConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Postgres:
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import {
|
||||
TerraformCloudConnectionMethod,
|
||||
TTerraformCloudConnection
|
||||
} from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type Props = {
|
||||
appConnection?: TTerraformCloudConnection;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.TerraformCloud)
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(TerraformCloudConnectionMethod.ApiToken),
|
||||
credentials: z.object({
|
||||
apiToken: z.string().trim().min(1, "API Token required")
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const TerraformCloudConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.TerraformCloud,
|
||||
method: TerraformCloudConnectionMethod.ApiToken
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.TerraformCloud].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(TerraformCloudConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}{" "}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.apiToken"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="API Token"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Connect to Terraform Cloud"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import {
|
||||
getConditionOperatorHelperInfo,
|
||||
renderOperatorSelectItems
|
||||
} from "./PermissionConditionHelpers";
|
||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
||||
|
||||
type Props = {
|
||||
position?: number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const DynamicSecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors }
|
||||
} = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions`
|
||||
});
|
||||
|
||||
const conditionErrorMessage =
|
||||
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.message ||
|
||||
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.root
|
||||
?.message;
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
Conditions determine when a policy will be applied (always if no conditions are present).
|
||||
</p>
|
||||
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
|
||||
All conditions must evaluate to true for the policy to take effect.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
const condition = watch(
|
||||
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}`
|
||||
) as {
|
||||
lhs: string;
|
||||
rhs: string;
|
||||
operator: string;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div className="w-1/4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.lhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
setValue(
|
||||
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`,
|
||||
PermissionConditionOperators.$IN as never
|
||||
);
|
||||
field.onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="environment">Environment Slug</SelectItem>
|
||||
<SelectItem value="secretPath">Secret Path</SelectItem>
|
||||
<SelectItem value="metadataKey">Metadata Key</SelectItem>
|
||||
<SelectItem value="metadataValue">Metadata Value</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-36 items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{renderOperatorSelectItems(condition.lhs)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={getConditionOperatorHelperInfo(
|
||||
condition?.operator as PermissionConditionOperators
|
||||
)}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.rhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
ariaLabel="plus"
|
||||
variant="outline_bg"
|
||||
className="p-2.5"
|
||||
onClick={() => items.remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{conditionErrorMessage && (
|
||||
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
|
||||
<FontAwesomeIcon icon={faWarning} className="text-red" />
|
||||
<span>{conditionErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() =>
|
||||
items.append({
|
||||
lhs: "environment",
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
rhs: ""
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+104
-8
@@ -47,7 +47,9 @@ const CmekPolicyActionSchema = z.object({
|
||||
delete: z.boolean().optional(),
|
||||
create: z.boolean().optional(),
|
||||
encrypt: z.boolean().optional(),
|
||||
decrypt: z.boolean().optional()
|
||||
decrypt: z.boolean().optional(),
|
||||
sign: z.boolean().optional(),
|
||||
verify: z.boolean().optional()
|
||||
});
|
||||
|
||||
const DynamicSecretPolicyActionSchema = z.object({
|
||||
@@ -283,11 +285,59 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
|
||||
} else {
|
||||
Object.keys(condition).forEach((conditionOperator) => {
|
||||
const rhs = condition[conditionOperator as PermissionConditionOperators];
|
||||
formConditions.push({
|
||||
operator: conditionOperator,
|
||||
lhs: type,
|
||||
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
|
||||
});
|
||||
if (Array.isArray(rhs) || typeof rhs === "string") {
|
||||
formConditions.push({
|
||||
operator: conditionOperator,
|
||||
lhs: type,
|
||||
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
|
||||
});
|
||||
} else if (
|
||||
conditionOperator === PermissionConditionOperators.$ELEMENTMATCH &&
|
||||
type === "metadata"
|
||||
) {
|
||||
const deepKeyCondition = rhs.key;
|
||||
if (deepKeyCondition) {
|
||||
if (typeof deepKeyCondition === "string") {
|
||||
formConditions.push({
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
lhs: "metadataKey",
|
||||
rhs: deepKeyCondition
|
||||
});
|
||||
} else {
|
||||
Object.keys(deepKeyCondition).forEach((keyOperator) => {
|
||||
const deepRhs = deepKeyCondition?.[keyOperator as PermissionConditionOperators];
|
||||
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
|
||||
formConditions.push({
|
||||
operator: keyOperator,
|
||||
lhs: "metadataKey",
|
||||
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const deepValueCondition = rhs.value;
|
||||
if (deepValueCondition) {
|
||||
if (typeof deepValueCondition === "string") {
|
||||
formConditions.push({
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
lhs: "metadataValue",
|
||||
rhs: deepValueCondition
|
||||
});
|
||||
} else {
|
||||
Object.keys(deepValueCondition).forEach((keyOperator) => {
|
||||
const deepRhs = deepValueCondition?.[keyOperator as PermissionConditionOperators];
|
||||
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
|
||||
formConditions.push({
|
||||
operator: keyOperator,
|
||||
lhs: "metadataValue",
|
||||
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -482,6 +532,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
|
||||
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
|
||||
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
|
||||
const canSign = action.includes(ProjectPermissionCmekActions.Sign);
|
||||
const canVerify = action.includes(ProjectPermissionCmekActions.Verify);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
@@ -492,6 +544,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
if (canDelete) formVal[subject]![0].delete = true;
|
||||
if (canEncrypt) formVal[subject]![0].encrypt = true;
|
||||
if (canDecrypt) formVal[subject]![0].decrypt = true;
|
||||
if (canSign) formVal[subject]![0].sign = true;
|
||||
if (canVerify) formVal[subject]![0].verify = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -630,7 +684,45 @@ const convertFormOperatorToCaslCondition = (
|
||||
conditions: { lhs: string; rhs: string; operator: string }[]
|
||||
) => {
|
||||
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
|
||||
|
||||
const metadataKeyCondition = conditions.find((condition) => condition.lhs === "metadataKey");
|
||||
const metadataValueCondition = conditions.find((condition) => condition.lhs === "metadataValue");
|
||||
|
||||
if (metadataKeyCondition || metadataValueCondition) {
|
||||
caslCondition.metadata = {
|
||||
[PermissionConditionOperators.$ELEMENTMATCH]: {}
|
||||
};
|
||||
|
||||
if (metadataKeyCondition) {
|
||||
const operator = metadataKeyCondition.operator as PermissionConditionOperators;
|
||||
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.key = {
|
||||
[metadataKeyCondition.operator]: [
|
||||
PermissionConditionOperators.$IN,
|
||||
PermissionConditionOperators.$ALL
|
||||
].includes(operator)
|
||||
? metadataKeyCondition.rhs.split(",")
|
||||
: metadataKeyCondition.rhs
|
||||
};
|
||||
}
|
||||
|
||||
if (metadataValueCondition) {
|
||||
const operator = metadataValueCondition.operator as PermissionConditionOperators;
|
||||
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.value = {
|
||||
[metadataValueCondition.operator]: [
|
||||
PermissionConditionOperators.$IN,
|
||||
PermissionConditionOperators.$ALL
|
||||
].includes(operator)
|
||||
? metadataValueCondition.rhs.split(",")
|
||||
: metadataValueCondition.rhs
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
conditions.forEach((el) => {
|
||||
// these are special fields and handled above
|
||||
if (el.lhs === "metadataKey" || el.lhs === "metadataValue") {
|
||||
return;
|
||||
}
|
||||
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
|
||||
if (
|
||||
el.operator === PermissionConditionOperators.$IN ||
|
||||
@@ -641,7 +733,9 @@ const convertFormOperatorToCaslCondition = (
|
||||
caslCondition[el.lhs][
|
||||
el.operator as Exclude<
|
||||
PermissionConditionOperators,
|
||||
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN
|
||||
| PermissionConditionOperators.$ALL
|
||||
| PermissionConditionOperators.$IN
|
||||
| PermissionConditionOperators.$ELEMENTMATCH
|
||||
>
|
||||
] = el.rhs;
|
||||
}
|
||||
@@ -769,7 +863,9 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" },
|
||||
{ label: "Encrypt", value: "encrypt" },
|
||||
{ label: "Decrypt", value: "decrypt" }
|
||||
{ label: "Decrypt", value: "decrypt" },
|
||||
{ label: "Sign", value: "sign" },
|
||||
{ label: "Verify", value: "verify" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Kms]: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { evaluatePermissionsAbility } from "@app/helpers/permissions";
|
||||
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions";
|
||||
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
|
||||
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
|
||||
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
|
||||
@@ -48,6 +49,9 @@ export const renderConditionalComponents = (
|
||||
if (subject === ProjectPermissionSub.Secrets)
|
||||
return <SecretPermissionConditions isDisabled={isDisabled} />;
|
||||
|
||||
if (subject === ProjectPermissionSub.DynamicSecrets)
|
||||
return <DynamicSecretPermissionConditions isDisabled={isDisabled} />;
|
||||
|
||||
if (isConditionalSubjects(subject)) {
|
||||
if (subject === ProjectPermissionSub.Identity) {
|
||||
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs, Tooltip } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -51,46 +51,19 @@ export const IntegrationsListPage = () => {
|
||||
title="Integrations"
|
||||
description="Manage integrations with third-party services."
|
||||
/>
|
||||
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
|
||||
<div className="mb-1 flex items-center text-sm">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
Integrations Update
|
||||
</div>
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
Infisical is excited to announce{" "}
|
||||
<a
|
||||
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/integrations/secret-syncs/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Secret Syncs
|
||||
</a>
|
||||
, a new way to sync your secrets to third-party services using{" "}
|
||||
<a
|
||||
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/integrations/app-connections/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
App Connections
|
||||
</a>
|
||||
, offering deeper customization and re-configurability.
|
||||
</p>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Existing integrations (now called Native Integrations) will continue to be supported
|
||||
as we build out our Secret Sync library.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab value={IntegrationsListPageTabs.SecretSyncs}>
|
||||
Secret Syncs
|
||||
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
|
||||
New
|
||||
</Badge>
|
||||
<Tab value={IntegrationsListPageTabs.SecretSyncs}>Secret Syncs</Tab>
|
||||
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>
|
||||
Native Integrations
|
||||
<Tooltip content="Native Integrations will be deprecated in 2026. Please migrate to Secret Syncs as they become available.">
|
||||
<div>
|
||||
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
|
||||
Legacy
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tab>
|
||||
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>Native Integrations</Tab>
|
||||
<Tab value={IntegrationsListPageTabs.FrameworkIntegrations}>
|
||||
Framework Integrations
|
||||
</Tab>
|
||||
@@ -108,6 +81,26 @@ export const IntegrationsListPage = () => {
|
||||
</ProjectPermissionCan>
|
||||
</TabPanel>
|
||||
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
|
||||
<div className="mb-5 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
|
||||
<div className="mb-1 flex items-center text-sm">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
Native Integrations Transitioning to Legacy Status
|
||||
</div>
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
Native integrations are now a legacy feature and we will begin a phased
|
||||
deprecation in 2026. We recommend migrating to our new{" "}
|
||||
<a
|
||||
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/integrations/secret-syncs/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Secret Syncs
|
||||
</a>{" "}
|
||||
feature which offers the same functionality as Native Integrations with improved
|
||||
stability, insights, re-configurability, and customization.
|
||||
</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionActions.Read}
|
||||
|
||||
+102
-63
@@ -8,6 +8,7 @@ import {
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
@@ -26,7 +28,9 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types";
|
||||
import { IntegrationsListPageTabs } from "@app/types/integrations";
|
||||
|
||||
type Props = {
|
||||
isLoading?: boolean;
|
||||
@@ -40,6 +44,9 @@ type Props = {
|
||||
|
||||
type TRevokeIntegrationPopUp = { provider: string };
|
||||
|
||||
const SECRET_SYNCS = Object.values(SecretSync) as string[];
|
||||
const isSecretSyncAvailable = (type: string) => SECRET_SYNCS.includes(type);
|
||||
|
||||
export const CloudIntegrationSection = ({
|
||||
isLoading,
|
||||
cloudIntegrations = [],
|
||||
@@ -54,6 +61,7 @@ export const CloudIntegrationSection = ({
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isEmpty = !isLoading && !cloudIntegrations?.length;
|
||||
|
||||
@@ -111,73 +119,104 @@ export const CloudIntegrationSection = ({
|
||||
))}
|
||||
|
||||
{!isLoading && filteredIntegrations.length ? (
|
||||
filteredIntegrations.map((cloudIntegration) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative ${
|
||||
cloudIntegration.isAvailable
|
||||
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
|
||||
: "opacity-50"
|
||||
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
onClick={() => {
|
||||
if (!cloudIntegration.isAvailable) return;
|
||||
if (
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.Integrations
|
||||
)
|
||||
) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "You do not have permission to create an integration"
|
||||
});
|
||||
return;
|
||||
}
|
||||
onIntegrationStart(cloudIntegration.slug);
|
||||
}}
|
||||
key={cloudIntegration.slug}
|
||||
>
|
||||
<img
|
||||
src={`/images/integrations/${cloudIntegration.image}`}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
|
||||
<div className="absolute right-0 top-0 z-30 h-full">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute right-0 top-0 w-24 flex-row items-center overflow-hidden whitespace-nowrap rounded-bl-md rounded-tr-md bg-primary px-2 py-0.5 text-xs text-black opacity-80 transition-all duration-300 group-hover:w-0 group-hover:p-0">
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2 text-xs" />
|
||||
Authorized
|
||||
</div>
|
||||
<Tooltip content="Revoke Access">
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
handlePopUpOpen("deleteConfirmation", {
|
||||
provider: cloudIntegration.slug
|
||||
});
|
||||
}}
|
||||
className="absolute right-0 top-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="xl" />
|
||||
filteredIntegrations.map((cloudIntegration) => {
|
||||
const syncSlug = cloudIntegration.syncSlug ?? cloudIntegration.slug;
|
||||
const isSyncAvailable = isSecretSyncAvailable(syncSlug);
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative ${
|
||||
cloudIntegration.isAvailable
|
||||
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
|
||||
: "opacity-50"
|
||||
} flex h-36 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3`}
|
||||
onClick={() => {
|
||||
if (isSyncAvailable) {
|
||||
navigate({
|
||||
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
|
||||
params: {
|
||||
projectId: currentWorkspace.id
|
||||
},
|
||||
search: {
|
||||
selectedTab: IntegrationsListPageTabs.SecretSyncs,
|
||||
addSync: syncSlug as SecretSync
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!cloudIntegration.isAvailable) return;
|
||||
if (
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.Integrations
|
||||
)
|
||||
) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "You do not have permission to create an integration"
|
||||
});
|
||||
return;
|
||||
}
|
||||
onIntegrationStart(cloudIntegration.slug);
|
||||
}}
|
||||
key={cloudIntegration.slug}
|
||||
>
|
||||
<div className="m-auto flex flex-col items-center">
|
||||
<img
|
||||
src={`/images/integrations/${cloudIntegration.image}`}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
<div
|
||||
className={`mt-2 max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200 ${isSyncAvailable ? "mb-4" : ""}`}
|
||||
>
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
|
||||
<div className="absolute right-0 top-0 z-30 h-full">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute right-0 top-0 w-24 flex-row items-center overflow-hidden whitespace-nowrap rounded-bl-md rounded-tr-md bg-primary px-2 py-0.5 text-xs text-black opacity-80 transition-all duration-300 group-hover:w-0 group-hover:p-0">
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2 text-xs" />
|
||||
Authorized
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content="Revoke Access">
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
handlePopUpOpen("deleteConfirmation", {
|
||||
provider: cloudIntegration.slug
|
||||
});
|
||||
}}
|
||||
className="absolute right-0 top-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="xl" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSyncAvailable && (
|
||||
<div className="absolute bottom-0 left-0 z-30 h-full w-full">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute bottom-0 left-0 w-full flex-row overflow-hidden whitespace-nowrap rounded-bl-md rounded-br-md bg-yellow/20 px-2 py-0.5 text-center text-xs text-yellow">
|
||||
Secret Sync Available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyState
|
||||
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
|
||||
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
|
||||
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
|
||||
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
|
||||
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
|
||||
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
|
||||
|
||||
type Props = {
|
||||
@@ -33,6 +34,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
return <DatabricksSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.Humanitec:
|
||||
return <HumanitecSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.TerraformCloud:
|
||||
return <TerraformCloudSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.Camunda:
|
||||
return <CamundaSyncDestinationCol secretSync={secretSync} />;
|
||||
case SecretSync.Vercel:
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { TTerraformCloudSync } from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
|
||||
|
||||
import { getSecretSyncDestinationColValues } from "../helpers";
|
||||
import { SecretSyncTableCell } from "../SecretSyncTableCell";
|
||||
|
||||
type Props = {
|
||||
secretSync: TTerraformCloudSync;
|
||||
};
|
||||
|
||||
export const TerraformCloudSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
};
|
||||
+9
@@ -1,3 +1,4 @@
|
||||
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
|
||||
import {
|
||||
GitHubSyncScope,
|
||||
@@ -73,6 +74,14 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
|
||||
}
|
||||
secondaryText = `Organization - ${destinationConfig.org}`;
|
||||
break;
|
||||
case SecretSync.TerraformCloud:
|
||||
primaryText = destinationConfig.org;
|
||||
if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) {
|
||||
secondaryText = destinationConfig.variableSetName;
|
||||
} else {
|
||||
secondaryText = destinationConfig.workspaceName;
|
||||
}
|
||||
break;
|
||||
case SecretSync.Camunda:
|
||||
primaryText = destinationConfig.clusterName ?? destinationConfig.clusterUUID;
|
||||
secondaryText = "Cluster";
|
||||
|
||||
+23
@@ -1,9 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretSyncModal } from "@app/components/secret-syncs";
|
||||
import { Button, Spinner } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
@@ -14,8 +17,27 @@ import { SecretSyncsTable } from "./SecretSyncTable";
|
||||
export const SecretSyncsTab = () => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
|
||||
|
||||
const { addSync, ...search } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
if (!addSync) return;
|
||||
|
||||
handlePopUpOpen("addSync", addSync);
|
||||
navigate({
|
||||
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
|
||||
params: {
|
||||
projectId: currentWorkspace.id
|
||||
},
|
||||
search
|
||||
});
|
||||
}, [addSync]);
|
||||
|
||||
const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs(
|
||||
currentWorkspace.id,
|
||||
{
|
||||
@@ -76,6 +98,7 @@ export const SecretSyncsTab = () => {
|
||||
<SecretSyncsTable secretSyncs={secretSyncs} />
|
||||
</div>
|
||||
<CreateSecretSyncModal
|
||||
selectSync={popUp.addSync.data}
|
||||
isOpen={popUp.addSync.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { workspaceKeys } from "@app/hooks/api";
|
||||
import { TIntegration } from "@app/hooks/api/integrations/types";
|
||||
import {
|
||||
fetchSecretSyncsByProjectId,
|
||||
SecretSync,
|
||||
secretSyncKeys,
|
||||
TSecretSync
|
||||
} from "@app/hooks/api/secretSyncs";
|
||||
@@ -15,7 +16,8 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
|
||||
import { IntegrationsListPage } from "./IntegrationsListPage";
|
||||
|
||||
const IntegrationsListPageQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional()
|
||||
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(),
|
||||
addSync: z.nativeEnum(SecretSync).optional()
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
|
||||
@@ -207,7 +207,8 @@ export const OverviewPage = () => {
|
||||
ProjectPermissionDynamicSecretActions.CreateRootCredential,
|
||||
subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment: env.slug,
|
||||
secretPath
|
||||
secretPath,
|
||||
metadata: ["*"]
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -272,7 +273,8 @@ export const OverviewPage = () => {
|
||||
totalUniqueSecretsInPage,
|
||||
totalUniqueSecretImportsInPage,
|
||||
totalUniqueDynamicSecretsInPage,
|
||||
totalUniqueSecretRotationsInPage
|
||||
totalUniqueSecretRotationsInPage,
|
||||
importedByEnvs
|
||||
} = overview ?? {};
|
||||
|
||||
const secretImportsShaped = secretImports
|
||||
@@ -1042,6 +1044,7 @@ export const OverviewPage = () => {
|
||||
secretPath={secretPath}
|
||||
selectedEntries={selectedEntries}
|
||||
resetSelectedEntries={resetSelectedEntries}
|
||||
importedByEnvs={importedByEnvs}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-4">
|
||||
<TableContainer
|
||||
|
||||
+94
-1
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
|
||||
import {
|
||||
SecretType,
|
||||
SecretV3RawSanitized,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
TSecretFolder
|
||||
} from "@app/hooks/api/types";
|
||||
import { MoveSecretsModal } from "@app/pages/secret-manager/OverviewPage/components/SelectionPanel/components";
|
||||
import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports";
|
||||
|
||||
export enum EntryType {
|
||||
FOLDER = "folder",
|
||||
@@ -34,9 +37,15 @@ type Props = {
|
||||
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||
};
|
||||
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
|
||||
};
|
||||
|
||||
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
|
||||
export const SelectionPanel = ({
|
||||
secretPath,
|
||||
resetSelectedEntries,
|
||||
selectedEntries,
|
||||
importedByEnvs
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
@@ -72,6 +81,81 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
)
|
||||
);
|
||||
|
||||
const secretsToDeleteKeys = useMemo(() => {
|
||||
return Object.values(selectedEntries.secret).flatMap((entries) =>
|
||||
Object.values(entries).map((secret) => secret.key)
|
||||
);
|
||||
}, [selectedEntries]);
|
||||
|
||||
const filterAndMergeEnvironments = (
|
||||
envNames: string[],
|
||||
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
|
||||
): ProjectSecretsImportedBy[] => {
|
||||
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
|
||||
|
||||
if (filteredEnvs.length === 0) return [];
|
||||
|
||||
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
|
||||
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
|
||||
|
||||
allImportedBy.forEach((item) => {
|
||||
const { slug } = item.environment;
|
||||
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
|
||||
groupedBySlug[slug].push(item);
|
||||
});
|
||||
|
||||
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
|
||||
const { environment } = group[0];
|
||||
const allFolders = group.flatMap((item) => item.folders);
|
||||
|
||||
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
|
||||
allFolders.forEach((folder) => {
|
||||
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
|
||||
foldersByName[folder.name].push(folder);
|
||||
});
|
||||
|
||||
const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => {
|
||||
const isImported = folders.some((folder) => folder.isImported);
|
||||
const allSecrets = folders.flatMap((folder) => folder.secrets || []);
|
||||
|
||||
const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = [];
|
||||
const secretIds = new Set<string>();
|
||||
|
||||
allSecrets
|
||||
.filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey))
|
||||
.forEach((secret) => {
|
||||
if (!secretIds.has(secret.secretId)) {
|
||||
secretIds.add(secret.secretId);
|
||||
uniqueSecrets.push(secret);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
isImported,
|
||||
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
environment,
|
||||
folders: mergedFolders.filter(
|
||||
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return mergedImportedBy;
|
||||
};
|
||||
|
||||
const importedBy = useMemo(() => {
|
||||
if (selectedKeysCount === 0 || !importedByEnvs) return null;
|
||||
return filterAndMergeEnvironments(
|
||||
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
|
||||
importedByEnvs
|
||||
);
|
||||
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
|
||||
|
||||
const getDeleteModalTitle = () => {
|
||||
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
|
||||
return "Do you want to delete the selected secrets and folders across environments?";
|
||||
@@ -241,6 +325,15 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
title={getDeleteModalTitle()}
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||
onDeleteApproved={handleBulkDelete}
|
||||
formContent={
|
||||
importedBy &&
|
||||
importedBy.some((element) => element.folders.length > 0) && (
|
||||
<CollapsibleSecretImports
|
||||
importedBy={importedBy}
|
||||
secretsToDelete={secretsToDeleteKeys}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ const Page = () => {
|
||||
|
||||
const canReadDynamicSecret = permission.can(
|
||||
ProjectPermissionDynamicSecretActions.ReadRootCredential,
|
||||
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
|
||||
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ["*"] })
|
||||
);
|
||||
|
||||
const canReadSecretRotations = permission.can(
|
||||
@@ -219,6 +219,7 @@ const Page = () => {
|
||||
totalDynamicSecretCount = 0,
|
||||
totalSecretCount = 0,
|
||||
totalCount = 0,
|
||||
importedBy,
|
||||
totalSecretRotationCount = 0
|
||||
} = data ?? {};
|
||||
|
||||
@@ -439,6 +440,7 @@ const Page = () => {
|
||||
onToggleRowType={handleToggleRowType}
|
||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||
protectedBranchPolicyName={boardPolicy?.name}
|
||||
importedBy={importedBy}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
|
||||
<div className="flex flex-col" id="dashboard">
|
||||
@@ -527,9 +529,10 @@ const Page = () => {
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
importedBy={importedBy}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
{!canReadSecret &&
|
||||
!canReadDynamicSecret &&
|
||||
!canReadSecretImports &&
|
||||
|
||||
+22
-2
@@ -81,6 +81,7 @@ import {
|
||||
useSelectedSecrets
|
||||
} from "../../SecretMainPage.store";
|
||||
import { Filter, RowType } from "../../SecretMainPage.types";
|
||||
import { CollapsibleSecretImports } from "../SecretListView/CollapsibleSecretImports";
|
||||
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
@@ -112,6 +113,14 @@ type Props = {
|
||||
onVisibilityToggle: () => void;
|
||||
onToggleRowType: (rowType: RowType) => void;
|
||||
onClickRollbackMode: () => void;
|
||||
importedBy?: {
|
||||
environment: { name: string; slug: string };
|
||||
folders: {
|
||||
name: string;
|
||||
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||
isImported: boolean;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export const ActionBar = ({
|
||||
@@ -129,7 +138,8 @@ export const ActionBar = ({
|
||||
onVisibilityToggle,
|
||||
onClickRollbackMode,
|
||||
onToggleRowType,
|
||||
protectedBranchPolicyName
|
||||
protectedBranchPolicyName,
|
||||
importedBy
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"addFolder",
|
||||
@@ -854,7 +864,8 @@ export const ActionBar = ({
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
secretTags: ["*"],
|
||||
metadata: ["*"]
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
@@ -1059,6 +1070,15 @@ export const ActionBar = ({
|
||||
title="Do you want to delete these secrets?"
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||
onDeleteApproved={handleSecretBulkDelete}
|
||||
formContent={
|
||||
importedBy &&
|
||||
importedBy.length > 0 && (
|
||||
<CollapsibleSecretImports
|
||||
importedBy={importedBy}
|
||||
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MoveSecretsModal
|
||||
popUp={popUp}
|
||||
|
||||
+52
-39
@@ -25,6 +25,8 @@ import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
|
||||
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
@@ -82,8 +84,16 @@ const formSchema = z.object({
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
|
||||
environment: z.object({ name: z.string(), slug: z.string() })
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().default("")
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
@@ -192,7 +202,8 @@ export const SqlDatabaseInputForm = ({
|
||||
maxTTL,
|
||||
provider,
|
||||
defaultTTL,
|
||||
environment
|
||||
environment,
|
||||
metadata
|
||||
}: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (createDynamicSecret.isPending) return;
|
||||
@@ -205,7 +216,8 @@ export const SqlDatabaseInputForm = ({
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: environment.slug
|
||||
environmentSlug: environment.slug,
|
||||
metadata
|
||||
});
|
||||
onCompleted();
|
||||
} catch {
|
||||
@@ -283,46 +295,47 @@ export const SqlDatabaseInputForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.projectGatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isProjectGatewaysLoading}
|
||||
placeholder="Internet gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{projectGateways?.map((el) => (
|
||||
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<MetadataForm control={control} />
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.projectGatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isProjectGatewaysLoading}
|
||||
placeholder="Internet gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{projectGateways?.map((el) => (
|
||||
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>
|
||||
<Controller
|
||||
|
||||
+34
-20
@@ -41,6 +41,7 @@ interface FolderProps {
|
||||
isExpanded?: boolean;
|
||||
level: number;
|
||||
basePath?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
@@ -48,6 +49,7 @@ interface TreeViewProps {
|
||||
basePath?: string;
|
||||
className?: string;
|
||||
onChange: (items: SecretItem[]) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
|
||||
@@ -81,7 +83,8 @@ const Folder: React.FC<FolderProps> = ({
|
||||
onFolderSelect,
|
||||
isExpanded = false,
|
||||
level,
|
||||
basePath
|
||||
basePath,
|
||||
isDisabled = false
|
||||
}) => {
|
||||
const [open, setOpen] = useState(isExpanded);
|
||||
const displayName = useMemo(() => getDisplayName(name), [name]);
|
||||
@@ -127,13 +130,16 @@ const Folder: React.FC<FolderProps> = ({
|
||||
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="folder-root"
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={allSelected || someSelected}
|
||||
onCheckedChange={handleFolderSelect}
|
||||
isIndeterminate={someSelected && !allSelected}
|
||||
/>
|
||||
{!isDisabled && (
|
||||
<Checkbox
|
||||
id="folder-root"
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={allSelected || someSelected}
|
||||
onCheckedChange={handleFolderSelect}
|
||||
isIndeterminate={someSelected && !allSelected}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor={`folder-${path}`}
|
||||
@@ -158,12 +164,15 @@ const Folder: React.FC<FolderProps> = ({
|
||||
<div className="ml-6 mr-2">
|
||||
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
|
||||
</div>
|
||||
<Checkbox
|
||||
id={`folder-${item.id}`}
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={selectedItemIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
||||
/>
|
||||
{!isDisabled && (
|
||||
<Checkbox
|
||||
id={`folder-${item.id}`}
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={selectedItemIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="ml-2 flex-1 cursor-pointer truncate"
|
||||
@@ -184,6 +193,7 @@ const Folder: React.FC<FolderProps> = ({
|
||||
onItemSelect={onItemSelect}
|
||||
onFolderSelect={onFolderSelect}
|
||||
level={level + 1}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
|
||||
data,
|
||||
basePath = "/",
|
||||
className = "",
|
||||
onChange
|
||||
onChange,
|
||||
isDisabled = false
|
||||
}) => {
|
||||
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
|
||||
const rootPath = "/";
|
||||
@@ -305,16 +316,19 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
|
||||
onFolderSelect={handleFolderSelect}
|
||||
isExpanded
|
||||
level={0}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pb-2 pr-2 pt-2">
|
||||
<h3 className="flex items-center text-mineshaft-400">
|
||||
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||
</h3>
|
||||
</div>
|
||||
{!isDisabled && (
|
||||
<div className="flex justify-end pb-2 pr-2 pt-2">
|
||||
<h3 className="flex items-center text-mineshaft-400">
|
||||
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+18
-5
@@ -29,11 +29,13 @@ 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 { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
|
||||
|
||||
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
|
||||
|
||||
type Props = {
|
||||
dynamicSecret: TDynamicSecret;
|
||||
dynamicSecretName: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
@@ -48,7 +50,8 @@ export const DynamicSecretLease = ({
|
||||
environment,
|
||||
secretPath,
|
||||
onClickNewLease,
|
||||
onClose
|
||||
onClose,
|
||||
dynamicSecret
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteSecret",
|
||||
@@ -140,7 +143,11 @@ export const DynamicSecretLease = ({
|
||||
<div className="flex items-center space-x-4">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Renew"
|
||||
>
|
||||
@@ -159,7 +166,11 @@ export const DynamicSecretLease = ({
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
@@ -181,7 +192,8 @@ export const DynamicSecretLease = ({
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
|
||||
@@ -215,7 +227,8 @@ export const DynamicSecretLease = ({
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath
|
||||
secretPath,
|
||||
metadata: dynamicSecret.metadata
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
|
||||
+16
-3
@@ -144,7 +144,11 @@ export const DynamicSecretListView = ({
|
||||
<div className="flex items-center space-x-2 px-4 py-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.Lease}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: secret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
@@ -186,7 +190,11 @@ export const DynamicSecretListView = ({
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: secret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
@@ -208,7 +216,11 @@ export const DynamicSecretListView = ({
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.DeleteRootCredential}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
metadata: secret.metadata
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
@@ -236,6 +248,7 @@ export const DynamicSecretListView = ({
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<DynamicSecretLease
|
||||
dynamicSecret={secret}
|
||||
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
|
||||
onClose={() => handlePopUpClose("dynamicSecretLeases")}
|
||||
projectSlug={projectSlug}
|
||||
|
||||
+61
-40
@@ -23,6 +23,8 @@ import { useWorkspace } from "@app/context";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
import { MetadataForm } from "../MetadataForm";
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
@@ -85,6 +87,13 @@ const formSchema = z.object({
|
||||
newName: z
|
||||
.string()
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().default("")
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
@@ -126,6 +135,7 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
newName: dynamicSecret.name,
|
||||
metadata: dynamicSecret.metadata,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"]),
|
||||
passwordRequirements:
|
||||
@@ -147,7 +157,13 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
const isGatewayInActive =
|
||||
projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1;
|
||||
|
||||
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
|
||||
const handleUpdateDynamicSecret = async ({
|
||||
inputs,
|
||||
maxTTL,
|
||||
defaultTTL,
|
||||
newName,
|
||||
metadata
|
||||
}: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
try {
|
||||
@@ -163,7 +179,8 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
...inputs,
|
||||
projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId
|
||||
},
|
||||
newName: newName === dynamicSecret.name ? undefined : newName
|
||||
newName: newName === dynamicSecret.name ? undefined : newName,
|
||||
metadata
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
@@ -229,46 +246,50 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.projectGatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message) || isGatewayInActive}
|
||||
errorText={
|
||||
isGatewayInActive && selectedProjectGatewayId
|
||||
? `Project Gateway ${selectedProjectGatewayId} is removed`
|
||||
: error?.message
|
||||
}
|
||||
label="Gateway"
|
||||
helperText=""
|
||||
>
|
||||
<Select
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isProjectGatewaysLoading}
|
||||
placeholder="Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem value={null as unknown as string} onClick={() => onChange(undefined)}>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{projectGateways?.map((el) => (
|
||||
<SelectItem value={el.projectGatewayId} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<MetadataForm control={control} />
|
||||
<div>
|
||||
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.projectGatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message) || isGatewayInActive}
|
||||
errorText={
|
||||
isGatewayInActive && selectedProjectGatewayId
|
||||
? `Project Gateway ${selectedProjectGatewayId} is removed`
|
||||
: error?.message
|
||||
}
|
||||
label="Gateway"
|
||||
helperText=""
|
||||
>
|
||||
<Select
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isProjectGatewaysLoading}
|
||||
placeholder="Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{projectGateways?.map((el) => (
|
||||
<SelectItem value={el.projectGatewayId} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Control, Controller, useFieldArray } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
|
||||
|
||||
export const MetadataForm = ({ control }: { control: Control<any> }) => {
|
||||
const metadataFormFields = useFieldArray({
|
||||
control,
|
||||
name: "metadata"
|
||||
});
|
||||
|
||||
return (
|
||||
<FormControl label="Metadata">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
|
||||
<div key={metadataFieldId} className="flex items-end space-x-2">
|
||||
<div className="flex-grow">
|
||||
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.key`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Input {...field} className="max-h-8" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
{i === 0 && (
|
||||
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Input {...field} className="max-h-8" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
ariaLabel="delete key"
|
||||
className="bottom-0.5 max-h-8"
|
||||
variant="outline_bg"
|
||||
onClick={() => metadataFormFields.remove(i)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className={`${metadataFormFields.fields.length > 0 ? "pt-2" : ""}`}>
|
||||
<IconButton
|
||||
ariaLabel="Add Key"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="rounded-md"
|
||||
onClick={() => metadataFormFields.append({ key: "", value: "" })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
|
||||
enum ItemType {
|
||||
Folder = "Folder",
|
||||
Secret = "Secret"
|
||||
}
|
||||
|
||||
interface FlatItem {
|
||||
type: ItemType;
|
||||
path: string;
|
||||
secretKey?: string;
|
||||
reference: string;
|
||||
id: string;
|
||||
environment: { name: string; slug: string };
|
||||
}
|
||||
|
||||
interface CollapsibleSecretImportsProps {
|
||||
importedBy?: {
|
||||
environment: { name: string; slug: string };
|
||||
folders: {
|
||||
name: string;
|
||||
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||
isImported: boolean;
|
||||
}[];
|
||||
}[];
|
||||
secretsToDelete: string[];
|
||||
}
|
||||
|
||||
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
|
||||
importedBy = [],
|
||||
secretsToDelete
|
||||
}) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const truncatePath = (path: string, maxLength = 24): string => {
|
||||
if (path.length <= maxLength) return path;
|
||||
|
||||
const endPortion = path.slice(-(maxLength - 3));
|
||||
const slashIndex = endPortion.indexOf("/");
|
||||
|
||||
if (slashIndex !== -1) {
|
||||
return `...${endPortion.slice(slashIndex)}`;
|
||||
}
|
||||
|
||||
return `...${endPortion}`;
|
||||
};
|
||||
|
||||
const handlePathClick = (item: FlatItem) => {
|
||||
let pathToNavigate;
|
||||
if (item.type === ItemType.Folder) {
|
||||
pathToNavigate = item.path;
|
||||
} else {
|
||||
const lastSlashIndex = item.path.lastIndexOf("/");
|
||||
pathToNavigate = lastSlashIndex > 0 ? item.path.substring(0, lastSlashIndex) : "/";
|
||||
}
|
||||
const encodedPath = encodeURIComponent(pathToNavigate);
|
||||
window.open(
|
||||
`/secret-manager/${currentWorkspace.id}/secrets/${item.environment.slug}?secretPath=${encodedPath}`,
|
||||
"_blank"
|
||||
);
|
||||
};
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const items: FlatItem[] = [];
|
||||
|
||||
importedBy.forEach((env) => {
|
||||
env.folders.forEach((folder) => {
|
||||
if (folder.isImported) {
|
||||
items.push({
|
||||
type: ItemType.Folder,
|
||||
path: folder.name,
|
||||
id: `folder-${env.environment.name}-${folder.name}`,
|
||||
reference: "Imported",
|
||||
environment: env.environment
|
||||
});
|
||||
}
|
||||
|
||||
if (folder.secrets?.length) {
|
||||
folder.secrets.forEach(({ secretId: secret, referencedSecretKey }) => {
|
||||
const secretPath = folder.name === "/" ? `/${secret}` : `${folder.name}/${secret}`;
|
||||
if (
|
||||
secretsToDelete.includes(referencedSecretKey) &&
|
||||
!items.some(
|
||||
(item) => item.environment.name === env.environment.name && item.path === secretPath
|
||||
)
|
||||
) {
|
||||
items.push({
|
||||
type: ItemType.Secret,
|
||||
path: secretPath,
|
||||
secretKey: referencedSecretKey,
|
||||
id: `secret-${env.environment.name}-${secretPath}`,
|
||||
reference: "Referenced",
|
||||
environment: env.environment
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const envCompare = a.environment.name.localeCompare(b.environment.name);
|
||||
if (envCompare !== 0) return envCompare;
|
||||
|
||||
const aPath = a.path.startsWith("/") ? a.path : `/${a.path}`;
|
||||
const bPath = b.path.startsWith("/") ? b.path : `/${b.path}`;
|
||||
|
||||
const aSegments = aPath.split("/").filter(Boolean).length;
|
||||
const bSegments = bPath.split("/").filter(Boolean).length;
|
||||
|
||||
if (aSegments !== bSegments) {
|
||||
return aSegments - bSegments;
|
||||
}
|
||||
|
||||
return aPath.localeCompare(bPath);
|
||||
});
|
||||
}, [importedBy]);
|
||||
|
||||
const hasImportedItems = importedBy.some((element) => {
|
||||
if (element.folders && element.folders.length > 0) {
|
||||
return element.folders.some(
|
||||
(folder) =>
|
||||
folder.isImported ||
|
||||
(folder.secrets &&
|
||||
folder.secrets.length > 0 &&
|
||||
folder.secrets.some((secret) => secretsToDelete.includes(secret.referencedSecretKey)))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!hasImportedItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full">
|
||||
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div className="mt-0.5 flex-shrink-0 text-red-500">
|
||||
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm font-semibold text-red-500">
|
||||
The following resources will be affected by this change
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-mineshaft-700">
|
||||
<Table>
|
||||
<THead className="sticky -top-1 bg-bunker-800">
|
||||
<Th className="px-4">Type</Th>
|
||||
<Th className="px-4">Environment</Th>
|
||||
<Th className="truncate px-4">Path</Th>
|
||||
<Th className="px-4">Usage</Th>
|
||||
</THead>
|
||||
<TBody>
|
||||
{flattenedItems.map((item) => (
|
||||
<Tr
|
||||
key={item.id}
|
||||
onClick={() => handlePathClick(item)}
|
||||
className="cursor-pointer hover:bg-mineshaft-700"
|
||||
title={`Navigate to ${item.path}`}
|
||||
>
|
||||
<Td>
|
||||
<FontAwesomeIcon
|
||||
icon={item.type === ItemType.Secret ? faKey : faFileImport}
|
||||
className={`h-4 w-4 ${item.type === ItemType.Secret ? "text-gray-400" : "text-green-700"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Td>
|
||||
<Td className="px-4">{item.environment.name}</Td>
|
||||
<Td className="truncate px-4">{truncatePath(item.path)}</Td>
|
||||
<Td className="px-4">{item.reference}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+29
-1
@@ -16,6 +16,7 @@ import { WsTag } from "@app/hooks/api/types";
|
||||
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
|
||||
|
||||
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
||||
import { SecretItem } from "./SecretItem";
|
||||
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
||||
@@ -28,6 +29,14 @@ type Props = {
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
isProtectedBranch?: boolean;
|
||||
importedBy?: {
|
||||
environment: { name: string; slug: string };
|
||||
folders: {
|
||||
name: string;
|
||||
secrets?: { secretId: string; referencedSecretKey: string }[];
|
||||
isImported: boolean;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export const SecretListView = ({
|
||||
@@ -37,7 +46,8 @@ export const SecretListView = ({
|
||||
secretPath = "/",
|
||||
tags: wsTags = [],
|
||||
isVisible,
|
||||
isProtectedBranch = false
|
||||
isProtectedBranch = false,
|
||||
importedBy
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
@@ -357,6 +367,24 @@ export const SecretListView = ({
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
||||
onDeleteApproved={handleSecretDelete}
|
||||
buttonText="Delete Secret"
|
||||
formContent={
|
||||
importedBy &&
|
||||
importedBy.length > 0 && (
|
||||
<CollapsibleSecretImports
|
||||
importedBy={importedBy}
|
||||
secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
deletionMessage={
|
||||
<>
|
||||
Type the secret key{" "}
|
||||
<span className="font-bold">
|
||||
"{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}"
|
||||
</span>{" "}
|
||||
to perform this action
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SecretDetailSidebar
|
||||
environment={environment}
|
||||
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
@@ -47,20 +52,29 @@ import {
|
||||
useGetSecretRotations,
|
||||
useRestartSecretRotation
|
||||
} from "@app/hooks/api";
|
||||
import { TSecretRotationProviderTemplate } from "@app/hooks/api/secretRotation/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { CreateRotationForm } from "@app/pages/secret-manager/SecretRotationPage/components/CreateRotationForm";
|
||||
|
||||
const Page = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"createRotation",
|
||||
"activeBot",
|
||||
"deleteRotation",
|
||||
"upgradePlan",
|
||||
"secretRotationV2"
|
||||
] as const);
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const canCreateRotation = permission.can(
|
||||
ProjectPermissionSecretRotationActions.Create,
|
||||
ProjectPermissionSub.SecretRotation
|
||||
);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data: secretRotationProviders, isPending: isRotationProviderLoading } =
|
||||
useGetSecretRotationProviders({ workspaceId });
|
||||
@@ -119,6 +133,18 @@ const Page = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRotation = (provider: TSecretRotationProviderTemplate) => {
|
||||
if (subscription && !subscription?.secretRotation) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
if (!canCreateRotation) {
|
||||
createNotification({ type: "error", text: "Access permission denied!!" });
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("createRotation", provider);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto w-full max-w-7xl bg-bunker-800 text-white">
|
||||
<PageHeader
|
||||
@@ -144,7 +170,7 @@ const Page = () => {
|
||||
Infisical is revamping its Secret Rotation experience.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-bunker-200">
|
||||
Secret Rotations can now be created from the{" "}
|
||||
PostgreSQL and Microsoft SQL Server Rotations can now be created from the{" "}
|
||||
<Link
|
||||
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
|
||||
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
|
||||
@@ -308,11 +334,20 @@ const Page = () => {
|
||||
key={`infisical-rotation-provider-${provider.name}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={() => {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key !== "Enter") return;
|
||||
if (provider.isDeprecated) {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
} else {
|
||||
handleCreateRotation(provider);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
if (provider.isDeprecated) {
|
||||
handlePopUpOpen("secretRotationV2", provider.title);
|
||||
} else {
|
||||
handleCreateRotation(provider);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -344,6 +379,12 @@ const Page = () => {
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<CreateRotationForm
|
||||
isOpen={popUp.createRotation.isOpen}
|
||||
workspaceId={workspaceId}
|
||||
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
|
||||
provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRotation.isOpen}
|
||||
title="Are you sure want to delete this rotation?"
|
||||
@@ -372,7 +413,7 @@ const Page = () => {
|
||||
>
|
||||
Secret Manager Dashboard
|
||||
</Link>{" "}
|
||||
to create a Secret Rotation.
|
||||
to create a {popUp.secretRotationV2.data} Rotation.
|
||||
</p>
|
||||
<div className="overflow-clip rounded border border-mineshaft-600">
|
||||
<img
|
||||
|
||||
+4
@@ -19,6 +19,7 @@ import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinat
|
||||
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
|
||||
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
|
||||
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
|
||||
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
|
||||
import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection";
|
||||
|
||||
type Props = {
|
||||
@@ -59,6 +60,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
|
||||
case SecretSync.Humanitec:
|
||||
DestinationComponents = <HumanitecSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
case SecretSync.TerraformCloud:
|
||||
DestinationComponents = <TerraformCloudSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
case SecretSync.Camunda:
|
||||
DestinationComponents = <CamundaSyncDestinationSection secretSync={secretSync} />;
|
||||
break;
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TerraformCloudSyncCategory } from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import {
|
||||
TerraformCloudSyncScope,
|
||||
TTerraformCloudSync
|
||||
} from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
|
||||
|
||||
type Props = {
|
||||
secretSync: TTerraformCloudSync;
|
||||
};
|
||||
|
||||
export const TerraformCloudSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
let Components: ReactNode;
|
||||
switch (destinationConfig.scope) {
|
||||
case TerraformCloudSyncScope.VariableSet:
|
||||
Components = (
|
||||
<>
|
||||
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Variable Set">
|
||||
{destinationConfig.variableSetName}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Category">
|
||||
{Object.keys(TerraformCloudSyncCategory).find(
|
||||
(key) =>
|
||||
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
|
||||
destinationConfig.category
|
||||
)}
|
||||
</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case TerraformCloudSyncScope.Workspace:
|
||||
Components = (
|
||||
<>
|
||||
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Workspace">{destinationConfig.workspaceName}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Category">
|
||||
{Object.keys(TerraformCloudSyncCategory).find(
|
||||
(key) =>
|
||||
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
|
||||
destinationConfig.category
|
||||
)}
|
||||
</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled Terraform Cloud Sync Destination Section Scope ${secretSync.destinationConfig.scope}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel className="capitalize" label="Scope">
|
||||
{destinationConfig.scope.replace("-", " ")}
|
||||
</GenericFieldLabel>
|
||||
{Components}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -48,6 +48,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
|
||||
case SecretSync.AzureAppConfiguration:
|
||||
case SecretSync.Databricks:
|
||||
case SecretSync.Humanitec:
|
||||
case SecretSync.TerraformCloud:
|
||||
case SecretSync.Camunda:
|
||||
case SecretSync.Vercel:
|
||||
AdditionalSyncOptionsComponent = null;
|
||||
|
||||
Reference in New Issue
Block a user