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:
Antoine Moreaux
2024-12-03 19:06:28 +01:00
committed by GitHub
parent 9a65e80566
commit 7943141d03
167 changed files with 5180 additions and 1901 deletions

View File

@ -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
}
}
`;

View File

@ -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
}
}
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -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
}
}
}
`;

View File

@ -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);

View File

@ -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 {

View File

@ -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>
);
};

View File

@ -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 />}
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -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>
</>
)}
</>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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>
)}
</>
);
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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 };
};

View File

@ -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: [],
});

View File

@ -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: [],
});

View File

@ -14,7 +14,11 @@ export type CurrentWorkspace = Pick<
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'isGoogleAuthEnabled'
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled'
| 'hasValidEntrepriseKey'
| 'subdomain'
| 'metadataVersion'
>;

View File

@ -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
}),
],
});

View File

@ -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,
});

View File

@ -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>({

View File

@ -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']),
},
),
],
});

View File

@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
export const workspacePublicDataState =
createState<PublicWorkspaceDataOutput | null>({
key: 'workspacePublicDataState',
defaultValue: null,
});

View File

@ -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',

View File

@ -0,0 +1,4 @@
export enum SignInUpMode {
SignIn = 'sign-in',
SignUp = 'sign-up',
}