feat(sso): allow to use OIDC and SAML (#7246)
## What it does ### Backend - [x] Add a mutation to create OIDC and SAML configuration - [x] Add a mutation to delete an SSO config - [x] Add a feature flag to toggle SSO - [x] Add a mutation to activate/deactivate an SSO config - [x] Add a mutation to delete an SSO config - [x] Add strategy to use OIDC or SAML - [ ] Improve error management ### Frontend - [x] Add section "security" in settings - [x] Add page to list SSO configurations - [x] Add page and forms to create OIDC or SAML configuration - [x] Add field to "connect with SSO" in the signin/signup process - [x] Trigger auth when a user switch to a workspace with SSO enable - [x] Add an option on the security page to activate/deactivate the global invitation link - [ ] Add new Icons for SSO Identity Providers (okta, Auth0, Azure, Microsoft) --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -0,0 +1,124 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer';
|
||||
import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm';
|
||||
import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm';
|
||||
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import styled from '@emotion/styled';
|
||||
import { ReactElement } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { H2Title, IconComponent, IconKey } from 'twenty-ui';
|
||||
import { IdpType } from '~/generated/graphql';
|
||||
|
||||
const StyledInputsContainer = styled.div`
|
||||
display: grid;
|
||||
gap: ${({ theme }) => theme.spacing(2, 4)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
'input-1 input-1'
|
||||
'input-2 input-3'
|
||||
'input-4 input-5';
|
||||
|
||||
& :first-of-type {
|
||||
grid-area: input-1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SettingsSSOIdentitiesProvidersForm = () => {
|
||||
const { control, getValues } =
|
||||
useFormContext<SettingSecurityNewSSOIdentityFormValues>();
|
||||
|
||||
const IdpMap: Record<
|
||||
IdpType,
|
||||
{
|
||||
form: ReactElement;
|
||||
option: {
|
||||
Icon: IconComponent;
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
> = {
|
||||
OIDC: {
|
||||
option: {
|
||||
Icon: IconKey,
|
||||
title: 'OIDC',
|
||||
value: 'OIDC',
|
||||
description: '',
|
||||
},
|
||||
form: <SettingsSSOOIDCForm />,
|
||||
},
|
||||
SAML: {
|
||||
option: {
|
||||
Icon: IconKey,
|
||||
title: 'SAML',
|
||||
value: 'SAML',
|
||||
description: '',
|
||||
},
|
||||
form: <SettingsSSOSAMLForm />,
|
||||
},
|
||||
};
|
||||
|
||||
const getFormByType = (type: Uppercase<IdpType> | undefined) => {
|
||||
switch (type) {
|
||||
case IdpType.Oidc:
|
||||
return IdpMap.OIDC.form;
|
||||
case IdpType.Saml:
|
||||
return IdpMap.SAML.form;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<H2Title title="Name" description="The name of your connection" />
|
||||
<StyledInputsContainer>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
autoComplete="off"
|
||||
label="Name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
placeholder="Google OIDC"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Type"
|
||||
description="Choose between OIDC and SAML protocols"
|
||||
/>
|
||||
<StyledInputsContainer>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<SettingsRadioCardContainer
|
||||
value={value}
|
||||
options={Object.values(IdpMap).map(
|
||||
(identityProviderType) => identityProviderType.option,
|
||||
)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
{getFormByType(getValues().type)}
|
||||
</SettingsPageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSSOIdentitiesProvidersForm;
|
||||
@ -0,0 +1,61 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
|
||||
import { SettingsSSOIdentitiesProvidersListEmptyStateCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard';
|
||||
import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer';
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
|
||||
import { SettingsListCard } from '../../components/SettingsListCard';
|
||||
import { guessSSOIdentityProviderIconByUrl } from '../utils/guessSSOIdentityProviderIconByUrl';
|
||||
|
||||
export const SettingsSSOIdentitiesProvidersListCard = () => {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
|
||||
SSOIdentitiesProvidersState,
|
||||
);
|
||||
|
||||
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
|
||||
onCompleted: (data) => {
|
||||
setSSOIdentitiesProviders(
|
||||
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
enqueueSnackBar(error.message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return !SSOIdentitiesProviders.length && !loading ? (
|
||||
<SettingsSSOIdentitiesProvidersListEmptyStateCard />
|
||||
) : (
|
||||
<SettingsListCard
|
||||
items={SSOIdentitiesProviders}
|
||||
getItemLabel={(SSOIdentityProvider) =>
|
||||
`${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}`
|
||||
}
|
||||
isLoading={loading}
|
||||
RowIconFn={(SSOIdentityProvider) =>
|
||||
guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer)
|
||||
}
|
||||
RowRightComponent={({ item: SSOIdp }) => (
|
||||
<SettingsSSOIdentityProviderRowRightContainer SSOIdp={SSOIdp} />
|
||||
)}
|
||||
hasFooter
|
||||
footerButtonLabel="Add SSO Identity Provider"
|
||||
onFooterButtonClick={() =>
|
||||
navigate(getSettingsPagePath(SettingsPath.NewSSOIdentityProvider))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { Card } from '@/ui/layout/card/components/Card';
|
||||
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
||||
import { CardHeader } from '@/ui/layout/card/components/CardHeader';
|
||||
import { IconKey } from 'twenty-ui';
|
||||
|
||||
const StyledHeader = styled(CardHeader)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
const StyledBody = styled(CardContent)`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const SettingsSSOIdentitiesProvidersListEmptyStateCard = () => {
|
||||
return (
|
||||
<Card>
|
||||
<StyledHeader>{'No SSO Identity Providers Configured'}</StyledHeader>
|
||||
<StyledBody>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title="Add SSO Identity Provider"
|
||||
variant="secondary"
|
||||
to={getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)}
|
||||
/>
|
||||
</StyledBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu';
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus';
|
||||
import { Status } from '@/ui/display/status/components/Status';
|
||||
import styled from '@emotion/styled';
|
||||
import { UnwrapRecoilValue } from 'recoil';
|
||||
|
||||
const StyledRowRightContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const SettingsSSOIdentityProviderRowRightContainer = ({
|
||||
SSOIdp,
|
||||
}: {
|
||||
SSOIdp: UnwrapRecoilValue<typeof SSOIdentitiesProvidersState>[0];
|
||||
}) => {
|
||||
return (
|
||||
<StyledRowRightContainer>
|
||||
<Status
|
||||
color={getColorBySSOIdentityProviderStatus[SSOIdp.status]}
|
||||
text={SSOIdp.status}
|
||||
weight="medium"
|
||||
/>
|
||||
<SettingsSecuritySSORowDropdownMenu SSOIdp={SSOIdp} />
|
||||
</StyledRowRightContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,154 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { H2Title, IconCopy } from 'twenty-ui';
|
||||
|
||||
const StyledInputsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2, 4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledButtonCopy = styled.div`
|
||||
align-items: end;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const SettingsSSOOIDCForm = () => {
|
||||
const { control } = useFormContext();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const theme = useTheme();
|
||||
|
||||
const authorizedUrl = window.location.origin;
|
||||
const redirectionUrl = `${window.location.origin}/auth/oidc/callback`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Client Settings"
|
||||
description="Provide your OIDC provider details"
|
||||
/>
|
||||
<StyledInputsContainer>
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
readOnly={true}
|
||||
label="Authorized URI"
|
||||
value={authorizedUrl}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<StyledButtonCopy>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
title="Copy"
|
||||
onClick={() => {
|
||||
enqueueSnackBar('Authorized Url copied to clipboard', {
|
||||
variant: SnackBarVariant.Success,
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
});
|
||||
navigator.clipboard.writeText(authorizedUrl);
|
||||
}}
|
||||
/>
|
||||
</StyledButtonCopy>
|
||||
</StyledContainer>
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
readOnly={true}
|
||||
label="Redirection URI"
|
||||
value={redirectionUrl}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<StyledButtonCopy>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
title="Copy"
|
||||
onClick={() => {
|
||||
enqueueSnackBar('Redirect Url copied to clipboard', {
|
||||
variant: SnackBarVariant.Success,
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
});
|
||||
navigator.clipboard.writeText(redirectionUrl);
|
||||
}}
|
||||
/>
|
||||
</StyledButtonCopy>
|
||||
</StyledContainer>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Identity Provider"
|
||||
description="Enter the credentials to set the connection"
|
||||
/>
|
||||
<StyledInputsContainer>
|
||||
<Controller
|
||||
name="clientID"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
autoComplete="off"
|
||||
label="Client ID"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
placeholder="900960562328-36306ohbk8e3.apps.googleusercontent.com"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="clientSecret"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
label="Client Secret"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
placeholder="****************************"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="issuer"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
autoComplete="off"
|
||||
label="Issuer URI"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,212 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
|
||||
import { parseSAMLMetadataFromXMLFile } from '@/settings/security/utils/parseSAMLMetadataFromXMLFile';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ChangeEvent, useRef } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
H2Title,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
} from 'twenty-ui';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledUploadFileContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const StyledInputsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2, 4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledButtonCopy = styled.div`
|
||||
align-items: end;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const SettingsSSOSAMLForm = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const theme = useTheme();
|
||||
const { setValue, getValues, watch } = useFormContext();
|
||||
|
||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (isDefined(e.target.files)) {
|
||||
const text = await e.target.files[0].text();
|
||||
const samlMetadataParsed = parseSAMLMetadataFromXMLFile(text);
|
||||
if (!samlMetadataParsed.success) {
|
||||
enqueueSnackBar('Invalid File', {
|
||||
variant: SnackBarVariant.Error,
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setValue('ssoURL', samlMetadataParsed.data.ssoUrl);
|
||||
setValue('certificate', samlMetadataParsed.data.certificate);
|
||||
setValue('issuer', samlMetadataParsed.data.entityID);
|
||||
}
|
||||
};
|
||||
|
||||
const entityID = `${REACT_APP_SERVER_BASE_URL}/auth/saml/login/${getValues('id')}`;
|
||||
const acsUrl = `${REACT_APP_SERVER_BASE_URL}/auth/saml/callback`;
|
||||
|
||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadFileClick = () => {
|
||||
inputFileRef?.current?.click?.();
|
||||
};
|
||||
|
||||
const ssoURL = watch('ssoURL');
|
||||
const certificate = watch('certificate');
|
||||
const issuer = watch('issuer');
|
||||
|
||||
const isXMLMetadataValid = () => {
|
||||
return [ssoURL, certificate, issuer].every(
|
||||
(field) => isDefined(field) && field.length > 0,
|
||||
);
|
||||
};
|
||||
|
||||
const downloadMetadata = async () => {
|
||||
const response = await fetch(
|
||||
`${REACT_APP_SERVER_BASE_URL}/auth/saml/metadata/${getValues('id')}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return enqueueSnackBar('Metadata file generation failed', {
|
||||
variant: SnackBarVariant.Error,
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
const text = await response.text();
|
||||
const blob = new Blob([text], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'metadata.xml';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Identity Provider Metadata XML"
|
||||
description="Upload the XML file with your connection infos"
|
||||
/>
|
||||
<StyledUploadFileContainer>
|
||||
<StyledFileInput
|
||||
ref={inputFileRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept=".xml"
|
||||
/>
|
||||
<Button
|
||||
Icon={IconUpload}
|
||||
onClick={handleUploadFileClick}
|
||||
title="Upload file"
|
||||
></Button>
|
||||
{isXMLMetadataValid() && (
|
||||
<IconCheck
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.lg}
|
||||
color={theme.color.blue}
|
||||
/>
|
||||
)}
|
||||
</StyledUploadFileContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Service Provider Details"
|
||||
description="Enter the infos to set the connection"
|
||||
/>
|
||||
<StyledInputsContainer>
|
||||
<StyledContainer>
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
onClick={downloadMetadata}
|
||||
title="Download file"
|
||||
></Button>
|
||||
</StyledContainer>
|
||||
<HorizontalSeparator visible={true} text={'Or'} />
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="ACS Url"
|
||||
value={acsUrl}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<StyledButtonCopy>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
title="Copy"
|
||||
onClick={() => {
|
||||
enqueueSnackBar('ACS Url copied to clipboard', {
|
||||
variant: SnackBarVariant.Success,
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
});
|
||||
navigator.clipboard.writeText(acsUrl);
|
||||
}}
|
||||
/>
|
||||
</StyledButtonCopy>
|
||||
</StyledContainer>
|
||||
<StyledContainer>
|
||||
<StyledLinkContainer>
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Entity ID"
|
||||
value={entityID}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledLinkContainer>
|
||||
<StyledButtonCopy>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
title="Copy"
|
||||
onClick={() => {
|
||||
enqueueSnackBar('Entity ID copied to clipboard', {
|
||||
variant: SnackBarVariant.Success,
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
});
|
||||
navigator.clipboard.writeText(entityID);
|
||||
}}
|
||||
/>
|
||||
</StyledButtonCopy>
|
||||
</StyledContainer>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { IconLink } from 'twenty-ui';
|
||||
import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent';
|
||||
import { Card } from '@/ui/layout/card/components/Card';
|
||||
import styled from '@emotion/styled';
|
||||
import { Toggle } from '@/ui/input/components/Toggle';
|
||||
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
const StyledToggle = styled(Toggle)`
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
export const SettingsSecurityOptionsList = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
|
||||
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
||||
|
||||
const handleChange = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) {
|
||||
throw new Error('User is not logged in');
|
||||
}
|
||||
await updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
isPublicInviteLinkEnabled: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
isPublicInviteLinkEnabled: value,
|
||||
});
|
||||
} catch (err: any) {
|
||||
enqueueSnackBar(err?.message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SettingsOptionCardContent
|
||||
Icon={IconLink}
|
||||
title="Invite by Link"
|
||||
description="Allow the invitation of new users by sharing an invite link."
|
||||
onClick={() =>
|
||||
handleChange(!currentWorkspace?.isPublicInviteLinkEnabled)
|
||||
}
|
||||
>
|
||||
<StyledToggle value={currentWorkspace?.isPublicInviteLinkEnabled} />
|
||||
</SettingsOptionCardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,102 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { IconArchive, IconDotsVertical, IconTrash } from 'twenty-ui';
|
||||
|
||||
import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider';
|
||||
import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider';
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { UnwrapRecoilValue } from 'recoil';
|
||||
import { SsoIdentityProviderStatus } from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
type SettingsSecuritySSORowDropdownMenuProps = {
|
||||
SSOIdp: UnwrapRecoilValue<typeof SSOIdentitiesProvidersState>[0];
|
||||
};
|
||||
|
||||
export const SettingsSecuritySSORowDropdownMenu = ({
|
||||
SSOIdp,
|
||||
}: SettingsSecuritySSORowDropdownMenuProps) => {
|
||||
const dropdownId = `settings-account-row-${SSOIdp.id}`;
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const { closeDropdown } = useDropdown(dropdownId);
|
||||
|
||||
const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider();
|
||||
const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider();
|
||||
|
||||
const handleDeleteSSOIdentityProvider = async (
|
||||
identityProviderId: string,
|
||||
) => {
|
||||
const result = await deleteSSOIdentityProvider({
|
||||
identityProviderId,
|
||||
});
|
||||
if (isDefined(result.errors)) {
|
||||
enqueueSnackBar('Error deleting SSO Identity Provider', {
|
||||
variant: SnackBarVariant.Error,
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSSOIdentityProviderStatus = async (
|
||||
identityProviderId: string,
|
||||
) => {
|
||||
const result = await updateSSOIdentityProvider({
|
||||
id: identityProviderId,
|
||||
status:
|
||||
SSOIdp.status === 'Active'
|
||||
? SsoIdentityProviderStatus.Inactive
|
||||
: SsoIdentityProviderStatus.Active,
|
||||
});
|
||||
if (isDefined(result.errors)) {
|
||||
enqueueSnackBar('Error editing SSO Identity Provider', {
|
||||
variant: SnackBarVariant.Error,
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
dropdownHotkeyScope={{ scope: dropdownId }}
|
||||
clickableComponent={
|
||||
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
accent="default"
|
||||
LeftIcon={IconArchive}
|
||||
text={SSOIdp.status === 'Active' ? 'Deactivate' : 'Activate'}
|
||||
onClick={() => {
|
||||
toggleSSOIdentityProviderStatus(SSOIdp.id);
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text="Delete"
|
||||
onClick={() => {
|
||||
handleDeleteSSOIdentityProvider(SSOIdp.id);
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_OIDC_SSO_IDENTITY_PROVIDER = gql`
|
||||
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
|
||||
createOIDCIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_SAML_SSO_IDENTITY_PROVIDER = gql`
|
||||
mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) {
|
||||
createSAMLIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_SSO_IDENTITY_PROVIDER = gql`
|
||||
mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) {
|
||||
deleteSSOIdentityProvider(input: $input) {
|
||||
identityProviderId
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const EDIT_SSO_IDENTITY_PROVIDER = gql`
|
||||
mutation EditSSOIdentityProvider($input: EditSsoInput!) {
|
||||
editSSOIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const LIST_WORKSPACE_SSO_IDENTITY_PROVIDERS = gql`
|
||||
query ListSSOIdentityProvidersByWorkspaceId {
|
||||
listSSOIdentityProvidersByWorkspaceId {
|
||||
type
|
||||
id
|
||||
name
|
||||
issuer
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,94 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider';
|
||||
|
||||
const mutationOIDCCallSpy = jest.fn();
|
||||
const mutationSAMLCallSpy = jest.fn();
|
||||
|
||||
jest.mock('~/generated/graphql', () => ({
|
||||
useCreateOidcIdentityProviderMutation: () => [mutationOIDCCallSpy],
|
||||
useCreateSamlIdentityProviderMutation: () => [mutationSAMLCallSpy],
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useCreateSSOIdentityProvider', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('create OIDC sso identity provider', async () => {
|
||||
const OIDCParams = {
|
||||
type: 'OIDC' as const,
|
||||
name: 'test',
|
||||
clientID: 'test',
|
||||
clientSecret: 'test',
|
||||
issuer: 'test',
|
||||
};
|
||||
renderHook(
|
||||
() => {
|
||||
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
|
||||
createSSOIdentityProvider(OIDCParams);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const { type, ...input } = OIDCParams;
|
||||
expect(mutationOIDCCallSpy).toHaveBeenCalledWith({
|
||||
onCompleted: expect.any(Function),
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('create SAML sso identity provider', async () => {
|
||||
const SAMLParams = {
|
||||
type: 'SAML' as const,
|
||||
name: 'test',
|
||||
metadata: 'test',
|
||||
certificate: 'test',
|
||||
id: 'test',
|
||||
issuer: 'test',
|
||||
ssoURL: 'test',
|
||||
};
|
||||
renderHook(
|
||||
() => {
|
||||
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
|
||||
createSSOIdentityProvider(SAMLParams);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const { type, ...input } = SAMLParams;
|
||||
expect(mutationOIDCCallSpy).not.toHaveBeenCalled();
|
||||
expect(mutationSAMLCallSpy).toHaveBeenCalledWith({
|
||||
onCompleted: expect.any(Function),
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('throw error if provider is not SAML or OIDC', async () => {
|
||||
const OTHERParams = {
|
||||
type: 'OTHER' as const,
|
||||
};
|
||||
renderHook(
|
||||
async () => {
|
||||
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
|
||||
await expect(
|
||||
// @ts-expect-error - It's expected to throw an error
|
||||
createSSOIdentityProvider(OTHERParams),
|
||||
).rejects.toThrowError();
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,40 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider';
|
||||
|
||||
const mutationDeleteSSOIDPCallSpy = jest.fn();
|
||||
|
||||
jest.mock('~/generated/graphql', () => ({
|
||||
useDeleteSsoIdentityProviderMutation: () => [mutationDeleteSSOIDPCallSpy],
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useDeleteSsoIdentityProvider', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('delete SSO identity provider', async () => {
|
||||
renderHook(
|
||||
() => {
|
||||
const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider();
|
||||
deleteSSOIdentityProvider({ identityProviderId: 'test' });
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mutationDeleteSSOIDPCallSpy).toHaveBeenCalledWith({
|
||||
onCompleted: expect.any(Function),
|
||||
variables: {
|
||||
input: { identityProviderId: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider';
|
||||
import { SsoIdentityProviderStatus } from '~/generated/graphql';
|
||||
|
||||
const mutationEditSSOIDPCallSpy = jest.fn();
|
||||
|
||||
jest.mock('~/generated/graphql', () => {
|
||||
const actual = jest.requireActual('~/generated/graphql');
|
||||
return {
|
||||
useEditSsoIdentityProviderMutation: () => [mutationEditSSOIDPCallSpy],
|
||||
SsoIdentityProviderStatus: actual.SsoIdentityProviderStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useEditSsoIdentityProvider', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Deactivate SSO identity provider', async () => {
|
||||
const params = {
|
||||
id: 'test',
|
||||
status: SsoIdentityProviderStatus.Inactive,
|
||||
};
|
||||
renderHook(
|
||||
() => {
|
||||
const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider();
|
||||
updateSSOIdentityProvider(params);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mutationEditSSOIDPCallSpy).toHaveBeenCalledWith({
|
||||
onCompleted: expect.any(Function),
|
||||
variables: {
|
||||
input: params,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
CreateOidcIdentityProviderMutationVariables,
|
||||
CreateSamlIdentityProviderMutationVariables,
|
||||
useCreateOidcIdentityProviderMutation,
|
||||
useCreateSamlIdentityProviderMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useCreateSSOIdentityProvider = () => {
|
||||
const [createOidcIdentityProviderMutation] =
|
||||
useCreateOidcIdentityProviderMutation();
|
||||
const [createSamlIdentityProviderMutation] =
|
||||
useCreateSamlIdentityProviderMutation();
|
||||
|
||||
const setSSOIdentitiesProviders = useSetRecoilState(
|
||||
SSOIdentitiesProvidersState,
|
||||
);
|
||||
|
||||
const createSSOIdentityProvider = async (
|
||||
input:
|
||||
| ({
|
||||
type: 'OIDC';
|
||||
} & CreateOidcIdentityProviderMutationVariables['input'])
|
||||
| ({
|
||||
type: 'SAML';
|
||||
} & CreateSamlIdentityProviderMutationVariables['input']),
|
||||
) => {
|
||||
if (input.type === 'OIDC') {
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const { type, ...params } = input;
|
||||
return await createOidcIdentityProviderMutation({
|
||||
variables: { input: params },
|
||||
onCompleted: (data) => {
|
||||
setSSOIdentitiesProviders((existingProvider) => [
|
||||
...existingProvider,
|
||||
data.createOIDCIdentityProvider,
|
||||
]);
|
||||
},
|
||||
});
|
||||
} else if (input.type === 'SAML') {
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const { type, ...params } = input;
|
||||
return await createSamlIdentityProviderMutation({
|
||||
variables: { input: params },
|
||||
onCompleted: (data) => {
|
||||
setSSOIdentitiesProviders((existingProvider) => [
|
||||
...existingProvider,
|
||||
data.createSAMLIdentityProvider,
|
||||
]);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error('Invalid IdpType');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createSSOIdentityProvider,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
DeleteSsoIdentityProviderMutationVariables,
|
||||
useDeleteSsoIdentityProviderMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useDeleteSSOIdentityProvider = () => {
|
||||
const [deleteSsoIdentityProviderMutation] =
|
||||
useDeleteSsoIdentityProviderMutation();
|
||||
|
||||
const setSSOIdentitiesProviders = useSetRecoilState(
|
||||
SSOIdentitiesProvidersState,
|
||||
);
|
||||
|
||||
const deleteSSOIdentityProvider = async ({
|
||||
identityProviderId,
|
||||
}: DeleteSsoIdentityProviderMutationVariables['input']) => {
|
||||
return await deleteSsoIdentityProviderMutation({
|
||||
variables: {
|
||||
input: { identityProviderId },
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setSSOIdentitiesProviders((SSOIdentitiesProviders) =>
|
||||
SSOIdentitiesProviders.filter(
|
||||
(identityProvider) =>
|
||||
identityProvider.id !==
|
||||
data.deleteSSOIdentityProvider.identityProviderId,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
deleteSSOIdentityProvider,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
EditSsoIdentityProviderMutationVariables,
|
||||
useEditSsoIdentityProviderMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useUpdateSSOIdentityProvider = () => {
|
||||
const [editSsoIdentityProviderMutation] =
|
||||
useEditSsoIdentityProviderMutation();
|
||||
|
||||
const setSSOIdentitiesProviders = useSetRecoilState(
|
||||
SSOIdentitiesProvidersState,
|
||||
);
|
||||
|
||||
const updateSSOIdentityProvider = async (
|
||||
payload: EditSsoIdentityProviderMutationVariables['input'],
|
||||
) => {
|
||||
return await editSsoIdentityProviderMutation({
|
||||
variables: {
|
||||
input: payload,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setSSOIdentitiesProviders((SSOIdentitiesProviders) =>
|
||||
SSOIdentitiesProviders.map((identityProvider) =>
|
||||
identityProvider.id === data.editSSOIdentityProvider.id
|
||||
? data.editSSOIdentityProvider
|
||||
: identityProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
updateSSOIdentityProvider,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SSOIdentityProvider } from '@/settings/security/types/SSOIdentityProvider';
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const SSOIdentitiesProvidersState = createState<
|
||||
Omit<SSOIdentityProvider, '__typename'>[]
|
||||
>({
|
||||
key: 'SSOIdentitiesProvidersState',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema';
|
||||
import { z } from 'zod';
|
||||
import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql';
|
||||
|
||||
export type SSOIdentityProvider = {
|
||||
__typename: 'SSOIdentityProvider';
|
||||
id: string;
|
||||
type: IdpType;
|
||||
issuer: string;
|
||||
name?: string | null;
|
||||
status: SsoIdentityProviderStatus;
|
||||
};
|
||||
|
||||
export type SettingSecurityNewSSOIdentityFormValues = z.infer<
|
||||
typeof SSOIdentitiesProvidersParamsSchema
|
||||
>;
|
||||
@ -0,0 +1,39 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { parseSAMLMetadataFromXMLFile } from '../parseSAMLMetadataFromXMLFile';
|
||||
|
||||
describe('parseSAMLMetadataFromXMLFile', () => {
|
||||
it('should parse SAML metadata from XML file', () => {
|
||||
const xmlString = `<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://test.com" validUntil="2026-02-04T17:46:23.000Z">
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>test</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://test.com"/>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://test.com"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
const result = parseSAMLMetadataFromXMLFile(xmlString);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
entityID: 'https://test.com',
|
||||
ssoUrl: 'https://test.com',
|
||||
certificate: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should return error if XML is invalid', () => {
|
||||
const xmlString = 'invalid xml';
|
||||
const result = parseSAMLMetadataFromXMLFile(xmlString);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: new Error('Error parsing XML'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { ThemeColor } from 'twenty-ui';
|
||||
import { SsoIdentityProviderStatus } from '~/generated/graphql';
|
||||
|
||||
export const getColorBySSOIdentityProviderStatus: Record<
|
||||
SsoIdentityProviderStatus,
|
||||
ThemeColor
|
||||
> = {
|
||||
Active: 'green',
|
||||
Inactive: 'gray',
|
||||
Error: 'red',
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { IconComponent, IconGoogle, IconKey } from 'twenty-ui';
|
||||
|
||||
export const guessSSOIdentityProviderIconByUrl = (
|
||||
url: string,
|
||||
): IconComponent => {
|
||||
if (url.includes('google')) {
|
||||
return IconGoogle;
|
||||
}
|
||||
|
||||
return IconKey;
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
entityID: z.string().url(),
|
||||
ssoUrl: z.string().url(),
|
||||
certificate: z.string().min(1),
|
||||
});
|
||||
|
||||
export const parseSAMLMetadataFromXMLFile = (
|
||||
xmlString: string,
|
||||
):
|
||||
| { success: true; data: z.infer<typeof validator> }
|
||||
| { success: false; error: unknown } => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlString, 'application/xml');
|
||||
|
||||
if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
|
||||
throw new Error('Error parsing XML');
|
||||
}
|
||||
|
||||
const entityDescriptor = xmlDoc.getElementsByTagName(
|
||||
'md:EntityDescriptor',
|
||||
)?.[0];
|
||||
const idpSSODescriptor = xmlDoc.getElementsByTagName(
|
||||
'md:IDPSSODescriptor',
|
||||
)?.[0];
|
||||
const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0];
|
||||
const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0];
|
||||
const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0];
|
||||
const x509Certificate = x509Data
|
||||
.getElementsByTagName('ds:X509Certificate')?.[0]
|
||||
.textContent?.trim();
|
||||
|
||||
const singleSignOnServices = Array.from(
|
||||
idpSSODescriptor.getElementsByTagName('md:SingleSignOnService'),
|
||||
).map((service) => ({
|
||||
Binding: service.getAttribute('Binding'),
|
||||
Location: service.getAttribute('Location'),
|
||||
}));
|
||||
|
||||
const result = {
|
||||
ssoUrl: singleSignOnServices.find((singleSignOnService) => {
|
||||
return (
|
||||
singleSignOnService.Binding ===
|
||||
'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
|
||||
);
|
||||
})?.Location,
|
||||
certificate: x509Certificate,
|
||||
entityID: entityDescriptor?.getAttribute('entityID'),
|
||||
};
|
||||
|
||||
return { success: true, data: validator.parse(result) };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
|
||||
import { IdpType } from '~/generated/graphql';
|
||||
|
||||
export const sSOIdentityProviderDefaultValues: Record<
|
||||
IdpType,
|
||||
() => SettingSecurityNewSSOIdentityFormValues
|
||||
> = {
|
||||
SAML: () => ({
|
||||
type: 'SAML',
|
||||
ssoURL: '',
|
||||
name: '',
|
||||
id: crypto.randomUUID(),
|
||||
certificate: '',
|
||||
issuer: '',
|
||||
}),
|
||||
OIDC: () => ({
|
||||
type: 'OIDC',
|
||||
name: '',
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
issuer: '',
|
||||
}),
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SSOIdentitiesProvidersOIDCParamsSchema = z
|
||||
.object({
|
||||
type: z.literal('OIDC'),
|
||||
clientID: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
})
|
||||
.required();
|
||||
|
||||
export const SSOIdentitiesProvidersSAMLParamsSchema = z
|
||||
.object({
|
||||
type: z.literal('SAML'),
|
||||
id: z.string().optional(),
|
||||
ssoURL: z.string().url().optional(),
|
||||
certificate: z.string().optional(),
|
||||
})
|
||||
.required();
|
||||
|
||||
export const SSOIdentitiesProvidersParamsSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
SSOIdentitiesProvidersOIDCParamsSchema,
|
||||
SSOIdentitiesProvidersSAMLParamsSchema,
|
||||
])
|
||||
.and(
|
||||
z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
issuer: z.string().url().optional(),
|
||||
})
|
||||
.required(),
|
||||
);
|
||||
Reference in New Issue
Block a user