Merge remote-tracking branch 'origin' into groups-phase-2c

This commit is contained in:
Tuan Dang
2024-04-25 06:50:02 -07:00
154 changed files with 7890 additions and 953 deletions
@@ -25,23 +25,24 @@ export const UpgradeProjectAlert = ({
project,
transparent
}: UpgradeProjectAlertProps): JSX.Element | null => {
const router = useRouter();
const { membership } = useProjectPermission();
const { hasProjectRole } = useProjectPermission();
const upgradeProject = useUpgradeProject();
const [currentStatus, setCurrentStatus] = useState<string | null>(null);
const [isUpgrading, setIsUpgrading] = useState(false);
const isProjectAdmin = hasProjectRole("admin");
const {
data: projectStatus,
isLoading: statusIsLoading,
refetch: manualProjectStatusRefetch
} = useGetUpgradeProjectStatus({
projectId: project.id,
enabled: membership.role === "admin" && project.version === ProjectVersion.V1,
enabled: isProjectAdmin && project.version === ProjectVersion.V1,
refetchInterval: 5_000,
onSuccess: (data) => {
if (membership.role !== "admin") {
if (!isProjectAdmin) {
return;
}
@@ -101,7 +102,7 @@ export const UpgradeProjectAlert = ({
variant="solid"
size="md"
isLoading={isLoading}
isDisabled={isLoading || membership.role !== "admin"}
isDisabled={isLoading || !isProjectAdmin}
onClick={onUpgradeProject}
>
Upgrade
@@ -113,13 +114,13 @@ export const UpgradeProjectAlert = ({
<div
className={twMerge(
"mt-4 flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white",
membership.role !== "admin" && "opacity-80"
!isProjectAdmin && "opacity-80"
)}
>
<FontAwesomeIcon icon={faWarning} className="pr-6 text-6xl text-white/80" />
<div className="flex w-full flex-col text-sm">
<span className="mb-2 text-lg font-semibold">Upgrade your project</span>
{membership.role === "admin" ? (
{isProjectAdmin ? (
<>
<p>
Upgrade your project version to continue receiving the latest improvements and
@@ -150,12 +151,12 @@ export const UpgradeProjectAlert = ({
</div>
<div className="my-2">
<Tooltip
className={twMerge(membership.role === "admin" && "hidden")}
className={twMerge(isProjectAdmin && "hidden")}
content="You need to be an admin to upgrade the project."
>
<Button
isLoading={isLoading}
isDisabled={isLoading || membership.role !== "admin"}
isDisabled={isLoading || !isProjectAdmin}
onClick={onUpgradeProject}
>
Upgrade
@@ -56,5 +56,7 @@ export const useProjectPermission = () => {
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
}
return ctx;
const hasProjectRole = (role: string) => ctx?.membership?.roles?.includes(role) || false;
return { ...ctx, hasProjectRole };
};
+32 -15
View File
@@ -16,7 +16,8 @@ export type TDynamicSecret = {
};
export enum DynamicSecretProviders {
SqlDatabase = "sql-database"
SqlDatabase = "sql-database",
Cassandra = "cassandra"
}
export enum SqlProviders {
@@ -25,21 +26,37 @@ export enum SqlProviders {
Oracle = "oracledb"
}
export type TDynamicSecretProvider = {
type: DynamicSecretProviders;
inputs: {
client: SqlProviders;
host: string;
port: number;
database: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
export type TDynamicSecretProvider =
| {
type: DynamicSecretProviders.SqlDatabase;
inputs: {
client: SqlProviders;
host: string;
port: number;
database: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
};
}
| {
type: DynamicSecretProviders.Cassandra;
inputs: {
host: string;
port: number;
keyspace?: string;
localDataCenter: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
};
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
@@ -10,6 +10,7 @@ import {
Environment,
HerokuPipelineCoupling,
IntegrationAuth,
KmsKey,
NorthflankSecretGroup,
Org,
Project,
@@ -43,6 +44,14 @@ const integrationAuthKeys = {
[{ integrationAuthId }, "integrationAuthGithubOrgs"] as const,
getIntegrationAuthGithubEnvs: (integrationAuthId: string, repoName: string, repoOwner: string) =>
[{ integrationAuthId, repoName, repoOwner }, "integrationAuthGithubOrgs"] as const,
getIntegrationAuthAwsKmsKeys: ({
integrationAuthId,
region
}: {
integrationAuthId: string,
region: string
}) =>
[{ integrationAuthId, region }, "integrationAuthAwsKmsKeyIds"] as const,
getIntegrationAuthQoveryOrgs: (integrationAuthId: string) =>
[{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const,
getIntegrationAuthQoveryProjects: ({
@@ -217,6 +226,27 @@ const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => {
return orgs;
};
const fetchIntegrationAuthAwsKmsKeys = async ({
integrationAuthId,
region
}: {
integrationAuthId: string;
region: string;
}) => {
const {
data: { kmsKeys }
} = await apiRequest.get<{ kmsKeys: KmsKey[] }>(
`/api/v1/integration-auth/${integrationAuthId}/aws-secrets-manager/kms-keys`,
{
params: {
region
}
}
);
return kmsKeys;
};
const fetchIntegrationAuthQoveryProjects = async ({
integrationAuthId,
orgId
@@ -544,6 +574,27 @@ export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => {
});
};
export const useGetIntegrationAuthAwsKmsKeys = ({
integrationAuthId,
region
}: {
integrationAuthId: string;
region: string;
}) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthAwsKmsKeys({
integrationAuthId,
region
}),
queryFn: () =>
fetchIntegrationAuthAwsKmsKeys({
integrationAuthId,
region
}),
enabled: true
});
};
export const useGetIntegrationAuthQoveryProjects = ({
integrationAuthId,
orgId
@@ -58,6 +58,11 @@ export type Project = {
projectId: string;
};
export type KmsKey = {
id: string;
alias: string;
};
export type Service = {
name: string;
serviceId: string;
@@ -66,7 +66,8 @@ export const useCreateIntegration = () => {
secretAWSTag?: {
key: string;
value: string;
};
}[];
kmsKeyId?: string;
};
}) => {
const {
+7 -1
View File
@@ -1 +1,7 @@
export { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "./queries";
export {
useCreateLDAPConfig,
useCreateLDAPGroupMapping,
useDeleteLDAPGroupMapping,
useTestLDAPConnection,
useUpdateLDAPConfig} from "./mutations";
export { useGetLDAPConfig, useGetLDAPGroupMaps } from "./queries";
@@ -0,0 +1,155 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ldapConfigKeys } from "./queries";
export const useCreateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
}: {
organizationId: string;
isActive: boolean;
url: string;
bindDN: string;
bindPass: string;
searchBase: string;
searchFilter: string;
groupSearchBase: string;
groupSearchFilter: string;
caCert?: string;
}) => {
const { data } = await apiRequest.post("/api/v1/ldap/config", {
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
});
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
});
};
export const useUpdateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
}: {
organizationId: string;
isActive?: boolean;
url?: string;
bindDN?: string;
bindPass?: string;
searchBase?: string;
searchFilter?: string;
groupSearchBase?: string;
groupSearchFilter?: string;
caCert?: string;
}) => {
const { data } = await apiRequest.patch("/api/v1/ldap/config", {
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
});
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
});
};
export const useCreateLDAPGroupMapping = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
ldapConfigId,
ldapGroupCN,
groupSlug
}: {
ldapConfigId: string;
ldapGroupCN: string;
groupSlug: string;
}) => {
const { data } = await apiRequest.post(`/api/v1/ldap/config/${ldapConfigId}/group-maps`, {
ldapGroupCN,
groupSlug
});
return data;
},
onSuccess(_, { ldapConfigId }) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPGroupMaps(ldapConfigId));
}
});
};
export const useDeleteLDAPGroupMapping = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
ldapConfigId,
ldapGroupMapId
}: {
ldapConfigId: string;
ldapGroupMapId: string;
}) => {
const { data } = await apiRequest.delete(
`/api/v1/ldap/config/${ldapConfigId}/group-maps/${ldapGroupMapId}`
);
return data;
},
onSuccess(_, { ldapConfigId }) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPGroupMaps(ldapConfigId));
}
});
};
export const useTestLDAPConnection = () => {
return useMutation({
mutationFn: async (ldapConfigId: string) => {
const { data } = await apiRequest.post<boolean>(
`/api/v1/ldap/config/${ldapConfigId}/test-connection`
);
return data;
}
});
};
+16 -73
View File
@@ -1,9 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
const ldapConfigKeys = {
getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const
import { LDAPGroupMap } from "./types";
export const ldapConfigKeys = {
getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const,
getLDAPGroupMaps: (ldapConfigId: string) => [{ ldapConfigId }, "ldap-group-maps"] as const
};
export const useGetLDAPConfig = (organizationId: string) => {
@@ -18,78 +21,18 @@ export const useGetLDAPConfig = (organizationId: string) => {
});
};
export const useCreateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}: {
organizationId: string;
isActive: boolean;
url: string;
bindDN: string;
bindPass: string;
searchBase: string;
caCert?: string;
}) => {
const { data } = await apiRequest.post("/api/v1/ldap/config", {
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
});
export const useGetLDAPGroupMaps = (ldapConfigId: string) => {
return useQuery({
queryKey: ldapConfigKeys.getLDAPGroupMaps(ldapConfigId),
queryFn: async () => {
if (!ldapConfigId) return [];
const { data } = await apiRequest.get<LDAPGroupMap[]>(
`/api/v1/ldap/config/${ldapConfigId}/group-maps`
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
});
};
export const useUpdateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}: {
organizationId: string;
isActive?: boolean;
url?: string;
bindDN?: string;
bindPass?: string;
searchBase?: string;
caCert?: string;
}) => {
const { data } = await apiRequest.patch("/api/v1/ldap/config", {
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
});
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
enabled: true
});
};
@@ -0,0 +1,10 @@
export type LDAPGroupMap = {
id: string;
ldapConfigId: string;
ldapGroupCN: string;
group: {
id: string;
name: string;
slug: string;
};
};
+8 -2
View File
@@ -106,7 +106,7 @@ const getUserProjectPermissions = async ({ workspaceId }: TGetUserProjectPermiss
const { data } = await apiRequest.get<{
data: {
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
membership: TProjectMembership;
membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] };
};
}>(`/api/v1/workspace/${workspaceId}/permissions`, {});
@@ -121,6 +121,12 @@ export const useGetUserProjectPermissions = ({ workspaceId }: TGetUserProjectPer
select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
const ability = createMongoAbility<ProjectPermissionSet>(rule, { conditionsMatcher });
return { permission: ability, membership: data.membership };
const membership = {
...data.membership,
roles: data.membership.roles.map(({ role }) => role)
};
return { permission: ability, membership };
}
});
+23 -23
View File
@@ -62,7 +62,7 @@ export type TProjectMembership = {
createdAt: string;
updatedAt: string;
projectId: string;
roleId: string;
roles: string[];
};
export type TWorkspaceUser = {
@@ -79,29 +79,29 @@ export type TWorkspaceUser = {
organization: string;
roles: (
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
@@ -128,10 +128,10 @@ export default function AWSParameterStoreCreateIntegrationPage() {
metadata: {
...(shouldTag
? {
secretAWSTag: {
secretAWSTag: [{
key: tagKey,
value: tagValue
}
}]
}
: {})
}
@@ -279,7 +279,7 @@ export default function AWSParameterStoreCreateIntegrationPage() {
label="Tag Value"
>
<Input
placeholder="managed-by"
placeholder="infisical"
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
@@ -14,6 +14,7 @@ import { motion } from "framer-motion";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { useGetIntegrationAuthAwsKmsKeys } from "@app/hooks/api/integrationAuth/queries";
import {
Button,
@@ -87,6 +88,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
const [targetSecretNameErrorText, setTargetSecretNameErrorText] = useState("");
const [tagKey, setTagKey] = useState("");
const [tagValue, setTagValue] = useState("");
const [kmsKeyId, setKmsKeyId] = useState("");
// const [path, setPath] = useState('');
// const [pathErrorText, setPathErrorText] = useState('');
@@ -94,6 +96,19 @@ export default function AWSSecretManagerCreateIntegrationPage() {
const [isLoading, setIsLoading] = useState(false);
const [shouldTag, setShouldTag] = useState(false);
const { data: integrationAuthAwsKmsKeys, isLoading: isIntegrationAuthAwsKmsKeysLoading } =
useGetIntegrationAuthAwsKmsKeys({
integrationAuthId: String(integrationAuthId),
region: selectedAWSRegion
});
useEffect(() => {
if (integrationAuthAwsKmsKeys) {
setKmsKeyId(String(integrationAuthAwsKmsKeys?.filter(key => key.alias === "alias/aws/secretsmanager")[0]?.id))
}
}, [integrationAuthAwsKmsKeys])
useEffect(() => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
@@ -127,12 +142,16 @@ export default function AWSSecretManagerCreateIntegrationPage() {
metadata: {
...(shouldTag
? {
secretAWSTag: {
secretAWSTag: [{
key: tagKey,
value: tagValue
}
}]
}
: {})
: {}),
...((kmsKeyId && integrationAuthAwsKmsKeys?.filter(key => key.id === kmsKeyId)[0]?.alias !== "alias/aws/secretsmanager") ?
{
kmsKeyId
}: {})
}
});
@@ -145,7 +164,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
}
};
return integrationAuth && workspace && selectedSourceEnvironment ? (
return (integrationAuth && workspace && selectedSourceEnvironment && !isIntegrationAuthAwsKmsKeysLoading) ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<Head>
<title>Set Up AWS Secrets Manager Integration</title>
@@ -278,13 +297,38 @@ export default function AWSSecretManagerCreateIntegrationPage() {
label="Tag Value"
>
<Input
placeholder="managed-by"
placeholder="infisical"
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</FormControl>
</div>
)}
<FormControl label="Encryption Key" className="mt-4">
<Select
value={kmsKeyId}
onValueChange={(e) => {
setKmsKeyId(e)
}}
className="w-full border border-mineshaft-500"
>
{integrationAuthAwsKmsKeys?.length ? (
integrationAuthAwsKmsKeys.map((key) => {
return (
<SelectItem
value={key.id as string}
key={`repo-id-${key.id}`}
className="w-[28.4rem] text-sm"
>
{key.alias}
</SelectItem>
);
})
) : (
<div />
)}
</Select>
</FormControl>
</motion.div>
</TabPanel>
</Tabs>
@@ -317,7 +361,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
<title>Set Up AWS Secrets Manager Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isintegrationAuthLoading ? (
{(isintegrationAuthLoading || isIntegrationAuthAwsKmsKeysLoading) ? (
<img
src="/images/loading/loading.gif"
height={70}
@@ -1,5 +1,10 @@
import { useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSaveIntegrationAccessToken } from "@app/hooks/api";
@@ -49,12 +54,56 @@ export default function TerraformCloudCreateIntegrationPage() {
return (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<Head>
<title>Authorize Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="After adding the details below, you will be prompted to set up an integration for a particular Infisical project and environment."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="Workspace Id"
value={workspacesId}
onChange={(e) => setWorkSpacesId(e.target.value)}
/>
</FormControl>
<FormControl
label="Terraform Cloud API Token"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="API Token"
@@ -64,21 +113,11 @@ export default function TerraformCloudCreateIntegrationPage() {
onChange={(e) => setApiKey(e.target.value)}
/>
</FormControl>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
>
<Input
placeholder="Workspace Id"
value={workspacesId}
onChange={(e) => setWorkSpacesId(e.target.value)}
/>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
>
Connect to Terraform Cloud
@@ -1,8 +1,14 @@
import { useEffect, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
import {
Button,
@@ -19,6 +25,15 @@ import {
} from "../../../hooks/api/integrationAuth";
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
const initialSyncBehaviors = [
{
label: "No Import - Overwrite all values in Terraform Cloud",
value: IntegrationSyncBehavior.OVERWRITE_TARGET
},
{ label: "Import non-sensitive - Prefer values from Terraform Cloud", value: IntegrationSyncBehavior.PREFER_TARGET },
{ label: "Import non-sensitive - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE }
];
const variableTypes = [{ name: "env" }, { name: "terraform" }];
export default function TerraformCloudCreateIntegrationPage() {
@@ -39,6 +54,7 @@ export default function TerraformCloudCreateIntegrationPage() {
const [variableType, setVariableType] = useState("");
const [variableTypeErrorText, setVariableTypeErrorText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [initialSyncBehavior, setInitialSyncBehavior] = useState("prefer-source")
useEffect(() => {
if (workspace) {
@@ -78,7 +94,10 @@ export default function TerraformCloudCreateIntegrationPage() {
)?.appId,
sourceEnvironment: selectedSourceEnvironment,
targetService: variableType,
secretPath
secretPath,
metadata: {
initialSyncBehavior
}
});
setIsLoading(false);
@@ -95,9 +114,40 @@ export default function TerraformCloudCreateIntegrationPage() {
integrationAuthApps &&
targetApp ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<FormControl label="Project Environment" className="mt-4">
<Head>
<title>Create Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Specify the encironment and path within Infisical that you want to push to which project in Terraform."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl label="Project Environment" className="px-6">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
@@ -113,7 +163,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<FormControl label="Secrets Path" className="px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
@@ -122,7 +172,7 @@ export default function TerraformCloudCreateIntegrationPage() {
</FormControl>
<FormControl
label="Category"
className="mt-4"
className="px-6"
errorText={variableTypeErrorText}
isError={variableTypeErrorText !== "" ?? false}
>
@@ -138,7 +188,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Terraform Cloud Project" className="mt-4">
<FormControl label="Terraform Cloud Project" className="px-6">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
@@ -161,10 +211,22 @@ export default function TerraformCloudCreateIntegrationPage() {
)}
</Select>
</FormControl>
<FormControl label="Initial Sync Behavior" className="px-6">
<Select value={initialSyncBehavior} onValueChange={(e) => setInitialSyncBehavior(e)} className="w-full border border-mineshaft-600">
{initialSyncBehaviors.map((b) => {
return (
<SelectItem value={b.value} key={`sync-behavior-${b.value}`}>
{b.label}
</SelectItem>
);
})}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
@@ -74,7 +74,7 @@ export const IntegrationsSection = ({
</div>
)}
{!isLoading && isBotActive && (
<div className="flex flex-col space-y-4 p-6 pt-0">
<div className="flex flex-col min-w-max space-y-4 p-6 pt-0">
{integrations?.map((integration) => (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
@@ -128,6 +128,9 @@ export const IntegrationsSection = ({
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(integration.integration === "aws-parameter-store" && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
@@ -138,6 +141,7 @@ export const IntegrationsSection = ({
{(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(integration.integration === "aws-parameter-store" && `${integration.path}`) ||
(integration.scope?.startsWith("github-") &&
`${integration.owner}/${integration.app}`) ||
integration.app}
@@ -165,6 +169,14 @@ export const IntegrationsSection = ({
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" ||
integration.integration === "github") && (
<div className="ml-2">
@@ -1,32 +1,27 @@
import { useState } from "react";
import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects
} from "@app/context";
import {
useAddUserToGroup,
useListGroupUsers,
useRemoveUserFromGroup} from "@app/hooks/api";
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -34,136 +29,127 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
};
export const OrgGroupMembersModal = ({
popUp,
handlePopUpToggle
}: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const popUpData = popUp?.groupMembers?.data as {
slug: string;
};
const { data, isLoading } = useListGroupUsers({
groupSlug: popUpData?.slug,
offset: (page - 1) * perPage,
limit: perPage,
username: searchMemberFilter
});
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
createNotification({
text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`,
type: "error"
});
}
const popUpData = popUp?.groupMembers?.data as {
slug: string;
};
const { data, isLoading } = useListGroupUsers({
groupSlug: popUpData?.slug,
offset: (page - 1) * perPage,
limit: perPage,
username: searchMemberFilter
});
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
createNotification({
text: `Successfully ${assign ? "assigned" : "removed"} user ${
assign ? "to" : "from"
} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`,
type: "error"
});
}
return (
<Modal
isOpen={popUp?.groupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupMembers", isOpen);
}}
>
<ModalContent title="Manage Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading && data?.users?.map(({
id,
firstName,
lastName,
username,
isPartOfGroup
}) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName} ${lastName}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAssignment(username, !isPartOfGroup)}
>
{isPartOfGroup ? "Unassign" : "Assign"}
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.totalCount !== undefined && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.users?.length && (
<EmptyState
title="No users found"
icon={faUsers}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
}
};
return (
<Modal
isOpen={popUp?.groupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupMembers", isOpen);
}}
>
<ModalContent title="Manage Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading &&
data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAssignment(username, !isPartOfGroup)}
>
{isPartOfGroup ? "Unassign" : "Assign"}
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.totalCount !== undefined && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.users?.length && (
<EmptyState title="No users found" icon={faUsers} />
)}
</TableContainer>
</ModalContent>
</Modal>
);
};
@@ -19,7 +19,10 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const GroupFormSchema = z.object({
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
slug: z.string().min(5, "Slug cannot be empty").max(36, "Slug must be 36 characters or fewer"),
slug: z
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.string()
});
@@ -0,0 +1,363 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
keyspace: z.string().optional(),
localDataCenter: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
const getSqlStatements = () => {
return {
creationStatement:
"CREATE ROLE '{{username}}' WITH PASSWORD = '{{password}}' AND LOGIN=true;\nGRANT ALL PERMISSIONS ON ALL KEYSPACES TO '{{username}}';",
renewStatement: "",
revocationStatement: 'DROP ROLE "{{username}}";'
};
};
export const CassandraInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements()
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Cassandra, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-postgres" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="host1,host2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={9042}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="provider.localDataCenter"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Local Data Center"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.keyspace"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Keyspace"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify CQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace. username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace, username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="variables: keyspace, username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { CassandraInputForm } from "./CassandraInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
type Props = {
@@ -21,6 +22,19 @@ enum WizardSteps {
ProviderInputs = "provider-inputs"
}
const DYNAMIC_SECRET_LIST = [
{
icon: faDatabase,
provider: DynamicSecretProviders.SqlDatabase,
title: "SQL\nDatabase"
},
{
icon: faDatabase,
provider: DynamicSecretProviders.Cassandra,
title: "Cassandra"
}
];
export const CreateDynamicSecretForm = ({
isOpen,
onToggle,
@@ -55,35 +69,34 @@ export const CreateDynamicSecretForm = ({
>
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
<div className="flex items-center space-x-4">
<div
className="flex h-32 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
setSelectedProvider(DynamicSecretProviders.SqlDatabase);
setWizardStep(WizardSteps.ProviderInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(DynamicSecretProviders.SqlDatabase);
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title }) => (
<div
key={`dynamic-secret-provider-${provider}`}
className="flex h-32 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
<FontAwesomeIcon icon={faDatabase} size="lg" />
<div className="text-center text-sm">
SQL
<br />
Database
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SqlDatabase && (
<motion.div
key="select-input-step"
key="dynamic-sql-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
@@ -98,6 +111,24 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Cassandra && (
<motion.div
key="dynamic-cassandra-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<CassandraInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -141,7 +141,7 @@ export const SqlDatabaseInputForm = ({
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)}>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
@@ -265,7 +265,7 @@ export const SqlDatabaseInputForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -278,7 +278,7 @@ export const SqlDatabaseInputForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
@@ -55,7 +55,10 @@ const OutputDisplay = ({
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
if (provider === DynamicSecretProviders.SqlDatabase) {
if (
provider === DynamicSecretProviders.SqlDatabase ||
provider === DynamicSecretProviders.Cassandra
) {
return (
<div>
<OutputDisplay label="Database User" value={DB_USERNAME} />
@@ -102,7 +105,6 @@ export const CreateDynamicSecretLease = ({
ttl: "1h"
}
});
const createDynamicSecretLease = useCreateDynamicSecretLease();
@@ -0,0 +1,374 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z
.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
keyspace: z.string().optional(),
localDataCenter: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
})
.partial(),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
.nullable(),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretCassandraForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="DYN-1" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.port"
defaultValue={9042}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="inputs.localDataCenter"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Local Data Center"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.keyspace"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Keyspace"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="inputs.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="multiple" className="w-full bg-mineshaft-700">
<AccordionItem value="modify-sql-statement">
<AccordionTrigger>Modify CQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="inputs.creationStatement"
defaultValue={
"CREATE ROLE '{{username}}' WITH PASSWORD '{{password}}' AND LOGIN=true;\nGRANT ALL PERMISSIONS ON ALL KEYSPACES TO '{{username}}';"
}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace. username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.revocationStatement"
defaultValue='DROP ROLE "{{username}}";'
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace, username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.renewStatement"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="variables: keyspace, username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -4,6 +4,7 @@ import { Spinner } from "@app/components/v2";
import { useGetDynamicSecretDetails } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
type Props = {
@@ -56,6 +57,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Cassandra && (
<motion.div
key="cassandra-provider-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretCassandraForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -128,7 +128,7 @@ export const EditDynamicSecretSqlProviderForm = ({
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)}>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
@@ -244,7 +244,7 @@ export const EditDynamicSecretSqlProviderForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -257,7 +257,7 @@ export const EditDynamicSecretSqlProviderForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
@@ -0,0 +1,256 @@
import { Controller, useForm } from "react-hook-form";
import { faUsers, faXmark } 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,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useCreateLDAPGroupMapping,
useDeleteLDAPGroupMapping,
useGetLDAPConfig,
useGetLDAPGroupMaps,
useGetOrganizationGroups
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
ldapGroupCN: z.string().min(1, "LDAP Group CN is required"),
groupSlug: z.string().min(1, "Group Slug is required")
});
export type TFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteLdapGroupMap"]>,
data?: {
ldapGroupMapId: string;
ldapGroupCN: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>,
state?: boolean
) => void;
};
export const LDAPGroupMapModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { data: ldapConfig } = useGetLDAPConfig(currentOrg?.id ?? "");
const { data: groups } = useGetOrganizationGroups(currentOrg?.id ?? "");
const { data: groupMaps, isLoading } = useGetLDAPGroupMaps(ldapConfig?.id ?? "");
const { mutateAsync: createLDAPGroupMapping, isLoading: createIsLoading } =
useCreateLDAPGroupMapping();
const { mutateAsync: deleteLDAPGroupMapping } = useDeleteLDAPGroupMapping();
const { control, handleSubmit, reset } = useForm<TFormData>({
resolver: zodResolver(schema),
defaultValues: {
ldapGroupCN: "",
groupSlug: ""
}
});
const onFormSubmit = async ({ groupSlug, ldapGroupCN }: TFormData) => {
try {
if (!ldapConfig) return;
await createLDAPGroupMapping({
ldapConfigId: ldapConfig.id,
groupSlug,
ldapGroupCN
});
reset();
createNotification({
text: `Successfully added LDAP group mapping for ${ldapGroupCN}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to add LDAP group mapping for ${ldapGroupCN}`,
type: "error"
});
}
};
const onDeleteGroupMapSubmit = async ({
ldapConfigId,
ldapGroupMapId,
ldapGroupCN
}: {
ldapConfigId: string;
ldapGroupMapId: string;
ldapGroupCN: string;
}) => {
try {
await deleteLDAPGroupMapping({
ldapConfigId,
ldapGroupMapId
});
handlePopUpToggle("deleteLdapGroupMap", false);
createNotification({
text: `Successfully deleted LDAP group mapping ${ldapGroupCN}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to delete LDAP group mapping ${ldapGroupCN}`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.ldapGroupMap?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("ldapGroupMap", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP Group Mappings">
<h2 className="mb-4">New Group Mapping</h2>
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<div className="flex">
<Controller
control={control}
name="ldapGroupCN"
render={({ field, fieldState: { error } }) => (
<FormControl
label="LDAP Group CN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="Engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSlug"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Infisical Group"
errorText={error?.message}
isError={Boolean(error)}
className="ml-4 w-full"
>
<div className="flex">
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(groups || []).map(({ name, id, slug }) => (
<SelectItem value={slug} key={`internal-group-${id}`}>
{name}
</SelectItem>
))}
</Select>
<Button className="ml-4" size="sm" type="submit" isLoading={createIsLoading}>
Add mapping
</Button>
</div>
</FormControl>
)}
/>
</div>
</form>
<h2 className="mb-4">Group Mappings</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>LDAP Group CN</Th>
<Th>Infisical Group</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="ldap-group-maps" />}
{!isLoading &&
groupMaps?.map(({ id, ldapGroupCN, group }) => {
return (
<Tr className="h-10 items-center" key={`ldap-group-map-${id}`}>
<Td>{ldapGroupCN}</Td>
<Td>{group.name}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteLdapGroupMap", {
ldapGroupMapId: id,
ldapGroupCN
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{groupMaps?.length === 0 && (
<EmptyState title="No LDAP group mappings found" icon={faUsers} />
)}
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteLdapGroupMap.isOpen}
title={`Are you sure want to delete the group mapping for ${
(popUp?.deleteLdapGroupMap?.data as { ldapGroupCN: string })?.ldapGroupCN || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteLdapGroupMap", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteLdapGroupMapData = popUp?.deleteLdapGroupMap?.data as {
ldapGroupMapId: string;
ldapGroupCN: string;
};
return onDeleteGroupMapSubmit({
ldapConfigId: ldapConfig?.id ?? "",
ldapGroupMapId: deleteLdapGroupMapData.ldapGroupMapId,
ldapGroupCN: deleteLdapGroupMapData.ldapGroupCN
});
}}
/>
</ModalContent>
</Modal>
);
};
@@ -6,7 +6,12 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "@app/hooks/api";
import {
useCreateLDAPConfig,
useGetLDAPConfig,
useTestLDAPConnection,
useUpdateLDAPConfig
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const LDAPFormSchema = z.object({
@@ -14,6 +19,9 @@ const LDAPFormSchema = z.object({
bindDN: z.string().default(""),
bindPass: z.string().default(""),
searchBase: z.string().default(""),
searchFilter: z.string().default(""),
groupSearchBase: z.string().default(""),
groupSearchFilter: z.string().default(""),
caCert: z.string().optional()
});
@@ -27,15 +35,25 @@ type Props = {
export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig();
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig();
const { mutateAsync: testLDAPConnection } = useTestLDAPConnection();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset } = useForm<TLDAPFormData>({
const { control, handleSubmit, reset, watch } = useForm<TLDAPFormData>({
resolver: zodResolver(LDAPFormSchema)
});
const watchUrl = watch("url");
const watchBindDN = watch("bindDN");
const watchBindPass = watch("bindPass");
const watchSearchBase = watch("searchBase");
const watchSearchFilter = watch("searchFilter");
const watchGroupSearchBase = watch("groupSearchBase");
const watchGroupSearchFilter = watch("groupSearchFilter");
const watchCaCert = watch("caCert");
useEffect(() => {
if (data) {
reset({
@@ -43,12 +61,25 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
bindDN: data?.bindDN ?? "",
bindPass: data?.bindPass ?? "",
searchBase: data?.searchBase ?? "",
searchFilter: data?.searchFilter ?? "",
groupSearchBase: data?.groupSearchBase ?? "",
groupSearchFilter: data?.groupSearchFilter ?? "",
caCert: data?.caCert ?? ""
});
}
}, [data]);
const onSSOModalSubmit = async ({ url, bindDN, bindPass, searchBase, caCert }: TLDAPFormData) => {
const onSSOModalSubmit = async ({
url,
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert,
shouldCloseModal = true
}: TLDAPFormData & { shouldCloseModal?: boolean }) => {
try {
if (!currentOrg) return;
@@ -60,6 +91,9 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
});
} else {
@@ -70,11 +104,16 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
bindDN,
bindPass,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert
});
}
handlePopUpClose("addLDAP");
if (shouldCloseModal) {
handlePopUpClose("addLDAP");
}
createNotification({
text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`,
@@ -89,6 +128,45 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
}
};
const handleTestLDAPConnection = async () => {
try {
await onSSOModalSubmit({
url: watchUrl,
bindDN: watchBindDN,
bindPass: watchBindPass,
searchBase: watchSearchBase,
searchFilter: watchSearchFilter,
groupSearchBase: watchGroupSearchBase,
groupSearchFilter: watchGroupSearchFilter,
caCert: watchCaCert,
shouldCloseModal: false
});
if (!data) return;
const result = await testLDAPConnection(data.id);
if (!result) {
createNotification({
text: "Failed to test the LDAP connection: Bind operation was unsuccessful",
type: "error"
});
return;
}
createNotification({
text: "Successfully tested the LDAP connection: Bind operation was successful",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to test the LDAP connection",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addLDAP?.isOpen}
@@ -131,7 +209,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Search Base / User DN"
label="User Search Base / User DN"
errorText={error?.message}
isError={Boolean(error)}
>
@@ -139,6 +217,48 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
</FormControl>
)}
/>
<Controller
control={control}
name="searchFilter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User Search Filter (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="(uid={{username}})" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSearchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Group Search Base / Group DN (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="ou=groups,dc=acme,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSearchFilter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Group Filter (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="(&(objectClass=posixGroup)(memberUid={{.Username}}))"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="caCert"
@@ -161,12 +281,8 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addLDAP")}
>
Cancel
<Button colorSchema="secondary" onClick={handleTestLDAPConnection}>
Test Connection
</Button>
</div>
</form>
@@ -10,16 +10,20 @@ import {
import { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { LDAPGroupMapModal } from "./LDAPGroupMapModal";
import { LDAPModal } from "./LDAPModal";
export const OrgLDAPSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateLDAPConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addLDAP",
"ldapGroupMap",
"deleteLdapGroupMap",
"upgradePlan"
] as const);
@@ -63,7 +67,10 @@ export const OrgLDAPSection = (): JSX.Element => {
url: "",
bindDN: "",
bindPass: "",
searchBase: ""
searchBase: "",
searchFilter: "",
groupSearchBase: "",
groupSearchFilter: ""
});
}
@@ -76,21 +83,51 @@ export const OrgLDAPSection = (): JSX.Element => {
}
};
const openLDAPGroupMapModal = () => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("ldapGroupMap");
};
return (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP</h2>
<div className="flex">
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
</div>
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP Group Mappings</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
<Button
onClick={openLDAPGroupMapModal}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
<p className="text-sm text-mineshaft-300">
Manage how LDAP groups are mapped to internal groups in Infisical
</p>
</div>
{data && (
<div className="py-4">
@@ -119,6 +156,11 @@ export const OrgLDAPSection = (): JSX.Element => {
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPGroupMapModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}