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:
@ -1,8 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
|
||||
type FooterNoteProps = { children: React.ReactNode };
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
@ -20,6 +18,24 @@ const StyledContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const FooterNote = ({ children }: FooterNoteProps) => (
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
export const FooterNote = () => (
|
||||
<StyledContainer>
|
||||
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>
|
||||
.
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@ -3,6 +3,7 @@ import styled from '@emotion/styled';
|
||||
|
||||
type HorizontalSeparatorProps = {
|
||||
visible?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
const StyledSeparator = styled.div<HorizontalSeparatorProps>`
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
@ -12,8 +13,39 @@ const StyledSeparator = styled.div<HorizontalSeparatorProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSeparatorContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledLine = styled.div<HorizontalSeparatorProps>`
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
height: ${({ visible }) => (visible ? '1px' : 0)};
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledText = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
margin: 0 ${({ theme }) => theme.spacing(2)};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const HorizontalSeparator = ({
|
||||
visible = true,
|
||||
text = '',
|
||||
}: HorizontalSeparatorProps): JSX.Element => (
|
||||
<StyledSeparator visible={visible} />
|
||||
<>
|
||||
{text ? (
|
||||
<StyledSeparatorContainer>
|
||||
<StyledLine visible={visible} />
|
||||
{text && <StyledText>{text}</StyledText>}
|
||||
<StyledLine visible={visible} />
|
||||
</StyledSeparatorContainer>
|
||||
) : (
|
||||
<StyledSeparator visible={visible} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -5,16 +5,12 @@ import { useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
|
||||
import { IconGoogle, IconMicrosoft, IconKey } from 'twenty-ui';
|
||||
|
||||
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
|
||||
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
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 { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
@ -26,6 +22,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
@ -64,9 +61,19 @@ export const SignInUpForm = () => {
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitCredentials,
|
||||
submitSSOEmail,
|
||||
} = useSignInUp(form);
|
||||
|
||||
const toggleSSOMode = () => {
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
continueWithEmail();
|
||||
} else {
|
||||
continueWithSSO();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@ -86,6 +93,8 @@ export const SignInUpForm = () => {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
} else if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -99,6 +108,10 @@ export const SignInUpForm = () => {
|
||||
return 'Continue';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
return 'Continue with SSO';
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
@ -136,7 +149,7 @@ export const SignInUpForm = () => {
|
||||
onClick={signInWithGoogle}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={!authProviders.microsoft} />
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -148,17 +161,143 @@ export const SignInUpForm = () => {
|
||||
onClick={signInWithMicrosoft}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={authProviders.password} />
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
{authProviders.sso && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconKey size={theme.icon.size.lg} />}
|
||||
title={
|
||||
signInUpStep === SignInUpStep.SSOEmail
|
||||
? 'Continue with email'
|
||||
: 'Single sign-on (SSO)'
|
||||
}
|
||||
onClick={toggleSSOMode}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{authProviders.password && (
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<HorizontalSeparator visible={true} />
|
||||
|
||||
{authProviders.password &&
|
||||
(signInUpStep === SignInUpStep.Password ||
|
||||
signInUpStep === SignInUpStep.Email ||
|
||||
signInUpStep === SignInUpStep.Init) && (
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={(value: string) => {
|
||||
onChange(value);
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
continueWithEmail();
|
||||
}
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
return;
|
||||
}
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep === SignInUpStep.SSOEmail && (
|
||||
<>
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
@ -181,46 +320,6 @@ export const SignInUpForm = () => {
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={(value: string) => {
|
||||
onChange(value);
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
continueWithEmail();
|
||||
}
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
@ -231,60 +330,28 @@ export const SignInUpForm = () => {
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
return;
|
||||
}
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
setShowErrors(true);
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledForm>
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Init && (
|
||||
<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>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Init && <FooterNote />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user