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:
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user