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:
Antoine Moreaux
2024-10-21 20:07:08 +02:00
committed by GitHub
parent 11c3f1c399
commit 0f0a7966b1
132 changed files with 5245 additions and 306 deletions

View File

@ -91,25 +91,7 @@ export const Invite = () => {
fullWidth
/>
</StyledContentContainer>
<FooterNote>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</FooterNote>
<FooterNote />
</>
) : (
<SignInUpForm />

View File

@ -0,0 +1,69 @@
/* @license Enterprise */
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
export const SSOWorkspaceSelection = () => {
const availableSSOIdentityProviders = useRecoilValue(
availableSSOIdentityProvidersState,
);
const { redirectToSSOLoginPage } = useSSO();
const availableWorkspacesForSSOGroupByWorkspace =
availableSSOIdentityProviders.reduce(
(acc, idp) => {
acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp];
return acc;
},
{} as Record<string, typeof availableSSOIdentityProviders>,
);
return (
<>
<StyledContentContainer>
{Object.values(availableWorkspacesForSSOGroupByWorkspace).map(
(idps) => (
<>
<StyledTitle>
{idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME}
</StyledTitle>
<HorizontalSeparator visible={false} />
{idps.map((idp) => (
<>
<MainButton
title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
))}
</>
),
)}
</StyledContentContainer>
<FooterNote />
</>
);
};

View File

@ -4,15 +4,14 @@ import { useRecoilValue } from 'recoil';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
import {
SignInUpMode,
SignInUpStep,
useSignInUp,
} from '@/auth/sign-in-up/hooks/useSignInUp';
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { isDefined } from '~/utils/isDefined';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { IconLockCustom } from '@ui/display/icon/components/IconLock';
import { SSOWorkspaceSelection } from './SSOWorkspaceSelection';
export const SignInUp = () => {
const { form } = useSignInUpForm();
@ -27,6 +26,9 @@ export const SignInUp = () => {
) {
return 'Welcome to Twenty';
}
if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) {
return 'Choose SSO connection';
}
return signInUpMode === SignInUpMode.SignIn
? 'Sign in to Twenty'
: 'Sign up to Twenty';
@ -39,10 +41,18 @@ export const SignInUp = () => {
return (
<>
<AnimatedEaseIn>
<Logo />
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<IconLockCustom size={40} />
) : (
<Logo />
)}
</AnimatedEaseIn>
<Title animate>{title}</Title>
<SignInUpForm />
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<SSOWorkspaceSelection />
) : (
<SignInUpForm />
)}
</>
);
};

View File

@ -148,17 +148,18 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
{currentWorkspace?.inviteHash && (
<Section>
<H2Title
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
/>
</Section>
)}
{currentWorkspace?.inviteHash &&
currentWorkspace?.isPublicInviteLinkEnabled && (
<Section>
<H2Title
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
/>
</Section>
)}
<Section>
<H2Title
title="Members"

View File

@ -0,0 +1,40 @@
import { H2Title } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityOptionsList } from '@/settings/security/components/SettingsSecurityOptionsList';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
export const SettingsSecurity = () => {
return (
<SubMenuTopBarContainer
title="Security"
actionButton={<SettingsReadDocumentationButton />}
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'Security' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="SSO" description="Configure an SSO connection" />
<SettingsSSOIdentitiesProvidersListCard />
</Section>
<Section>
<H2Title
title="Other"
description="Customize your workspace security"
/>
<SettingsSecurityOptionsList />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,86 @@
/* @license Enterprise */
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm';
import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider';
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues';
import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
export const SettingsSecuritySSOIdentifyProvider = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
const formConfig = useForm<SettingSecurityNewSSOIdentityFormValues>({
mode: 'onChange',
resolver: zodResolver(SSOIdentitiesProvidersParamsSchema),
defaultValues: Object.values(sSOIdentityProviderDefaultValues).reduce(
(acc, fn) => ({ ...acc, ...fn() }),
{},
),
});
const selectedType = formConfig.watch('type');
useEffect(
() =>
formConfig.reset({
...sSOIdentityProviderDefaultValues[selectedType](),
name: formConfig.getValues('name'),
}),
[formConfig, selectedType],
);
const handleSave = async () => {
try {
await createSSOIdentityProvider(formConfig.getValues());
navigate(getSettingsPagePath(SettingsPath.Security));
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
return (
<SubMenuTopBarContainer
title="New SSO Configuration"
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!formConfig.formState.isValid}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Security))}
onSave={handleSave}
/>
}
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{
children: 'Security',
href: getSettingsPagePath(SettingsPath.Security),
},
{ children: 'New' },
]}
>
<FormProvider
// eslint-disable-next-line react/jsx-props-no-spreading
{...formConfig}
>
<SettingsSSOIdentitiesProvidersForm />
</FormProvider>
</SubMenuTopBarContainer>
);
};