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:
@ -1,13 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql`
|
||||
mutation FindAvailableSSOIdentityProviders(
|
||||
$input: FindAvailableSSOIDPInput!
|
||||
) {
|
||||
findAvailableSSOIdentityProviders(input: $input) {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -1,24 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_JWT = gql`
|
||||
mutation GenerateJWT($workspaceId: String!) {
|
||||
generateJWT(workspaceId: $workspaceId) {
|
||||
... on GenerateJWTOutputWithAuthTokens {
|
||||
success
|
||||
reason
|
||||
authTokens {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
... on GenerateJWTOutputWithSSOAUTH {
|
||||
success
|
||||
reason
|
||||
availableSSOIDPs {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,23 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SWITCH_WORKSPACE = gql`
|
||||
mutation SwitchWorkspace($workspaceId: String!) {
|
||||
switchWorkspace(workspaceId: $workspaceId) {
|
||||
id
|
||||
subdomain
|
||||
authProviders {
|
||||
sso {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
issuer
|
||||
}
|
||||
google
|
||||
magicLink
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -3,7 +3,26 @@ import { gql } from '@apollo/client';
|
||||
export const CHECK_USER_EXISTS = gql`
|
||||
query CheckUserExists($email: String!, $captchaToken: String) {
|
||||
checkUserExists(email: $email, captchaToken: $captchaToken) {
|
||||
exists
|
||||
__typename
|
||||
... on UserExists {
|
||||
exists
|
||||
availableWorkspaces {
|
||||
id
|
||||
displayName
|
||||
subdomain
|
||||
logo
|
||||
sso {
|
||||
type
|
||||
id
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
... on UserNotExists {
|
||||
exists
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
|
||||
query GetPublicWorkspaceDataBySubdomain {
|
||||
getPublicWorkspaceDataBySubdomain {
|
||||
id
|
||||
logo
|
||||
displayName
|
||||
subdomain
|
||||
authProviders {
|
||||
sso {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
issuer
|
||||
}
|
||||
google
|
||||
magicLink
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -114,11 +114,11 @@ describe('useAuth', () => {
|
||||
|
||||
expect(state.icons).toEqual({});
|
||||
expect(state.authProviders).toEqual({
|
||||
google: false,
|
||||
google: true,
|
||||
microsoft: false,
|
||||
magicLink: false,
|
||||
password: false,
|
||||
sso: false,
|
||||
password: true,
|
||||
sso: [],
|
||||
});
|
||||
expect(state.billing).toBeNull();
|
||||
expect(state.isDeveloperDefaultSignInPrefilled).toBe(false);
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
snapshot_UNSTABLE,
|
||||
useGotoRecoilSnapshot,
|
||||
useRecoilCallback,
|
||||
useRecoilState,
|
||||
useRecoilValue,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import { iconsState } from 'twenty-ui';
|
||||
@ -42,10 +42,18 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
|
||||
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
|
||||
import { currentUserState } from '../states/currentUserState';
|
||||
import { tokenPairState } from '../states/tokenPairState';
|
||||
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
|
||||
|
||||
import { urlManagerState } from '@/url-manager/states/url-manager.state';
|
||||
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [, setTokenPair] = useRecoilState(tokenPairState);
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const setCurrentUser = useSetRecoilState(currentUserState);
|
||||
const urlManager = useRecoilValue(urlManagerState);
|
||||
const setLastAuthenticateWorkspaceState = useSetRecoilState(
|
||||
lastAuthenticateWorkspaceState,
|
||||
);
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
@ -60,6 +68,7 @@ export const useAuth = () => {
|
||||
const [challenge] = useChallengeMutation();
|
||||
const [signUp] = useSignUpMutation();
|
||||
const [verify] = useVerifyMutation();
|
||||
const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager();
|
||||
const [checkUserExistsQuery, { data: checkUserExistsData }] =
|
||||
useCheckUserExistsLazyQuery();
|
||||
|
||||
@ -203,6 +212,15 @@ export const useAuth = () => {
|
||||
const workspace = user.defaultWorkspace ?? null;
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
if (isDefined(workspace) && isTwentyWorkspaceSubdomain) {
|
||||
setLastAuthenticateWorkspaceState({
|
||||
id: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
cookieAttributes: {
|
||||
domain: `.${urlManager.frontDomain}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(verifyResult.data?.verify.user.workspaces)) {
|
||||
const validWorkspaces = verifyResult.data?.verify.user.workspaces
|
||||
@ -227,9 +245,12 @@ export const useAuth = () => {
|
||||
setTokenPair,
|
||||
setCurrentUser,
|
||||
setCurrentWorkspace,
|
||||
isTwentyWorkspaceSubdomain,
|
||||
setCurrentWorkspaceMembers,
|
||||
setCurrentWorkspaceMember,
|
||||
setDateTimeFormat,
|
||||
setLastAuthenticateWorkspaceState,
|
||||
urlManager.frontDomain,
|
||||
setWorkspaces,
|
||||
],
|
||||
);
|
||||
@ -301,23 +322,34 @@ export const useAuth = () => {
|
||||
[setIsVerifyPendingState, signUp, handleVerify],
|
||||
);
|
||||
|
||||
const buildRedirectUrl = (
|
||||
path: string,
|
||||
params: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceInviteHash?: string;
|
||||
const buildRedirectUrl = useCallback(
|
||||
(
|
||||
path: string,
|
||||
params: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceInviteHash?: string;
|
||||
},
|
||||
) => {
|
||||
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
|
||||
if (isDefined(params.workspaceInviteHash)) {
|
||||
url.searchParams.set('inviteHash', params.workspaceInviteHash);
|
||||
}
|
||||
if (isDefined(params.workspacePersonalInviteToken)) {
|
||||
url.searchParams.set(
|
||||
'inviteToken',
|
||||
params.workspacePersonalInviteToken,
|
||||
);
|
||||
}
|
||||
const subdomain = getWorkspaceSubdomain;
|
||||
|
||||
if (isDefined(subdomain)) {
|
||||
url.searchParams.set('workspaceSubdomain', subdomain);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
) => {
|
||||
const authServerUrl = REACT_APP_SERVER_BASE_URL;
|
||||
const url = new URL(`${authServerUrl}${path}`);
|
||||
if (isDefined(params.workspaceInviteHash)) {
|
||||
url.searchParams.set('inviteHash', params.workspaceInviteHash);
|
||||
}
|
||||
if (isDefined(params.workspacePersonalInviteToken)) {
|
||||
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
[getWorkspaceSubdomain],
|
||||
);
|
||||
|
||||
const handleGoogleLogin = useCallback(
|
||||
(params: {
|
||||
@ -326,7 +358,7 @@ export const useAuth = () => {
|
||||
}) => {
|
||||
window.location.href = buildRedirectUrl('/auth/google', params);
|
||||
},
|
||||
[],
|
||||
[buildRedirectUrl],
|
||||
);
|
||||
|
||||
const handleMicrosoftLogin = useCallback(
|
||||
@ -336,7 +368,7 @@ export const useAuth = () => {
|
||||
}) => {
|
||||
window.location.href = buildRedirectUrl('/auth/microsoft', params);
|
||||
},
|
||||
[],
|
||||
[buildRedirectUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -3,9 +3,7 @@
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
FindAvailableSsoIdentityProvidersMutationVariables,
|
||||
GetAuthorizationUrlMutationVariables,
|
||||
useFindAvailableSsoIdentityProvidersMutation,
|
||||
useGetAuthorizationUrlMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
@ -13,20 +11,8 @@ import { isDefined } from '~/utils/isDefined';
|
||||
export const useSSO = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [findAvailableSSOProviderByEmailMutation] =
|
||||
useFindAvailableSsoIdentityProvidersMutation();
|
||||
const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation();
|
||||
|
||||
const findAvailableSSOProviderByEmail = async ({
|
||||
email,
|
||||
}: FindAvailableSsoIdentityProvidersMutationVariables['input']) => {
|
||||
return await findAvailableSSOProviderByEmailMutation({
|
||||
variables: {
|
||||
input: { email },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getAuthorizationUrlForSSO = async ({
|
||||
identityProviderId,
|
||||
}: GetAuthorizationUrlMutationVariables['input']) => {
|
||||
@ -63,6 +49,5 @@ export const useSSO = () => {
|
||||
return {
|
||||
redirectToSSOLoginPage,
|
||||
getAuthorizationUrlForSSO,
|
||||
findAvailableSSOProviderByEmail,
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,36 +7,25 @@ import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
SignUp = 'sign-up',
|
||||
}
|
||||
import { signInUpModeState } from '@/auth/states/signInUpModeState';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
|
||||
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
|
||||
|
||||
const isMatchingLocation = useIsMatchingLocation();
|
||||
|
||||
const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO();
|
||||
const setAvailableWorkspacesForSSOState = useSetRecoilState(
|
||||
availableSSOIdentityProvidersState,
|
||||
);
|
||||
|
||||
const workspaceInviteHash = useParams().workspaceInviteHash;
|
||||
const [searchParams] = useSearchParams();
|
||||
const workspacePersonalInviteToken =
|
||||
@ -44,12 +33,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
|
||||
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
|
||||
|
||||
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
|
||||
return isMatchingLocation(AppPath.SignInUp)
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp;
|
||||
});
|
||||
|
||||
const {
|
||||
signInWithCredentials,
|
||||
signUpWithCredentials,
|
||||
@ -67,7 +50,12 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
}, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]);
|
||||
}, [
|
||||
isMatchingLocation,
|
||||
requestFreshCaptchaToken,
|
||||
setSignInUpMode,
|
||||
setSignInUpStep,
|
||||
]);
|
||||
|
||||
const continueWithCredentials = useCallback(async () => {
|
||||
const token = await readCaptchaToken();
|
||||
@ -101,47 +89,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
enqueueSnackBar,
|
||||
requestFreshCaptchaToken,
|
||||
setSignInUpStep,
|
||||
setSignInUpMode,
|
||||
]);
|
||||
|
||||
const continueWithSSO = () => {
|
||||
setSignInUpStep(SignInUpStep.SSOEmail);
|
||||
};
|
||||
|
||||
const submitSSOEmail = async (email: string) => {
|
||||
const result = await findAvailableSSOProviderByEmail({
|
||||
email,
|
||||
});
|
||||
|
||||
if (isDefined(result.errors)) {
|
||||
return enqueueSnackBar(result.errors[0].message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!result.data?.findAvailableSSOIdentityProviders ||
|
||||
result.data?.findAvailableSSOIdentityProviders.length === 0
|
||||
) {
|
||||
enqueueSnackBar('No workspaces with SSO found', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If only one workspace, redirect to SSO
|
||||
if (result.data?.findAvailableSSOIdentityProviders.length === 1) {
|
||||
return redirectToSSOLoginPage(
|
||||
result.data.findAvailableSSOIdentityProviders[0].id,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.data?.findAvailableSSOIdentityProviders.length > 1) {
|
||||
setAvailableWorkspacesForSSOState(
|
||||
result.data.findAvailableSSOIdentityProviders,
|
||||
);
|
||||
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
async (data) => {
|
||||
const token = await readCaptchaToken();
|
||||
@ -150,19 +100,21 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
|
||||
signInUpMode === SignInUpMode.SignIn && !isInviteMode
|
||||
? await signInWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
token,
|
||||
)
|
||||
: await signUpWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
token,
|
||||
);
|
||||
if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) {
|
||||
await signInWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await signUpWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
token,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
enqueueSnackBar(err?.message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
@ -189,8 +141,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitSSOEmail,
|
||||
submitCredentials,
|
||||
};
|
||||
};
|
||||
|
||||
@ -3,33 +3,50 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { z } from 'zod';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
|
||||
export const validationSchema = z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z.string().trim().email('Email must be a valid email'),
|
||||
password: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
captchaToken: z.string().default(''),
|
||||
})
|
||||
.required();
|
||||
const makeValidationSchema = (signInUpStep: SignInUpStep) =>
|
||||
z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z.string().trim().email('Email must be a valid email'),
|
||||
password:
|
||||
signInUpStep === SignInUpStep.Password
|
||||
? z
|
||||
.string()
|
||||
.regex(
|
||||
PASSWORD_REGEX,
|
||||
'Password must contain at least 8 characters',
|
||||
)
|
||||
: z.string().optional(),
|
||||
captchaToken: z.string().default(''),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type Form = z.infer<typeof validationSchema>;
|
||||
export type Form = z.infer<ReturnType<typeof makeValidationSchema>>;
|
||||
export const useSignInUpForm = () => {
|
||||
const location = useLocation();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step
|
||||
|
||||
const isDeveloperDefaultSignInPrefilled = useRecoilValue(
|
||||
isDeveloperDefaultSignInPrefilledState,
|
||||
);
|
||||
const [searchParams] = useSearchParams();
|
||||
const invitationPrefilledEmail = searchParams.get('email');
|
||||
const prefilledEmail = searchParams.get('email');
|
||||
|
||||
const form = useForm<Form>({
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
defaultValues: {
|
||||
exist: false,
|
||||
email: '',
|
||||
@ -40,12 +57,12 @@ export const useSignInUpForm = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(invitationPrefilledEmail)) {
|
||||
form.setValue('email', invitationPrefilledEmail);
|
||||
if (isDefined(prefilledEmail)) {
|
||||
form.setValue('email', prefilledEmail);
|
||||
} else if (isDeveloperDefaultSignInPrefilled === true) {
|
||||
form.setValue('email', 'tim@apple.dev');
|
||||
form.setValue('password', 'Applecar2025');
|
||||
}
|
||||
}, [form, isDeveloperDefaultSignInPrefilled, invitationPrefilledEmail]);
|
||||
}, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail, location.search]);
|
||||
return { form: form };
|
||||
};
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { UserExists } from '~/generated/graphql';
|
||||
|
||||
export const availableSSOIdentityProvidersForAuthState = createState<
|
||||
NonNullable<UserExists['availableWorkspaces']>[0]['sso']
|
||||
>({
|
||||
key: 'availableSSOIdentityProvidersForAuth',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql';
|
||||
|
||||
export const availableSSOIdentityProvidersState = createState<
|
||||
NonNullable<
|
||||
FindAvailableSsoIdentityProvidersMutationResult['data']
|
||||
>['findAvailableSSOIdentityProviders']
|
||||
>({
|
||||
key: 'availableSSOIdentityProviders',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -14,7 +14,11 @@ export type CurrentWorkspace = Pick<
|
||||
| 'currentBillingSubscription'
|
||||
| 'workspaceMembersCount'
|
||||
| 'isPublicInviteLinkEnabled'
|
||||
| 'isGoogleAuthEnabled'
|
||||
| 'isMicrosoftAuthEnabled'
|
||||
| 'isPasswordAuthEnabled'
|
||||
| 'hasValidEntrepriseKey'
|
||||
| 'subdomain'
|
||||
| 'metadataVersion'
|
||||
>;
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { cookieStorageEffect } from '~/utils/recoil-effects';
|
||||
import { Workspace } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const lastAuthenticateWorkspaceState = createState<
|
||||
| (Pick<Workspace, 'id' | 'subdomain'> & {
|
||||
cookieAttributes?: Cookies.CookieAttributes;
|
||||
})
|
||||
| null
|
||||
>({
|
||||
key: 'lastAuthenticateWorkspaceState',
|
||||
defaultValue: null,
|
||||
effects: [
|
||||
cookieStorageEffect('lastAuthenticateWorkspace', {
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
export const signInUpModeState = createState<SignInUpMode>({
|
||||
key: 'signInUpModeState',
|
||||
defaultValue: SignInUpMode.SignIn,
|
||||
});
|
||||
@ -4,8 +4,8 @@ export enum SignInUpStep {
|
||||
Init = 'init',
|
||||
Email = 'email',
|
||||
Password = 'password',
|
||||
SSOEmail = 'SSOEmail',
|
||||
SSOWorkspaceSelection = 'SSOWorkspaceSelection',
|
||||
WorkspaceSelection = 'workspaceSelection',
|
||||
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
|
||||
}
|
||||
|
||||
export const signInUpStepState = createState<SignInUpStep>({
|
||||
|
||||
@ -2,9 +2,17 @@ import { createState } from 'twenty-ui';
|
||||
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
import { cookieStorageEffect } from '~/utils/recoil-effects';
|
||||
|
||||
export const tokenPairState = createState<AuthTokenPair | null>({
|
||||
key: 'tokenPairState',
|
||||
defaultValue: null,
|
||||
effects: [cookieStorageEffect('tokenPair')],
|
||||
effects: [
|
||||
cookieStorageEffect(
|
||||
'tokenPair',
|
||||
{},
|
||||
{
|
||||
validateInitFn: (payload: AuthTokenPair) =>
|
||||
Boolean(payload['accessToken']),
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
|
||||
|
||||
export const workspacePublicDataState =
|
||||
createState<PublicWorkspaceDataOutput | null>({
|
||||
key: 'workspacePublicDataState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -2,7 +2,10 @@ import { createState } from 'twenty-ui';
|
||||
|
||||
import { Workspace } from '~/generated/graphql';
|
||||
|
||||
export type Workspaces = Pick<Workspace, 'id' | 'logo' | 'displayName'>;
|
||||
export type Workspaces = Pick<
|
||||
Workspace,
|
||||
'id' | 'logo' | 'displayName' | 'subdomain'
|
||||
>;
|
||||
|
||||
export const workspacesState = createState<Workspaces[] | null>({
|
||||
key: 'workspacesState',
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
SignUp = 'sign-up',
|
||||
}
|
||||
Reference in New Issue
Block a user