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

@ -18,6 +18,7 @@ import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { getPageTitleFromPath } from '~/utils/title-utils';
@ -34,6 +35,7 @@ export const AppRouterProviders = () => {
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<AuthProvider>
<ApolloMetadataClientProvider>

View File

@ -105,6 +105,12 @@ const SettingsWorkspace = lazy(() =>
})),
);
const SettingsDomain = lazy(() =>
import('~/pages/settings/workspace/SettingsDomain').then((module) => ({
default: module.SettingsDomain,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({
default: module.SettingsWorkspaceMembers,
@ -288,6 +294,8 @@ export const SettingsRoutes = ({
{isBillingEnabled && (
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
)}
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
@ -382,14 +390,12 @@ export const SettingsRoutes = ({
element={<SettingsObjectFieldEdit />}
/>
<Route path={SettingsPath.Releases} element={<Releases />} />
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
{isSSOEnabled && (
<>
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
</>
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
)}
{isAdminPageEnabled && (
<>

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

View File

@ -1,5 +1,4 @@
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
@ -7,23 +6,28 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetClientConfigQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState';
export const ClientConfigProviderEffect = () => {
const setAuthProviders = useSetRecoilState(authProvidersState);
const setIsDebugMode = useSetRecoilState(isDebugModeState);
const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState);
const setUrlManager = useSetRecoilState(urlManagerState);
const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState(
isDeveloperDefaultSignInPrefilledState,
);
const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState);
const setIsMultiWorkspaceEnabled = useSetRecoilState(
isMultiWorkspaceEnabledState,
);
const setIsSSOEnabledState = useSetRecoilState(isSSOEnabledState);
const setBilling = useSetRecoilState(billingState);
const setSupportChat = useSetRecoilState(supportChatState);
@ -69,17 +73,10 @@ export const ClientConfigProviderEffect = () => {
error: undefined,
}));
setAuthProviders({
google: data?.clientConfig.authProviders.google,
microsoft: data?.clientConfig.authProviders.microsoft,
password: data?.clientConfig.authProviders.password,
magicLink: false,
sso: data?.clientConfig.authProviders.sso,
});
setIsDebugMode(data?.clientConfig.debugMode);
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support);
@ -97,12 +94,16 @@ export const ClientConfigProviderEffect = () => {
setChromeExtensionId(data?.clientConfig?.chromeExtensionId);
setApiConfig(data?.clientConfig?.api);
setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled);
setUrlManager({
defaultSubdomain: data?.clientConfig?.defaultSubdomain,
frontDomain: data?.clientConfig?.frontDomain,
});
}, [
data,
setAuthProviders,
setIsDebugMode,
setIsDeveloperDefaultSignInPrefilled,
setIsSignUpDisabled,
setIsMultiWorkspaceEnabled,
setSupportChat,
setBilling,
setSentryConfig,
@ -113,6 +114,8 @@ export const ClientConfigProviderEffect = () => {
setApiConfig,
setIsAnalyticsEnabled,
error,
setUrlManager,
setIsSSOEnabledState,
]);
return <></>;

View File

@ -3,19 +3,16 @@ import { gql } from '@apollo/client';
export const GET_CLIENT_CONFIG = gql`
query GetClientConfig {
clientConfig {
authProviders {
google
password
microsoft
sso
}
billing {
isBillingEnabled
billingUrl
billingFreeTrialDurationInDays
}
signInPrefilled
signUpDisabled
isMultiWorkspaceEnabled
isSSOEnabled
defaultSubdomain
frontDomain
debugMode
analyticsEnabled
support {

View File

@ -5,10 +5,10 @@ import { AuthProviders } from '~/generated/graphql';
export const authProvidersState = createState<AuthProviders>({
key: 'authProvidersState',
defaultValue: {
google: false,
google: true,
magicLink: false,
password: false,
password: true,
microsoft: false,
sso: false,
sso: [],
},
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isMultiWorkspaceEnabledState = createState<boolean>({
key: 'isMultiWorkspaceEnabled',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isSSOEnabledState = createState<boolean>({
key: 'isSSOEnabledState',
defaultValue: false,
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const isSignUpDisabledState = createState<boolean>({
key: 'isSignUpDisabledState',
defaultValue: false,
});

View File

@ -15,10 +15,14 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
id: '1',
featureFlags: [],
allowImpersonation: false,
subdomain: 'test',
activationStatus: WorkspaceActivationStatus.Active,
hasValidEntrepriseKey: false,
metadataVersion: 1,
isPublicInviteLinkEnabled: false,
isGoogleAuthEnabled: true,
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
});
},
});

View File

@ -58,7 +58,7 @@ const StyledIconContainer = styled.div`
height: 75%;
`;
const StyledDeveloperSection = styled.div`
const StyledContainer = styled.div`
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(1)};
@ -82,7 +82,6 @@ export const SettingsNavigationDrawerItems = () => {
);
const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED');
const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED');
const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED');
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;
@ -192,14 +191,20 @@ export const SettingsNavigationDrawerItems = () => {
Icon={IconCode}
/>
)}
{isSSOEnabled && (
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
{isAdvancedModeEnabled && (
<StyledContainer>
<StyledIconContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
</StyledIconContainer>
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
</StyledContainer>
)}
</NavigationDrawerSection>
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
@ -209,7 +214,7 @@ export const SettingsNavigationDrawerItems = () => {
exit="exit"
variants={motionAnimationVariants}
>
<StyledDeveloperSection>
<StyledContainer>
<StyledIconContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
</StyledIconContainer>
@ -228,7 +233,7 @@ export const SettingsNavigationDrawerItems = () => {
/>
)}
</NavigationDrawerSection>
</StyledDeveloperSection>
</StyledContainer>
</motion.div>
)}
</AnimatePresence>

View File

@ -10,7 +10,7 @@ import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui';
import { IdpType } from '~/generated/graphql';
import { IdentityProviderType } from '~/generated/graphql';
const StyledInputsContainer = styled.div`
display: grid;
@ -30,8 +30,8 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
const { control, getValues } =
useFormContext<SettingSecurityNewSSOIdentityFormValues>();
const IdpMap: Record<
IdpType,
const IdentitiesProvidersMap: Record<
IdentityProviderType,
{
form: ReactElement;
option: {
@ -62,12 +62,12 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
},
};
const getFormByType = (type: Uppercase<IdpType> | undefined) => {
const getFormByType = (type: Uppercase<IdentityProviderType> | undefined) => {
switch (type) {
case IdpType.Oidc:
return IdpMap.OIDC.form;
case IdpType.Saml:
return IdpMap.SAML.form;
case IdentityProviderType.Oidc:
return IdentitiesProvidersMap.OIDC.form;
case IdentityProviderType.Saml:
return IdentitiesProvidersMap.SAML.form;
default:
return null;
}
@ -106,7 +106,7 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
render={({ field: { onChange, value } }) => (
<SettingsRadioCardContainer
value={value}
options={Object.values(IdpMap).map(
options={Object.values(IdentitiesProvidersMap).map(
(identityProviderType) => identityProviderType.option,
)}
onChange={onChange}

View File

@ -8,11 +8,14 @@ import { SettingsPath } from '@/types/SettingsPath';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useRecoilState } from 'recoil';
import { IconKey } from 'twenty-ui';
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
const StyledLink = styled(Link, {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'isDisabled',
@ -22,11 +25,29 @@ const StyledLink = styled(Link, {
`;
export const SettingsSSOIdentitiesProvidersListCard = () => {
const { enqueueSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
SSOIdentitiesProvidersState,
);
return !SSOIdentitiesProviders.length ? (
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
skip: currentWorkspace?.hasValidEntrepriseKey === false,
onCompleted: (data) => {
setSSOIdentitiesProviders(
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
return loading || !SSOIdentitiesProviders.length ? (
<StyledLink
to={getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)}
isDisabled={currentWorkspace?.hasValidEntrepriseKey !== true}

View File

@ -5,33 +5,14 @@ import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/securit
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useRecoilValue } from 'recoil';
export const SettingsSSOIdentitiesProvidersListCardWrapper = () => {
const { enqueueSnackBar } = useSnackBar();
const navigate = useNavigate();
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
SSOIdentitiesProvidersState,
);
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
onCompleted: (data) => {
setSSOIdentitiesProviders(
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
return (
<SettingsListCard
@ -39,7 +20,6 @@ export const SettingsSSOIdentitiesProvidersListCardWrapper = () => {
getItemLabel={(SSOIdentityProvider) =>
`${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}`
}
isLoading={loading}
RowIconFn={(SSOIdentityProvider) =>
guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer)
}

View File

@ -2,24 +2,98 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState } from 'recoil';
import { Card, IconLink, isDefined } from 'twenty-ui';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
IconLink,
Card,
IconGoogle,
IconMicrosoft,
IconPassword,
} from 'twenty-ui';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { AuthProviders } from '~/generated-metadata/graphql';
import { capitalize } from '~/utils/string/capitalize';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
const StyledSettingsSecurityOptionsList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsSecurityOptionsList = () => {
const { enqueueSnackBar } = useSnackBar();
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
if (!isDefined(currentWorkspace)) {
throw new Error(
'The current workspace must be defined to edit its security options.',
);
}
const [updateWorkspace] = useUpdateWorkspaceMutation();
const isValidAuthProvider = (
key: string,
): key is Exclude<keyof typeof currentWorkspace, '__typename'> => {
if (!currentWorkspace) return false;
return Reflect.has(currentWorkspace, key);
};
const toggleAuthMethod = async (
authProvider: keyof Omit<AuthProviders, '__typename' | 'magicLink' | 'sso'>,
) => {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
const key = `is${capitalize(authProvider)}AuthEnabled`;
if (!isValidAuthProvider(key)) {
throw new Error('Invalid auth provider');
}
const allAuthProvidersEnabled = [
currentWorkspace.isGoogleAuthEnabled,
currentWorkspace.isMicrosoftAuthEnabled,
currentWorkspace.isPasswordAuthEnabled,
(SSOIdentitiesProviders?.length ?? 0) > 0,
];
if (
currentWorkspace[key] === true &&
allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1
) {
return enqueueSnackBar(
'At least one authentication method must be enabled',
{
variant: SnackBarVariant.Error,
},
);
}
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
updateWorkspace({
variables: {
input: {
[key]: !currentWorkspace[key],
},
},
}).catch((err) => {
// rollback optimistic update if err
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
});
});
};
const handleChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
@ -44,17 +118,49 @@ export const SettingsSecurityOptionsList = () => {
};
return (
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
checked={currentWorkspace.isPublicInviteLinkEnabled}
advancedMode
onChange={() =>
handleChange(!currentWorkspace.isPublicInviteLinkEnabled)
}
/>
</Card>
<StyledSettingsSecurityOptionsList>
{currentWorkspace && (
<>
<Card>
<SettingsOptionCardContentToggle
Icon={IconGoogle}
title="Google"
description="Allow logins through Google's single sign-on functionality."
checked={currentWorkspace.isGoogleAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('google')}
/>
<SettingsOptionCardContentToggle
Icon={IconMicrosoft}
title="Microsoft"
description="Allow logins through Microsoft's single sign-on functionality."
checked={currentWorkspace.isMicrosoftAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('microsoft')}
/>
<SettingsOptionCardContentToggle
Icon={IconPassword}
title="Password"
description="Allow users to sign in with an email and password."
checked={currentWorkspace.isPasswordAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('password')}
/>
</Card>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
checked={currentWorkspace.isPublicInviteLinkEnabled}
advancedMode
onChange={() =>
handleChange(!currentWorkspace.isPublicInviteLinkEnabled)
}
/>
</Card>
</>
)}
</StyledSettingsSecurityOptionsList>
);
};

View File

@ -0,0 +1,4 @@
export type AuthProvidersKeys =
| 'isGoogleAuthEnabled'
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled';

View File

@ -2,12 +2,15 @@
import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema';
import { z } from 'zod';
import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql';
import {
IdentityProviderType,
SsoIdentityProviderStatus,
} from '~/generated/graphql';
export type SSOIdentityProvider = {
__typename: 'SSOIdentityProvider';
id: string;
type: IdpType;
type: IdentityProviderType;
issuer: string;
name?: string | null;
status: SsoIdentityProviderStatus;

View File

@ -16,7 +16,6 @@ export const parseSAMLMetadataFromXMLFile = (
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'application/xml');
if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
throw new Error('Error parsing XML');
}
@ -28,10 +27,10 @@ export const parseSAMLMetadataFromXMLFile = (
'md:IDPSSODescriptor',
)?.[0];
const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0];
const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0];
const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0];
const keyInfo = keyDescriptor?.getElementsByTagName('ds:KeyInfo')[0];
const x509Data = keyInfo?.getElementsByTagName('ds:X509Data')[0];
const x509Certificate = x509Data
.getElementsByTagName('ds:X509Certificate')?.[0]
?.getElementsByTagName('ds:X509Certificate')?.[0]
.textContent?.trim();
const singleSignOnServices = Array.from(

View File

@ -1,17 +1,25 @@
/* @license Enterprise */
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { IdpType } from '~/generated/graphql';
import { IdentityProviderType } from '~/generated/graphql';
export const sSOIdentityProviderDefaultValues: Record<
IdpType,
IdentityProviderType,
() => SettingSecurityNewSSOIdentityFormValues
> = {
SAML: () => ({
type: 'SAML',
ssoURL: '',
name: '',
id: crypto.randomUUID(),
id:
window.location.protocol === 'https:'
? crypto.randomUUID()
: '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
(
+c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
).toString(16),
),
certificate: '',
issuer: '',
}),

View File

@ -10,7 +10,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const WorkspaceLogoUploader = () => {
const [uploadLogo] = useUploadWorkspaceLogoMutation();
const [updateWorkspce] = useUpdateWorkspaceMutation();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
@ -39,7 +39,7 @@ export const WorkspaceLogoUploader = () => {
if (!currentWorkspace?.id) {
throw new Error('Workspace id not found');
}
await updateWorkspce({
await updateWorkspace({
variables: {
input: {
logo: null,

View File

@ -19,6 +19,7 @@ export enum SettingsPath {
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
Domain = 'domain',
CRMMigration = 'crm-migration',
Developers = 'developers',
ServerlessFunctions = 'functions',

View File

@ -15,6 +15,13 @@ import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui';
import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI';
import { Link } from 'react-router-dom';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
const StyledLink = styled(Link)`
text-decoration: none;
width: 100%;
`;
const StyledLogo = styled.div<{ logo: string }>`
background: url(${({ logo }) => logo});
@ -72,6 +79,7 @@ export const MultiWorkspaceDropdownButton = ({
useState(false);
const { switchWorkspace } = useWorkspaceSwitching();
const { buildWorkspaceUrl } = useUrlManager();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
@ -96,13 +104,9 @@ export const MultiWorkspaceDropdownButton = ({
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<StyledLogo
logo={
getImageAbsoluteURI(
currentWorkspace?.logo === null
? DEFAULT_WORKSPACE_LOGO
: currentWorkspace?.logo,
) ?? ''
}
logo={getImageAbsoluteURI(
currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
@ -118,23 +122,26 @@ export const MultiWorkspaceDropdownButton = ({
dropdownComponents={
<DropdownMenuItemsContainer>
{workspaces.map((workspace) => (
<MenuItemSelectAvatar
<StyledLink
key={workspace.id}
text={workspace.displayName ?? ''}
avatar={
<StyledLogo
logo={
getImageAbsoluteURI(
workspace.logo === null
? DEFAULT_WORKSPACE_LOGO
: workspace.logo,
) ?? ''
}
/>
}
selected={currentWorkspace?.id === workspace.id}
onClick={() => handleChange(workspace.id)}
/>
to={buildWorkspaceUrl(workspace.subdomain)}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? ''}
avatar={
<StyledLogo
logo={getImageAbsoluteURI(
workspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
}
selected={currentWorkspace?.id === workspace.id}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace.id);
}}
/>
</StyledLink>
))}
</DropdownMenuItemsContainer>
}

View File

@ -11,6 +11,7 @@ import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigat
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const StyledContainer = styled.div`
align-items: center;
@ -60,14 +61,17 @@ export const NavigationDrawerHeader = ({
}: NavigationDrawerHeaderProps) => {
const isMobile = useIsMobile();
const workspaces = useRecoilValue(workspacesState);
const isMultiWorkspace = workspaces !== null && workspaces.length > 1;
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState,
);
return (
<StyledContainer>
{isMultiWorkspace ? (
{isMultiWorkspaceEnabled &&
workspaces !== null &&
workspaces.length > 1 ? (
<MultiWorkspaceDropdownButton workspaces={workspaces} />
) : (
<StyledSingleWorkspaceContainer>

View File

@ -1,74 +1,44 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { AppPath } from '@/types/AppPath';
import { useGenerateJwtMutation } from '~/generated/graphql';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSwitchWorkspaceMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
export const useWorkspaceSwitching = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const [generateJWT] = useGenerateJwtMutation();
const { redirectToSSOLoginPage } = useSSO();
const [switchWorkspaceMutation] = useSwitchWorkspaceMutation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const setAvailableWorkspacesForSSOState = useSetRecoilState(
availableSSOIdentityProvidersState,
);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { clearSession } = useAuth();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { enqueueSnackBar } = useSnackBar();
const { redirectToHome, redirectToWorkspace } = useUrlManager();
const switchWorkspace = async (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return;
const jwt = await generateJWT({
if (!isMultiWorkspaceEnabled) {
return enqueueSnackBar(
'Switching workspace is not available in single workspace mode',
{
variant: SnackBarVariant.Error,
},
);
}
const { data, errors } = await switchWorkspaceMutation({
variables: {
workspaceId,
},
});
if (isDefined(jwt.errors)) {
throw jwt.errors;
if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) {
return redirectToHome();
}
if (!isDefined(jwt.data?.generateJWT)) {
throw new Error('could not create token');
}
if (
jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' &&
'availableSSOIDPs' in jwt.data.generateJWT
) {
if (jwt.data.generateJWT.availableSSOIDPs.length === 1) {
redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id);
}
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
await clearSession();
setAvailableWorkspacesForSSOState(
jwt.data.generateJWT.availableSSOIDPs,
);
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
}
return;
}
if (
jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' &&
'authTokens' in jwt.data.generateJWT
) {
const { tokens } = jwt.data.generateJWT.authTokens;
setTokenPair(tokens);
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly.
window.location.href = AppPath.Index;
}
redirectToWorkspace(data.switchWorkspace.subdomain);
};
return { switchWorkspace };

View File

@ -0,0 +1,110 @@
import { useMemo, useCallback } from 'react';
import { isDefined } from '~/utils/isDefined';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { useRecoilValue } from 'recoil';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
export const useUrlManager = () => {
const urlManager = useRecoilValue(urlManagerState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const homePageDomain = useMemo(() => {
return isMultiWorkspaceEnabled
? `${urlManager.defaultSubdomain}.${urlManager.frontDomain}`
: urlManager.frontDomain;
}, [
isMultiWorkspaceEnabled,
urlManager.defaultSubdomain,
urlManager.frontDomain,
]);
const isTwentyHomePage = useMemo(() => {
if (!isMultiWorkspaceEnabled) return true;
return window.location.hostname === homePageDomain;
}, [homePageDomain, isMultiWorkspaceEnabled]);
const isTwentyWorkspaceSubdomain = useMemo(() => {
if (!isMultiWorkspaceEnabled) return false;
if (
!isDefined(urlManager.frontDomain) ||
!isDefined(urlManager.defaultSubdomain)
) {
throw new Error('frontDomain and defaultSubdomain are required');
}
return window.location.hostname !== homePageDomain;
}, [
homePageDomain,
isMultiWorkspaceEnabled,
urlManager.defaultSubdomain,
urlManager.frontDomain,
]);
const getWorkspaceSubdomain = useMemo(() => {
if (!isDefined(urlManager.frontDomain)) {
throw new Error('frontDomain is not defined');
}
return isTwentyWorkspaceSubdomain
? window.location.hostname.replace(`.${urlManager.frontDomain}`, '')
: null;
}, [isTwentyWorkspaceSubdomain, urlManager.frontDomain]);
const buildWorkspaceUrl = useCallback(
(
subdomain?: string,
onPage?: string,
searchParams?: Record<string, string>,
) => {
const url = new URL(window.location.href);
if (isDefined(subdomain) && subdomain.length !== 0) {
url.hostname = `${subdomain}.${urlManager.frontDomain}`;
}
if (isDefined(onPage)) {
url.pathname = onPage;
}
if (isDefined(searchParams)) {
Object.entries(searchParams).forEach(([key, value]) =>
url.searchParams.set(key, value),
);
}
return url.toString();
},
[urlManager.frontDomain],
);
const redirectToWorkspace = useCallback(
(
subdomain: string,
onPage?: string,
searchParams?: Record<string, string>,
) => {
if (!isMultiWorkspaceEnabled) return;
window.location.href = buildWorkspaceUrl(subdomain, onPage, searchParams);
},
[buildWorkspaceUrl, isMultiWorkspaceEnabled],
);
const redirectToHome = useCallback(() => {
const url = new URL(window.location.href);
if (url.hostname !== homePageDomain) {
url.hostname = homePageDomain;
window.location.href = url.toString();
}
}, [homePageDomain]);
return {
redirectToHome,
redirectToWorkspace,
homePageDomain,
isTwentyHomePage,
buildWorkspaceUrl,
isTwentyWorkspaceSubdomain,
getWorkspaceSubdomain,
};
};

View File

@ -0,0 +1,12 @@
import { createState } from 'twenty-ui';
import { ClientConfig } from '~/generated/graphql';
export const urlManagerState = createState<
Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'>
>({
key: 'urlManager',
defaultValue: {
frontDomain: '',
defaultSubdomain: undefined,
},
});

View File

@ -32,6 +32,10 @@ export const USER_QUERY_FRAGMENT = gql`
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
subdomain
hasValidEntrepriseKey
featureFlags {
id
@ -53,6 +57,7 @@ export const USER_QUERY_FRAGMENT = gql`
logo
displayName
domainName
subdomain
}
}
userVars

View File

@ -0,0 +1,96 @@
import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil';
import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useEffect } from 'react';
import { isDefined } from '~/utils/isDefined';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
export const WorkspaceProviderEffect = () => {
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const setAuthProviders = useSetRecoilState(authProvidersState);
const setWorkspacePublicDataState = useSetRecoilState(
workspacePublicDataState,
);
const [lastAuthenticateWorkspace, setLastAuthenticateWorkspace] =
useRecoilState(lastAuthenticateWorkspaceState);
const {
redirectToHome,
getWorkspaceSubdomain,
redirectToWorkspace,
isTwentyHomePage,
} = useUrlManager();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
useGetPublicWorkspaceDataBySubdomainQuery({
skip:
(isMultiWorkspaceEnabled && isTwentyHomePage) ||
isDefined(workspacePublicData),
onCompleted: (data) => {
setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders);
setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error(error);
setLastAuthenticateWorkspace(null);
redirectToHome();
},
});
useEffect(() => {
if (
isMultiWorkspaceEnabled &&
isDefined(workspacePublicData?.subdomain) &&
workspacePublicData.subdomain !== getWorkspaceSubdomain
) {
redirectToWorkspace(workspacePublicData.subdomain);
}
}, [
getWorkspaceSubdomain,
isMultiWorkspaceEnabled,
redirectToWorkspace,
workspacePublicData,
]);
useEffect(() => {
if (
isMultiWorkspaceEnabled &&
isDefined(lastAuthenticateWorkspace?.subdomain) &&
isTwentyHomePage
) {
redirectToWorkspace(lastAuthenticateWorkspace.subdomain);
}
}, [
isMultiWorkspaceEnabled,
isTwentyHomePage,
lastAuthenticateWorkspace,
redirectToWorkspace,
]);
useEffect(() => {
try {
if (isDefined(workspacePublicData?.logo)) {
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ||
document.createElement('link');
link.rel = 'icon';
link.href = workspacePublicData.logo;
document.getElementsByTagName('head')[0].appendChild(link);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}, [workspacePublicData]);
return <></>;
};

View File

@ -3,7 +3,13 @@ import { gql } from '@apollo/client';
export const ACTIVATE_WORKSPACE = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
id
workspace {
id
subdomain
}
loginToken {
...AuthTokenFragment
}
}
}
`;

View File

@ -5,9 +5,14 @@ export const UPDATE_WORKSPACE = gql`
updateWorkspace(data: $input) {
id
domainName
subdomain
displayName
logo
allowImpersonation
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
}
}
`;