feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)
## Summary Add support for multi-workspace feature and adjust configurations and states accordingly. - Introduced new state isMultiWorkspaceEnabledState. - Updated ClientConfigProviderEffect component to handle multi-workspace. - Modified GraphQL schema and queries to include multi-workspace related configurations. - Adjusted server environment variables and their respective documentation to support multi-workspace toggle. - Updated server-side logic to handle new multi-workspace configurations and conditions.
This commit is contained in:
@ -0,0 +1,60 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpEmailField = ({
|
||||
showErrors,
|
||||
onChange: onChangeFromProps,
|
||||
}: {
|
||||
showErrors: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
}) => {
|
||||
const form = useFormContext<Form>();
|
||||
|
||||
return (
|
||||
<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 (isDefined(onChangeFromProps)) onChangeFromProps(value);
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
);
|
||||
};
|
||||
@ -1,393 +0,0 @@
|
||||
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import {
|
||||
useSignInUpForm,
|
||||
validationSchema,
|
||||
} 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';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import {
|
||||
ActionLink,
|
||||
HorizontalSeparator,
|
||||
IconGoogle,
|
||||
IconKey,
|
||||
IconMicrosoft,
|
||||
Loader,
|
||||
MainButton,
|
||||
StyledText,
|
||||
} from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpForm = () => {
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const isRequestingCaptchaToken = useRecoilValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
const { form } = useSignInUpForm();
|
||||
const { handleResetPassword } = useHandleResetPassword();
|
||||
|
||||
const {
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitCredentials,
|
||||
submitSSOEmail,
|
||||
} = useSignInUp(form);
|
||||
|
||||
if (
|
||||
signInUpStep === SignInUpStep.Init &&
|
||||
!authProviders.google &&
|
||||
!authProviders.microsoft &&
|
||||
!authProviders.sso
|
||||
) {
|
||||
continueWithEmail();
|
||||
}
|
||||
|
||||
const toggleSSOMode = () => {
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
continueWithEmail();
|
||||
} else {
|
||||
continueWithSSO();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === Key.Enter) {
|
||||
event.preventDefault();
|
||||
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
} else if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
if (!form.formState.isSubmitting) {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
} else if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = useMemo(() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
return 'Continue With Email';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
return 'Continue';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
return 'Continue with SSO';
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const shouldWaitForCaptchaToken =
|
||||
signInUpStep !== SignInUpStep.Init &&
|
||||
isDefined(captchaProvider?.provider) &&
|
||||
isRequestingCaptchaToken;
|
||||
|
||||
const isEmailStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Email &&
|
||||
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
|
||||
// We make the isValid check synchronous and update a reactState to make sure this does not happen
|
||||
const isPasswordStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Password &&
|
||||
(!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
const isSubmitButtonDisabled =
|
||||
isEmailStepSubmitButtonDisabledCondition ||
|
||||
isPasswordStepSubmitButtonDisabledCondition;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{authProviders.google && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{authProviders.microsoft && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
{authProviders.sso && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconKey size={theme.icon.size.lg} />}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
title={
|
||||
signInUpStep === SignInUpStep.SSOEmail
|
||||
? 'Continue with email'
|
||||
: 'Single sign-on (SSO)'
|
||||
}
|
||||
onClick={toggleSSOMode}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(authProviders.google ||
|
||||
authProviders.microsoft ||
|
||||
authProviders.sso) && <HorizontalSeparator visible />}
|
||||
|
||||
{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}
|
||||
/>
|
||||
{signInUpMode === SignInUpMode.SignUp && (
|
||||
<StyledText
|
||||
text={'At least 8 characters long.'}
|
||||
color={theme.font.color.secondary}
|
||||
/>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
<MainButton
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
|
||||
}
|
||||
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 /> : null)}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep === SignInUpStep.SSOEmail && (
|
||||
<>
|
||||
<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={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
<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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,164 @@
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
IconGoogle,
|
||||
IconMicrosoft,
|
||||
Loader,
|
||||
MainButton,
|
||||
HorizontalSeparator,
|
||||
} from 'twenty-ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
|
||||
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { signInUpModeState } from '@/auth/states/signInUpModeState';
|
||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledContentContainer = styled(motion.div)`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SignInUpGlobalScopeForm = () => {
|
||||
const theme = useTheme();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
const { checkUserExists } = useAuth();
|
||||
const { readCaptchaToken } = useReadCaptchaToken();
|
||||
const { redirectToWorkspace } = useUrlManager();
|
||||
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
||||
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { submitCredentials } = useSignInUp(form);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
await submitCredentials(form.getValues());
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await readCaptchaToken();
|
||||
await checkUserExists.checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email'),
|
||||
captchaToken: token,
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueSnackBar(`${error.message}`, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
requestFreshCaptchaToken();
|
||||
if (data.checkUserExists.__typename === 'UserExists') {
|
||||
if (
|
||||
isDefined(data?.checkUserExists.availableWorkspaces) &&
|
||||
data.checkUserExists.availableWorkspaces.length >= 1
|
||||
) {
|
||||
return redirectToWorkspace(
|
||||
data?.checkUserExists.availableWorkspaces[0].subdomain,
|
||||
pathname,
|
||||
{
|
||||
email: form.getValues('email'),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.checkUserExists.__typename === 'UserNotExists') {
|
||||
setSignInUpMode(SignInUpMode.SignUp);
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
<HorizontalSeparator visible />
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<FormProvider {...form}>
|
||||
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<SignInUpEmailField showErrors={showErrors} />
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<SignInUpPasswordField
|
||||
showErrors={showErrors}
|
||||
signInUpMode={signInUpMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainButton
|
||||
title={
|
||||
signInUpStep === SignInUpStep.Password ? 'Sign Up' : 'Continue'
|
||||
}
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</FormProvider>
|
||||
</StyledContentContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,67 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { StyledText } from 'twenty-ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpPasswordField = ({
|
||||
showErrors,
|
||||
signInUpMode,
|
||||
}: {
|
||||
showErrors: boolean;
|
||||
signInUpMode: SignInUpMode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const form = useFormContext<Form>();
|
||||
|
||||
return (
|
||||
<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
|
||||
/>
|
||||
{signInUpMode === SignInUpMode.SignUp && (
|
||||
<StyledText
|
||||
text={'At least 8 characters long.'}
|
||||
color={theme.font.color.secondary}
|
||||
/>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SignInUpSSOIdentityProviderSelection = () => {
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{isDefined(authProviders?.sso) &&
|
||||
authProviders?.sso.map((idp) => (
|
||||
<>
|
||||
<MainButton
|
||||
key={idp.id}
|
||||
title={idp.name}
|
||||
onClick={() => redirectToSSOLoginPage(idp.id)}
|
||||
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
))}
|
||||
</StyledContentContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,142 @@
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { Loader, MainButton } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import styled from '@emotion/styled';
|
||||
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SignInUpWithCredentials = () => {
|
||||
const { form, validationSchema } = useSignInUpForm();
|
||||
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const isRequestingCaptchaToken = useRecoilValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
|
||||
const {
|
||||
signInUpMode,
|
||||
continueWithEmail,
|
||||
continueWithCredentials,
|
||||
submitCredentials,
|
||||
} = useSignInUp(form);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitButtonDisabled) return;
|
||||
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
} else if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
if (!form.formState.isSubmitting) {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = useMemo(() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
return 'Continue With Email';
|
||||
}
|
||||
|
||||
if (
|
||||
signInUpMode === SignInUpMode.SignIn &&
|
||||
signInUpStep === SignInUpStep.Password
|
||||
) {
|
||||
return 'Sign in';
|
||||
}
|
||||
|
||||
if (
|
||||
signInUpMode === SignInUpMode.SignUp &&
|
||||
signInUpStep === SignInUpStep.Password
|
||||
) {
|
||||
return 'Sign up';
|
||||
}
|
||||
|
||||
return 'Continue';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
const shouldWaitForCaptchaToken =
|
||||
signInUpStep !== SignInUpStep.Init &&
|
||||
isDefined(captchaProvider?.provider) &&
|
||||
isRequestingCaptchaToken;
|
||||
|
||||
const isEmailStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Email &&
|
||||
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
|
||||
// We make the isValid check synchronous and update a reactState to make sure this does not happen
|
||||
const isPasswordStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Password &&
|
||||
(!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
const isSubmitButtonDisabled =
|
||||
isEmailStepSubmitButtonDisabledCondition ||
|
||||
isPasswordStepSubmitButtonDisabledCondition;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(signInUpStep === SignInUpStep.Password ||
|
||||
signInUpStep === SignInUpStep.Email ||
|
||||
signInUpStep === SignInUpStep.Init) && (
|
||||
<>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<FormProvider {...form}>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<SignInUpEmailField showErrors={showErrors} />
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<SignInUpPasswordField
|
||||
showErrors={showErrors}
|
||||
signInUpMode={signInUpMode}
|
||||
/>
|
||||
)}
|
||||
<MainButton
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
|
||||
}
|
||||
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</FormProvider>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { IconGoogle, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { memo } from 'react';
|
||||
|
||||
const GoogleIcon = memo(() => {
|
||||
const theme = useTheme();
|
||||
return <IconGoogle size={theme.icon.size.md} />;
|
||||
});
|
||||
|
||||
export const SignInUpWithGoogle = () => {
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={GoogleIcon}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import { IconMicrosoft, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const SignInUpWithMicrosoft = () => {
|
||||
const theme = useTheme();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.md} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import { IconLock, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
|
||||
export const SignInUpWithSSO = () => {
|
||||
const theme = useTheme();
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
|
||||
const signInWithSSO = () => {
|
||||
if (authProviders.sso.length === 1) {
|
||||
return redirectToSSOLoginPage(authProviders.sso[0].id);
|
||||
}
|
||||
|
||||
setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconLock size={theme.icon.size.md} />}
|
||||
title="Single sign-on (SSO)"
|
||||
onClick={signInWithSSO}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ActionLink, HorizontalSeparator } from 'twenty-ui';
|
||||
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
|
||||
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
|
||||
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO';
|
||||
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SignInUpWorkspaceScopeForm = () => {
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
const { handleResetPassword } = useHandleResetPassword();
|
||||
|
||||
const { signInUpStep, continueWithEmail, continueWithCredentials } =
|
||||
useSignInUp(form);
|
||||
const location = useLocation();
|
||||
|
||||
const checkAuthProviders = useCallback(() => {
|
||||
if (
|
||||
signInUpStep === SignInUpStep.Init &&
|
||||
!authProviders.google &&
|
||||
!authProviders.microsoft &&
|
||||
!authProviders.sso
|
||||
) {
|
||||
return continueWithEmail();
|
||||
}
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const email = searchParams.get('email');
|
||||
if (isDefined(email) && authProviders.password) {
|
||||
return continueWithCredentials();
|
||||
}
|
||||
}, [
|
||||
continueWithCredentials,
|
||||
location.search,
|
||||
authProviders.google,
|
||||
authProviders.microsoft,
|
||||
authProviders.password,
|
||||
authProviders.sso,
|
||||
continueWithEmail,
|
||||
signInUpStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthProviders();
|
||||
}, [checkAuthProviders]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{authProviders.google && <SignInUpWithGoogle />}
|
||||
|
||||
{authProviders.microsoft && <SignInUpWithMicrosoft />}
|
||||
|
||||
{authProviders.sso.length > 0 && <SignInUpWithSSO />}
|
||||
|
||||
{(authProviders.google ||
|
||||
authProviders.microsoft ||
|
||||
authProviders.sso.length > 0) &&
|
||||
authProviders.password ? (
|
||||
<HorizontalSeparator visible />
|
||||
) : null}
|
||||
|
||||
{authProviders.password && <SignInUpWithCredentials />}
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user