improvement: move sso/provision settings back to org settings tabs

This commit is contained in:
Scott Wilson
2025-07-11 10:58:35 -07:00
parent e35ac599f8
commit 1aa9be203e
29 changed files with 14 additions and 149 deletions
@@ -0,0 +1,293 @@
import { useEffect } from "react";
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 { useNavigate } from "@tanstack/react-router";
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, isPending } = useGetLDAPGroupMaps(ldapConfig?.id ?? "");
const { mutateAsync: createLDAPGroupMapping, isPending: createIsLoading } =
useCreateLDAPGroupMapping();
const { mutateAsync: deleteLDAPGroupMapping } = useDeleteLDAPGroupMapping();
const navigate = useNavigate();
const { control, handleSubmit, reset, setValue } = 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"
});
}
};
useEffect(() => {
if (groups && groups.length > 0) {
setValue("groupSlug", groups[0].slug);
}
}, [groups, popUp.ldapGroupMap.isOpen]);
return (
<Modal
isOpen={popUp?.ldapGroupMap?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("ldapGroupMap", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP Group Mappings">
{groups && groups.length > 0 && (
<>
<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>
{isPending && <TableSkeleton columns={3} innerKey="ldap-group-maps" />}
{!isPending &&
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 you 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
});
}}
/>
</>
)}
{groups && groups.length === 0 && (
<div>
<div>
You do not have any Infisical groups in your organization. Create one in order to
proceed.
</div>
<Button
className="mt-4"
size="sm"
onClick={() =>
navigate({
to: "/organization/access-management"
})
}
>
Create
</Button>
</div>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,371 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateLDAPConfig,
useGetLDAPConfig,
useTestLDAPConnection,
useUpdateLDAPConfig
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const LDAPFormSchema = z.object({
url: z.string().default(""),
bindDN: z.string().default(""),
bindPass: z.string().default(""),
searchBase: z.string().default(""),
searchFilter: z.string().default(""),
uniqueUserAttribute: z.string().default(""),
groupSearchBase: z.string().default(""),
groupSearchFilter: z.string().default(""),
caCert: z.string().optional()
});
export type TLDAPFormData = z.infer<typeof LDAPFormSchema>;
type Props = {
popUp: UsePopUpState<["addLDAP"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addLDAP"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addLDAP"]>, state?: boolean) => void;
hideDelete?: boolean;
};
export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateLDAPConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateLDAPConfig();
const { mutateAsync: testLDAPConnection } = useTestLDAPConnection();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset, watch } = useForm<TLDAPFormData>({
resolver: zodResolver(LDAPFormSchema)
});
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle();
const handleLdapSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url: "",
bindDN: "",
bindPass: "",
searchBase: "",
searchFilter: "",
uniqueUserAttribute: "",
groupSearchBase: "",
groupSearchFilter: "",
caCert: ""
});
createNotification({
text: "Successfully deleted OIDC configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting OIDC configuration.",
type: "error"
});
}
};
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");
const watchUniqueUserAttribute = watch("uniqueUserAttribute");
useEffect(() => {
if (data) {
reset({
url: data?.url ?? "",
bindDN: data?.bindDN ?? "",
bindPass: data?.bindPass ?? "",
searchBase: data?.searchBase ?? "",
searchFilter: data?.searchFilter ?? "",
groupSearchBase: data?.groupSearchBase ?? "",
groupSearchFilter: data?.groupSearchFilter ?? "",
caCert: data?.caCert ?? "",
uniqueUserAttribute: data?.uniqueUserAttribute ?? ""
});
}
}, [data]);
const onSSOModalSubmit = async ({
url,
bindDN,
bindPass,
uniqueUserAttribute,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert,
shouldCloseModal = true
}: TLDAPFormData & { shouldCloseModal?: boolean }) => {
try {
if (!currentOrg) return;
if (!data) {
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
uniqueUserAttribute,
groupSearchBase,
groupSearchFilter,
caCert
});
} else {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
uniqueUserAttribute,
groupSearchBase,
groupSearchFilter,
caCert
});
}
if (shouldCloseModal) {
handlePopUpClose("addLDAP");
}
createNotification({
text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} LDAP configuration`,
type: "error"
});
}
};
const handleTestLDAPConnection = async () => {
try {
await onSSOModalSubmit({
url: watchUrl,
bindDN: watchBindDN,
bindPass: watchBindPass,
searchBase: watchSearchBase,
searchFilter: watchSearchFilter,
groupSearchBase: watchGroupSearchBase,
groupSearchFilter: watchGroupSearchFilter,
uniqueUserAttribute: watchUniqueUserAttribute,
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}
onOpenChange={(isOpen) => {
handlePopUpToggle("addLDAP", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP configuration">
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl label="URL" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="ldaps://ldap.myorg.com:636" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindDN"
render={({ field, fieldState: { error } }) => (
<FormControl label="Bind DN" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl label="Bind Pass" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} type="password" placeholder="********" />
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User Search Base / User DN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="ou=people,dc=acme,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="uniqueUserAttribute"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Unique User Attribute (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="uidNumber" />
</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"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button colorSchema="secondary" onClick={handleTestLDAPConnection}>
Test Connection
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure you want to delete LDAP?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleLdapSoftDelete}
/>
</>
);
};
@@ -0,0 +1,488 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetOIDCConfig } from "@app/hooks/api";
import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations";
import { OIDCJWTSignatureAlgorithm } from "@app/hooks/api/oidcConfig/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum ConfigurationType {
CUSTOM = "custom",
DISCOVERY_URL = "discoveryURL"
}
type Props = {
popUp: UsePopUpState<["addOIDC"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void;
hideDelete?: boolean;
};
const schema = z
.object({
configurationType: z.string(),
issuer: z.string().optional(),
discoveryURL: z.string().optional(),
authorizationEndpoint: z.string().optional(),
jwksUri: z.string().optional(),
tokenEndpoint: z.string().optional(),
userinfoEndpoint: z.string().optional(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
allowedEmailDomains: z.string().optional(),
jwtSignatureAlgorithm: z.nativeEnum(OIDCJWTSignatureAlgorithm).optional()
})
.superRefine((data, ctx) => {
if (data.configurationType === ConfigurationType.CUSTOM) {
if (!data.issuer) {
ctx.addIssue({
path: ["issuer"],
message: "Issuer is required",
code: z.ZodIssueCode.custom
});
}
if (!data.authorizationEndpoint) {
ctx.addIssue({
path: ["authorizationEndpoint"],
message: "Authorization endpoint is required",
code: z.ZodIssueCode.custom
});
}
if (!data.jwksUri) {
ctx.addIssue({
path: ["jwksUri"],
message: "JWKS URI is required",
code: z.ZodIssueCode.custom
});
}
if (!data.tokenEndpoint) {
ctx.addIssue({
path: ["tokenEndpoint"],
message: "Token endpoint is required",
code: z.ZodIssueCode.custom
});
}
if (!data.userinfoEndpoint) {
ctx.addIssue({
path: ["userinfoEndpoint"],
message: "Userinfo endpoint is required",
code: z.ZodIssueCode.custom
});
}
} else {
// eslint-disable-next-line no-lonely-if
if (!data.discoveryURL) {
ctx.addIssue({
path: ["discoveryURL"],
message: "Discovery URL is required",
code: z.ZodIssueCode.custom
});
}
}
});
export type OIDCFormData = z.infer<typeof schema>;
export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateOIDCConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateOIDCConfig();
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle(false);
const { data } = useGetOIDCConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset, setValue, watch } = useForm<OIDCFormData>({
resolver: zodResolver(schema),
defaultValues: {
configurationType: ConfigurationType.DISCOVERY_URL
}
});
const [isClientIdFocused, setIsClientIdFocused] = useToggle();
const [isClientSecretFocused, setIsClientSecretFocused] = useToggle();
const configurationTypeValue = watch("configurationType");
const handleOidcSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
issuer: "",
discoveryURL: "",
authorizationEndpoint: "",
allowedEmailDomains: "",
jwksUri: "",
tokenEndpoint: "",
userinfoEndpoint: "",
clientId: "",
clientSecret: "",
isActive: false,
organizationId: currentOrg.id
});
createNotification({
text: "Successfully deleted OIDC configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting OIDC configuration.",
type: "error"
});
}
};
useEffect(() => {
if (data) {
setValue("issuer", data.issuer);
setValue("authorizationEndpoint", data.authorizationEndpoint);
setValue("jwksUri", data.jwksUri);
setValue("tokenEndpoint", data.tokenEndpoint);
setValue("userinfoEndpoint", data.userinfoEndpoint);
setValue("discoveryURL", data.discoveryURL);
setValue("clientId", data.clientId);
setValue("clientSecret", data.clientSecret);
setValue("allowedEmailDomains", data.allowedEmailDomains);
setValue("configurationType", data.configurationType);
setValue("jwtSignatureAlgorithm", data.jwtSignatureAlgorithm);
}
}, [data]);
const onOIDCModalSubmit = async ({
issuer,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
configurationType,
discoveryURL,
clientId,
clientSecret,
jwtSignatureAlgorithm
}: OIDCFormData) => {
try {
if (!currentOrg) {
return;
}
if (!data) {
await createMutateAsync({
issuer,
configurationType,
discoveryURL,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
clientId,
clientSecret,
isActive: true,
organizationId: currentOrg.id,
jwtSignatureAlgorithm
});
} else {
await updateMutateAsync({
issuer,
configurationType,
discoveryURL,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
clientId,
clientSecret,
isActive: true,
organizationId: currentOrg.id,
jwtSignatureAlgorithm
});
}
handlePopUpClose("addOIDC");
createNotification({
text: `Successfully ${!data ? "added" : "updated"} OIDC SSO configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} OIDC SSO configuration`,
type: "error"
});
}
};
return (
<>
<Modal
isOpen={popUp?.addOIDC?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addOIDC", isOpen);
reset();
}}
>
<ModalContent title="Manage OIDC configuration">
<form onSubmit={handleSubmit(onOIDCModalSubmit)}>
<Controller
control={control}
name="configurationType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
className="w-full"
defaultValue="discoveryURL"
{...field}
onValueChange={(e) => onChange(e)}
>
<SelectItem value={ConfigurationType.DISCOVERY_URL}>Discovery URL</SelectItem>
<SelectItem value={ConfigurationType.CUSTOM}>Custom</SelectItem>
</Select>
</FormControl>
)}
/>
{configurationTypeValue === ConfigurationType.DISCOVERY_URL && (
<Controller
control={control}
name="discoveryURL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Discovery Document URL"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://accounts.google.com/.well-known/openid-configuration"
autoComplete="off"
/>
</FormControl>
)}
/>
)}
{configurationTypeValue === ConfigurationType.CUSTOM && (
<>
<Controller
control={control}
name="issuer"
render={({ field, fieldState: { error } }) => (
<FormControl label="Issuer" errorText={error?.message} isError={Boolean(error)}>
<Input
{...field}
placeholder="https://accounts.google.com"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="authorizationEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authorization Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://accounts.google.com/o/oauth2/v2/auth"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="tokenEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Token Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://oauth2.googleapis.com/token"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="userinfoEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User info endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://openidconnect.googleapis.com/v1/userinfo"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="jwksUri"
render={({ field, fieldState: { error } }) => (
<FormControl
label="JWKS URI"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://www.googleapis.com/oauth2/v3/certs"
autoComplete="off"
/>
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
defaultValue={OIDCJWTSignatureAlgorithm.RS256}
name="jwtSignatureAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="JWT Signature Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={OIDCJWTSignatureAlgorithm.RS256}>RS256</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.RS512}>RS512</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.HS256}>HS256</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.EDDSA}>EdDSA</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="allowedEmailDomains"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Email Domains (defaults to any)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="infisical.com, google.com" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Client ID" errorText={error?.message} isError={Boolean(error)}>
<Input
placeholder="Client ID"
type={isClientIdFocused ? "text" : "password"}
onFocus={() => setIsClientIdFocused.on()}
{...field}
onBlur={() => {
field.onBlur();
setIsClientIdFocused.off();
}}
autoComplete="off"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="Client Secret"
type={isClientSecretFocused ? "text" : "password"}
autoComplete="off"
onFocus={() => setIsClientSecretFocused.on()}
onBlur={() => {
field.onBlur();
setIsClientSecretFocused.off();
}}
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addOIDC")}
>
Cancel
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure you want to delete OIDC?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleOidcSoftDelete}
/>
</>
);
};
@@ -0,0 +1,187 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGeneralAuthSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const { mutateAsync } = useUpdateOrg();
const logout = useLogoutUser();
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
authEnforced: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
type: "success"
});
if (value) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`);
window.close();
}
} catch (err) {
console.error(err);
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
const handleEnableBypassOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
bypassOrgAuthEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
return (
<>
{/* <div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Allow users to send invites</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-org-invites"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Allow members to invite new users to this organization</p>
</div> */}
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enforce SAML SSO</span>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce users to authenticate via SAML to access this organization
</p>
</div>
{currentOrg?.authEnforced && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
<Tooltip
className="max-w-lg"
content={
<div>
<span>
When this is enabled, we strongly recommend enforcing MFA at the organization
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the{" "}
<a
target="_blank"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal"
rel="noreferrer"
>
Admin Login Portal
</a>{" "}
at{" "}
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href={`${window.location.origin}/login/admin`}
>
{window.location.origin}/login/admin
</a>
</p>
</div>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-admin-bypass"
isChecked={currentOrg?.bypassOrgAuthEnabled ?? false}
onCheckedChange={(value) => handleEnableBypassOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
<span>
Allow organization admins to bypass SAML enforcement when SSO is unavailable,
misconfigured, or inaccessible.
</span>
</p>
</div>
)}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
/>
</>
);
};
@@ -0,0 +1,172 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
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);
const { mutateAsync: createMutateAsync } = useCreateLDAPConfig();
const handleLDAPToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} LDAP`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} LDAP`,
type: "error"
});
}
};
const addLDAPBtnClick = async () => {
try {
if (subscription?.ldap && currentOrg) {
if (!data) {
// case: LDAP is not configured
// -> initialize empty LDAP configuration
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url: "",
bindDN: "",
bindPass: "",
uniqueUserAttribute: "",
searchBase: "",
searchFilter: "",
groupSearchBase: "",
groupSearchFilter: ""
});
}
handlePopUpOpen("addLDAP");
} else {
handlePopUpOpen("upgradePlan");
}
} catch (err) {
console.error(err);
}
};
const openLDAPGroupMapModal = () => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("ldapGroupMap");
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<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={openLDAPGroupMapModal}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
<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">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable LDAP</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleLDAPToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
>
Enable
</Switch>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with LDAP
</p>
</div>
)}
<LDAPModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPGroupMapModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use LDAP authentication if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,334 @@
import { faInfoCircle, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useGetOIDCConfig, useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations";
import { usePopUp } from "@app/hooks/usePopUp";
import { OIDCModal } from "./OIDCModal";
export const OrgOIDCSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateOIDCConfig();
const { mutateAsync: updateOrg } = useUpdateOrg();
const logout = useLogoutUser();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addOIDC",
"upgradePlan"
] as const);
const handleOIDCToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} OIDC SSO`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await updateOrg({
orgId: currentOrg?.id,
authEnforced: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
type: "success"
});
if (value) {
await logout.mutateAsync();
window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`);
window.close();
}
} catch (err) {
console.error(err);
}
};
const handleEnableBypassOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await updateOrg({
orgId: currentOrg?.id,
bypassOrgAuthEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
const handleOIDCGroupManagement = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
manageGroupMemberships: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} OIDC group membership mapping`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
const addOidcButtonClick = async () => {
if (subscription?.oidcSSO && currentOrg) {
handlePopUpOpen("addOIDC");
} else {
handlePopUpOpen("upgradePlan");
}
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">OIDC</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button
onClick={addOidcButtonClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Manage OIDC authentication configuration</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable OIDC</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-oidc-sso"
onCheckedChange={(value) => handleOIDCToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with OIDC
</p>
</div>
)}
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enforce OIDC SSO</span>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
isChecked={currentOrg?.authEnforced ?? false}
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
<span>Enforce users to authenticate via OIDC to access this organization.</span>
</p>
</div>
{currentOrg?.authEnforced && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
<Tooltip
className="max-w-lg"
content={
<div>
<span>
When this is enabled, we strongly recommend enforcing MFA at the organization
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the{" "}
<a
target="_blank"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal"
rel="noreferrer"
>
Admin Login Portal
</a>{" "}
at{" "}
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href={`${window.location.origin}/login/admin`}
>
{window.location.origin}/login/admin
</a>
</p>
</div>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-admin-bypass"
isChecked={currentOrg?.bypassOrgAuthEnabled ?? false}
onCheckedChange={(value) => handleEnableBypassOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
<span>
Allow organization admins to bypass OIDC enforcement when SSO is unavailable,
misconfigured, or inaccessible.
</span>
</p>
</div>
)}
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="text-md flex items-center text-mineshaft-100">
<span>OIDC Group Membership Mapping</span>
<Tooltip
className="max-w-lg"
content={
<>
<p>
When this feature is enabled, Infisical will automatically sync group
memberships between the OIDC provider and Infisical. Users will be added to
Infisical groups that match their OIDC group names, and removed from any
Infisical groups not present in their groups claim. When enabled, manual
management of Infisical group memberships will be disabled.
</p>
<p className="mt-4">
To use this feature you must include group claims in the OIDC token.
</p>
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview"
>
See your OIDC provider docs for details.
</a>
<p className="mt-4 text-yellow">
<FontAwesomeIcon className="mr-1" icon={faWarning} />
Group membership changes in the OIDC provider only sync with Infisical when a
user logs in via OIDC. For example, if you remove a user from a group in the
OIDC provider, this change will not be reflected in Infisical until their next
OIDC login. To ensure this behavior, Infisical recommends enabling Enforce OIDC
SSO.
</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="ml-1 mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
isChecked={data?.manageGroupMemberships ?? false}
onCheckedChange={(value) => handleOIDCGroupManagement(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Infisical will manage user group memberships based on the OIDC provider
</p>
</div>
<OIDCModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use OIDC SSO if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -0,0 +1,131 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SSOModal } from "./SSOModal";
export const OrgSSOSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isPending } = useGetSSOConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateSSOConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"upgradePlan",
"addSSO"
] as const);
const { mutateAsync: createMutateAsync } = useCreateSSOConfig();
const handleSamlSSOToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} SAML SSO`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} SAML SSO`,
type: "error"
});
}
};
const addSSOBtnClick = async () => {
try {
if (subscription?.samlSSO && currentOrg) {
if (!data) {
// case: SAML SSO is not configured
// -> initialize empty SAML SSO configuration
await createMutateAsync({
organizationId: currentOrg.id,
authProvider: "okta-saml",
isActive: false,
entryPoint: "",
issuer: "",
cert: ""
});
}
handlePopUpOpen("addSSO");
} else {
handlePopUpOpen("upgradePlan");
}
} catch (err) {
console.error(err);
}
};
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">SAML</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button onClick={addSSOBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Manage SAML authentication configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable SAML</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleSamlSSOToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with SAML
</p>
</div>
<SSOModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SAML SSO if you switch to Infisical's Pro plan."
/>
</>
);
};
@@ -0,0 +1,199 @@
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useServerConfig,
useSubscription
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useGetLDAPConfig, useGetOIDCConfig, useGetSSOConfig } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { LDAPModal } from "./LDAPModal";
import { OIDCModal } from "./OIDCModal";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgOIDCSection } from "./OrgOIDCSection";
import { OrgSSOSection } from "./OrgSSOSection";
import { SSOModal } from "./SSOModal";
export const OrgSsoTab = withPermission(
() => {
const {
config: { enabledLoginMethods }
} = useServerConfig();
const { currentOrg } = useOrganization();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addLDAP",
"addSSO",
"addOIDC",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { data: oidcConfig, isPending: isLoadingOidcConfig } = useGetOIDCConfig(
currentOrg?.id ?? ""
);
const { data: samlConfig, isPending: isLoadingSamlConfig } = useGetSSOConfig(
currentOrg?.id ?? ""
);
const { data: ldapConfig, isPending: isLoadingLdapConfig } = useGetLDAPConfig(
currentOrg?.id ?? ""
);
const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig;
const shouldDisplaySection = (method: LoginMethod) =>
!enabledLoginMethods || enabledLoginMethods.includes(method);
const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer);
const isSamlConfigured =
samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert);
const isLdapConfigured = ldapConfig && ldapConfig.url;
const shouldShowCreateIdentityProviderView =
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
const createIdentityProviderView =
shouldDisplaySection(LoginMethod.SAML) ||
shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP) ? (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
<p className="mb-2 mt-1 text-gray-400">
Connect your identity provider to simplify user management
</p>
{shouldDisplaySection(LoginMethod.SAML) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
(shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) &&
"border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">SAML</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addSSO");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.OIDC) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">OIDC</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addOIDC");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.LDAP) && (
<div className="mt-4 flex items-center justify-between">
<p className="text-lg text-gray-200">LDAP</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
return;
}
handlePopUpOpen("addLDAP");
}}
>
Connect
</Button>
</div>
)}
</div>
<SSOModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<OIDCModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
) : (
<EmptyState title="" iconSize="2x" className="!pb-10 pt-14">
<p className="text-center text-lg">Single Sign-On (SSO) has been disabled</p>
<p className="text-center">Contact your server administrator</p>
</EmptyState>
);
if (areConfigsLoading) {
return <ContentLoader />;
}
return (
<>
{shouldShowCreateIdentityProviderView ? (
createIdentityProviderView
) : (
<>
{isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgGeneralAuthSection />
<OrgSSOSection />
</div>
)}
{isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && <OrgOIDCSection />}
{isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && <OrgLDAPSection />}
</>
)}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`You can use ${
(popUp.upgradePlan.data as { feature: string })?.feature
} if you switch to Infisical's ${
(popUp.upgradePlan.data as { plan: string })?.plan
} plan.`}
/>
</>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Sso }
);
@@ -0,0 +1,381 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { SSOModalHeader } from "./SSOModalHeader";
enum AuthProvider {
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml",
KEYCLOAK_SAML = "keycloak-saml",
GOOGLE_SAML = "google-saml",
AUTH0_SAML = "auth0-saml"
}
const ssoAuthProviders = [
{ label: "Okta SAML", value: AuthProvider.OKTA_SAML, image: "Okta.png", docsUrl: "okta" },
{
label: "Azure / Entra SAML",
value: AuthProvider.AZURE_SAML,
image: "Microsoft Azure.png",
docsUrl: "azure"
},
{
label: "JumpCloud SAML",
value: AuthProvider.JUMPCLOUD_SAML,
image: "JumpCloud.png",
docsUrl: "jumpcloud"
},
{
label: "Keycloak SAML",
value: AuthProvider.KEYCLOAK_SAML,
image: "Keycloak.png",
docsUrl: "keycloak-saml"
},
{
label: "Google SAML",
value: AuthProvider.GOOGLE_SAML,
image: "Google.png",
docsUrl: "google-saml"
},
{ label: "Auth0 SAML", value: AuthProvider.AUTH0_SAML, image: "Auth0.png", docsUrl: "auth0-saml" }
];
const schema = z
.object({
authProvider: z.string().min(1, "SSO Type is required"),
entryPoint: z.string().default(""),
issuer: z.string().default(""),
cert: z.string().default("")
})
.required();
export type AddSSOFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addSSO"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addSSO"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addSSO"]>, state?: boolean) => void;
hideDelete?: boolean;
};
export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateSSOConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateSSOConfig();
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle();
const { data } = useGetSSOConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset, watch, setValue, getValues } = useForm<AddSSOFormData>({
defaultValues: {
authProvider: AuthProvider.OKTA_SAML
},
resolver: zodResolver(schema)
});
useEffect(() => {
if (data) {
reset({
authProvider: data?.authProvider ?? "",
entryPoint: data?.entryPoint ?? "",
issuer: data?.issuer ?? "",
cert: data?.cert ?? ""
});
}
}, [data]);
const handleSamlSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
entryPoint: "",
issuer: "",
cert: ""
});
createNotification({
text: "Successfully deleted SAML SSO configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting SAML SSO configuration.",
type: "error"
});
}
};
const onSSOModalSubmit = async ({ authProvider, entryPoint, issuer, cert }: AddSSOFormData) => {
try {
if (!currentOrg) return;
if (!data) {
await createMutateAsync({
organizationId: currentOrg.id,
authProvider,
isActive: false,
entryPoint,
issuer,
cert
});
} else {
await updateMutateAsync({
organizationId: currentOrg.id,
authProvider,
isActive: false,
entryPoint,
issuer,
cert
});
}
handlePopUpClose("addSSO");
createNotification({
text: `Successfully ${!data ? "added" : "updated"} SAML SSO configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} SAML SSO configuration`,
type: "error"
});
}
};
const renderLabels = (authProvider: string) => {
switch (authProvider) {
case AuthProvider.OKTA_SAML:
return {
acsUrl: "Single sign-on URL",
entityId: "Audience URI (SP Entity ID)",
entryPoint: "Identity Provider Single Sign-On URL",
entryPointPlaceholder: "https://your-domain.okta.com/app/app-name/xxx/sso/saml",
issuer: "Identity Provider Issuer",
issuerPlaceholder: "http://www.okta.com/xxx"
};
case AuthProvider.AZURE_SAML:
return {
acsUrl: "Reply URL (Assertion Consumer Service URL)",
entityId: "Identifier (Entity ID)",
entryPoint: "Login URL",
entryPointPlaceholder: "https://login.microsoftonline.com/xxx/saml2",
issuer: "Azure / Entra Application ID",
issuerPlaceholder: "abc-def-ghi-jkl-mno"
};
case AuthProvider.JUMPCLOUD_SAML:
return {
acsUrl: "ACS URL",
entityId: "SP Entity ID",
entryPoint: "IDP URL",
entryPointPlaceholder: "https://sso.jumpcloud.com/saml2/xxx",
issuer: "IdP Entity ID",
issuerPlaceholder: "xxx"
};
case AuthProvider.KEYCLOAK_SAML:
return {
acsUrl: "Valid redirect URI",
entityId: "SP Entity ID",
entryPoint: "IDP URL",
entryPointPlaceholder: "https://keycloak.mysite.com/realms/myrealm/protocol/saml",
issuer: "Client ID",
issuerPlaceholder: window.origin
};
case AuthProvider.GOOGLE_SAML:
return {
acsUrl: "ACS URL",
entityId: "SP Entity ID",
entryPoint: "SSO URL",
entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx",
issuer: "Issuer",
issuerPlaceholder: window.origin
};
case AuthProvider.AUTH0_SAML:
return {
acsUrl: "Application Callback URL",
entityId: "Audience",
entryPoint: "Identity Provider Login URL",
entryPointPlaceholder: "https://xxx.auth0.com/samlp/xxx",
issuer: "Issuer",
issuerPlaceholder: "urn:xxx-xxx.us.auth0.com"
};
default:
return {
acsUrl: "ACS URL",
entityId: "Entity ID",
entryPoint: "Entrypoint",
entryPointPlaceholder: "Enter entrypoint...",
issuer: "Issuer",
issuerPlaceholder: "Enter placeholder..."
};
}
};
const authProvider = watch("authProvider");
useEffect(() => {
if (authProvider === AuthProvider.GOOGLE_SAML && getValues("issuer") === "") {
setValue("issuer", window.origin);
}
}, [authProvider]);
return (
<>
<Modal
isOpen={popUp?.addSSO?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addSSO", isOpen);
reset();
}}
>
<ModalContent title="Manage SAML configuration">
<SSOModalHeader
providerDetails={ssoAuthProviders.find((provider) => provider.value === authProvider)!}
isConnected={Boolean(data)}
/>
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
name="authProvider"
defaultValue="okta-saml"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{ssoAuthProviders.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{authProvider && data && (
<>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">
{renderLabels(authProvider).acsUrl}
</h3>
<p className="text-md break-all text-gray-400">{`${window.origin}/api/v1/sso/saml2/${data.id}`}</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">
{renderLabels(authProvider).entityId}
</h3>
<p className="text-md text-gray-400">{window.origin}</p>
</div>
<Controller
control={control}
name="entryPoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label={renderLabels(authProvider).entryPoint}
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Input
{...field}
placeholder={renderLabels(authProvider).entryPointPlaceholder}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="issuer"
render={({ field, fieldState: { error } }) => (
<FormControl
label={renderLabels(authProvider).issuer}
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder={renderLabels(authProvider).issuerPlaceholder}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="cert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addSSO")}
>
Cancel
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure you want to delete SAML SSO?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleSamlSoftDelete}
/>
</>
);
};
@@ -0,0 +1,53 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type ProviderDetails = {
label: string;
image: string;
value: string;
docsUrl: string;
};
type Props = {
providerDetails: ProviderDetails;
isConnected: boolean;
};
const docsBaseUrl = "https://infisical.com/docs/documentation/platform/sso";
export const SSOModalHeader = ({ providerDetails, isConnected }: Props) => {
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${providerDetails.label} logo`}
src={`/images/sso/${providerDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{providerDetails.label}
<a
href={`${docsBaseUrl}/${providerDetails.docsUrl}`}
target="_blank"
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConnected
? `${providerDetails.label} Connection`
: `Connect to ${providerDetails.label}`}
</p>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { OrgSsoTab } from "./OrgSsoTab";