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:
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql`
|
||||
mutation FindAvailableSSOIdentityProviders(
|
||||
$input: FindAvailableSSOIDPInput!
|
||||
) {
|
||||
findAvailableSSOIdentityProviders(input: $input) {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -1,24 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_JWT = gql`
|
||||
mutation GenerateJWT($workspaceId: String!) {
|
||||
generateJWT(workspaceId: $workspaceId) {
|
||||
... on GenerateJWTOutputWithAuthTokens {
|
||||
success
|
||||
reason
|
||||
authTokens {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
... on GenerateJWTOutputWithSSOAUTH {
|
||||
success
|
||||
reason
|
||||
availableSSOIDPs {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,23 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SWITCH_WORKSPACE = gql`
|
||||
mutation SwitchWorkspace($workspaceId: String!) {
|
||||
switchWorkspace(workspaceId: $workspaceId) {
|
||||
id
|
||||
subdomain
|
||||
authProviders {
|
||||
sso {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
issuer
|
||||
}
|
||||
google
|
||||
magicLink
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -3,7 +3,26 @@ import { gql } from '@apollo/client';
|
||||
export const CHECK_USER_EXISTS = gql`
|
||||
query CheckUserExists($email: String!, $captchaToken: String) {
|
||||
checkUserExists(email: $email, captchaToken: $captchaToken) {
|
||||
exists
|
||||
__typename
|
||||
... on UserExists {
|
||||
exists
|
||||
availableWorkspaces {
|
||||
id
|
||||
displayName
|
||||
subdomain
|
||||
logo
|
||||
sso {
|
||||
type
|
||||
id
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
... on UserNotExists {
|
||||
exists
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
|
||||
query GetPublicWorkspaceDataBySubdomain {
|
||||
getPublicWorkspaceDataBySubdomain {
|
||||
id
|
||||
logo
|
||||
displayName
|
||||
subdomain
|
||||
authProviders {
|
||||
sso {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
issuer
|
||||
}
|
||||
google
|
||||
magicLink
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -114,11 +114,11 @@ describe('useAuth', () => {
|
||||
|
||||
expect(state.icons).toEqual({});
|
||||
expect(state.authProviders).toEqual({
|
||||
google: false,
|
||||
google: true,
|
||||
microsoft: false,
|
||||
magicLink: false,
|
||||
password: false,
|
||||
sso: false,
|
||||
password: true,
|
||||
sso: [],
|
||||
});
|
||||
expect(state.billing).toBeNull();
|
||||
expect(state.isDeveloperDefaultSignInPrefilled).toBe(false);
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
snapshot_UNSTABLE,
|
||||
useGotoRecoilSnapshot,
|
||||
useRecoilCallback,
|
||||
useRecoilState,
|
||||
useRecoilValue,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import { iconsState } from 'twenty-ui';
|
||||
@ -42,10 +42,18 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
|
||||
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
|
||||
import { currentUserState } from '../states/currentUserState';
|
||||
import { tokenPairState } from '../states/tokenPairState';
|
||||
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
|
||||
|
||||
import { urlManagerState } from '@/url-manager/states/url-manager.state';
|
||||
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [, setTokenPair] = useRecoilState(tokenPairState);
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const setCurrentUser = useSetRecoilState(currentUserState);
|
||||
const urlManager = useRecoilValue(urlManagerState);
|
||||
const setLastAuthenticateWorkspaceState = useSetRecoilState(
|
||||
lastAuthenticateWorkspaceState,
|
||||
);
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
@ -60,6 +68,7 @@ export const useAuth = () => {
|
||||
const [challenge] = useChallengeMutation();
|
||||
const [signUp] = useSignUpMutation();
|
||||
const [verify] = useVerifyMutation();
|
||||
const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager();
|
||||
const [checkUserExistsQuery, { data: checkUserExistsData }] =
|
||||
useCheckUserExistsLazyQuery();
|
||||
|
||||
@ -203,6 +212,15 @@ export const useAuth = () => {
|
||||
const workspace = user.defaultWorkspace ?? null;
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
if (isDefined(workspace) && isTwentyWorkspaceSubdomain) {
|
||||
setLastAuthenticateWorkspaceState({
|
||||
id: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
cookieAttributes: {
|
||||
domain: `.${urlManager.frontDomain}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(verifyResult.data?.verify.user.workspaces)) {
|
||||
const validWorkspaces = verifyResult.data?.verify.user.workspaces
|
||||
@ -227,9 +245,12 @@ export const useAuth = () => {
|
||||
setTokenPair,
|
||||
setCurrentUser,
|
||||
setCurrentWorkspace,
|
||||
isTwentyWorkspaceSubdomain,
|
||||
setCurrentWorkspaceMembers,
|
||||
setCurrentWorkspaceMember,
|
||||
setDateTimeFormat,
|
||||
setLastAuthenticateWorkspaceState,
|
||||
urlManager.frontDomain,
|
||||
setWorkspaces,
|
||||
],
|
||||
);
|
||||
@ -301,23 +322,34 @@ export const useAuth = () => {
|
||||
[setIsVerifyPendingState, signUp, handleVerify],
|
||||
);
|
||||
|
||||
const buildRedirectUrl = (
|
||||
path: string,
|
||||
params: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceInviteHash?: string;
|
||||
const buildRedirectUrl = useCallback(
|
||||
(
|
||||
path: string,
|
||||
params: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceInviteHash?: string;
|
||||
},
|
||||
) => {
|
||||
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
|
||||
if (isDefined(params.workspaceInviteHash)) {
|
||||
url.searchParams.set('inviteHash', params.workspaceInviteHash);
|
||||
}
|
||||
if (isDefined(params.workspacePersonalInviteToken)) {
|
||||
url.searchParams.set(
|
||||
'inviteToken',
|
||||
params.workspacePersonalInviteToken,
|
||||
);
|
||||
}
|
||||
const subdomain = getWorkspaceSubdomain;
|
||||
|
||||
if (isDefined(subdomain)) {
|
||||
url.searchParams.set('workspaceSubdomain', subdomain);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
) => {
|
||||
const authServerUrl = REACT_APP_SERVER_BASE_URL;
|
||||
const url = new URL(`${authServerUrl}${path}`);
|
||||
if (isDefined(params.workspaceInviteHash)) {
|
||||
url.searchParams.set('inviteHash', params.workspaceInviteHash);
|
||||
}
|
||||
if (isDefined(params.workspacePersonalInviteToken)) {
|
||||
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
[getWorkspaceSubdomain],
|
||||
);
|
||||
|
||||
const handleGoogleLogin = useCallback(
|
||||
(params: {
|
||||
@ -326,7 +358,7 @@ export const useAuth = () => {
|
||||
}) => {
|
||||
window.location.href = buildRedirectUrl('/auth/google', params);
|
||||
},
|
||||
[],
|
||||
[buildRedirectUrl],
|
||||
);
|
||||
|
||||
const handleMicrosoftLogin = useCallback(
|
||||
@ -336,7 +368,7 @@ export const useAuth = () => {
|
||||
}) => {
|
||||
window.location.href = buildRedirectUrl('/auth/microsoft', params);
|
||||
},
|
||||
[],
|
||||
[buildRedirectUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpEmailField = ({
|
||||
showErrors,
|
||||
onChange: onChangeFromProps,
|
||||
}: {
|
||||
showErrors: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
}) => {
|
||||
const form = useFormContext<Form>();
|
||||
|
||||
return (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={(value: string) => {
|
||||
onChange(value);
|
||||
if (isDefined(onChangeFromProps)) onChangeFromProps(value);
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
);
|
||||
};
|
||||
@ -1,393 +0,0 @@
|
||||
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import {
|
||||
useSignInUpForm,
|
||||
validationSchema,
|
||||
} from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import {
|
||||
ActionLink,
|
||||
HorizontalSeparator,
|
||||
IconGoogle,
|
||||
IconKey,
|
||||
IconMicrosoft,
|
||||
Loader,
|
||||
MainButton,
|
||||
StyledText,
|
||||
} from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpForm = () => {
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const isRequestingCaptchaToken = useRecoilValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
const { form } = useSignInUpForm();
|
||||
const { handleResetPassword } = useHandleResetPassword();
|
||||
|
||||
const {
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitCredentials,
|
||||
submitSSOEmail,
|
||||
} = useSignInUp(form);
|
||||
|
||||
if (
|
||||
signInUpStep === SignInUpStep.Init &&
|
||||
!authProviders.google &&
|
||||
!authProviders.microsoft &&
|
||||
!authProviders.sso
|
||||
) {
|
||||
continueWithEmail();
|
||||
}
|
||||
|
||||
const toggleSSOMode = () => {
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
continueWithEmail();
|
||||
} else {
|
||||
continueWithSSO();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === Key.Enter) {
|
||||
event.preventDefault();
|
||||
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
} else if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
if (!form.formState.isSubmitting) {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
} else if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = useMemo(() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
return 'Continue With Email';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
return 'Continue';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
return 'Continue with SSO';
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const shouldWaitForCaptchaToken =
|
||||
signInUpStep !== SignInUpStep.Init &&
|
||||
isDefined(captchaProvider?.provider) &&
|
||||
isRequestingCaptchaToken;
|
||||
|
||||
const isEmailStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Email &&
|
||||
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
|
||||
// We make the isValid check synchronous and update a reactState to make sure this does not happen
|
||||
const isPasswordStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Password &&
|
||||
(!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
const isSubmitButtonDisabled =
|
||||
isEmailStepSubmitButtonDisabledCondition ||
|
||||
isPasswordStepSubmitButtonDisabledCondition;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{authProviders.google && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{authProviders.microsoft && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
{authProviders.sso && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconKey size={theme.icon.size.lg} />}
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
|
||||
}
|
||||
title={
|
||||
signInUpStep === SignInUpStep.SSOEmail
|
||||
? 'Continue with email'
|
||||
: 'Single sign-on (SSO)'
|
||||
}
|
||||
onClick={toggleSSOMode}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(authProviders.google ||
|
||||
authProviders.microsoft ||
|
||||
authProviders.sso) && <HorizontalSeparator visible />}
|
||||
|
||||
{authProviders.password &&
|
||||
(signInUpStep === SignInUpStep.Password ||
|
||||
signInUpStep === SignInUpStep.Email ||
|
||||
signInUpStep === SignInUpStep.Init) && (
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={(value: string) => {
|
||||
onChange(value);
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
continueWithEmail();
|
||||
}
|
||||
}}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{signInUpMode === SignInUpMode.SignUp && (
|
||||
<StyledText
|
||||
text={'At least 8 characters long.'}
|
||||
color={theme.font.color.secondary}
|
||||
/>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
<MainButton
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
|
||||
}
|
||||
onClick={async () => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
return;
|
||||
}
|
||||
if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}}
|
||||
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep === SignInUpStep.SSOEmail && (
|
||||
<>
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
setShowErrors(true);
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledForm>
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Init && <FooterNote />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,164 @@
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
IconGoogle,
|
||||
IconMicrosoft,
|
||||
Loader,
|
||||
MainButton,
|
||||
HorizontalSeparator,
|
||||
} from 'twenty-ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
|
||||
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { signInUpModeState } from '@/auth/states/signInUpModeState';
|
||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledContentContainer = styled(motion.div)`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SignInUpGlobalScopeForm = () => {
|
||||
const theme = useTheme();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
const { checkUserExists } = useAuth();
|
||||
const { readCaptchaToken } = useReadCaptchaToken();
|
||||
const { redirectToWorkspace } = useUrlManager();
|
||||
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
||||
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { submitCredentials } = useSignInUp(form);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.Password) {
|
||||
await submitCredentials(form.getValues());
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await readCaptchaToken();
|
||||
await checkUserExists.checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email'),
|
||||
captchaToken: token,
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueSnackBar(`${error.message}`, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
requestFreshCaptchaToken();
|
||||
if (data.checkUserExists.__typename === 'UserExists') {
|
||||
if (
|
||||
isDefined(data?.checkUserExists.availableWorkspaces) &&
|
||||
data.checkUserExists.availableWorkspaces.length >= 1
|
||||
) {
|
||||
return redirectToWorkspace(
|
||||
data?.checkUserExists.availableWorkspaces[0].subdomain,
|
||||
pathname,
|
||||
{
|
||||
email: form.getValues('email'),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.checkUserExists.__typename === 'UserNotExists') {
|
||||
setSignInUpMode(SignInUpMode.SignUp);
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
<HorizontalSeparator visible />
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<FormProvider {...form}>
|
||||
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<SignInUpEmailField showErrors={showErrors} />
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<SignInUpPasswordField
|
||||
showErrors={showErrors}
|
||||
signInUpMode={signInUpMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainButton
|
||||
title={
|
||||
signInUpStep === SignInUpStep.Password ? 'Sign Up' : 'Continue'
|
||||
}
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</FormProvider>
|
||||
</StyledContentContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,67 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { StyledText } from 'twenty-ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const SignInUpPasswordField = ({
|
||||
showErrors,
|
||||
signInUpMode,
|
||||
}: {
|
||||
showErrors: boolean;
|
||||
signInUpMode: SignInUpMode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const form = useFormContext<Form>();
|
||||
|
||||
return (
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
{signInUpMode === SignInUpMode.SignUp && (
|
||||
<StyledText
|
||||
text={'At least 8 characters long.'}
|
||||
color={theme.font.color.secondary}
|
||||
/>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SignInUpSSOIdentityProviderSelection = () => {
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{isDefined(authProviders?.sso) &&
|
||||
authProviders?.sso.map((idp) => (
|
||||
<>
|
||||
<MainButton
|
||||
key={idp.id}
|
||||
title={idp.name}
|
||||
onClick={() => redirectToSSOLoginPage(idp.id)}
|
||||
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
))}
|
||||
</StyledContentContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,142 @@
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { Loader, MainButton } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import styled from '@emotion/styled';
|
||||
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SignInUpWithCredentials = () => {
|
||||
const { form, validationSchema } = useSignInUpForm();
|
||||
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const isRequestingCaptchaToken = useRecoilValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
|
||||
const {
|
||||
signInUpMode,
|
||||
continueWithEmail,
|
||||
continueWithCredentials,
|
||||
submitCredentials,
|
||||
} = useSignInUp(form);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitButtonDisabled) return;
|
||||
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
} else if (signInUpStep === SignInUpStep.Email) {
|
||||
if (isDefined(form?.formState?.errors?.email)) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
if (!form.formState.isSubmitting) {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = useMemo(() => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
return 'Continue With Email';
|
||||
}
|
||||
|
||||
if (
|
||||
signInUpMode === SignInUpMode.SignIn &&
|
||||
signInUpStep === SignInUpStep.Password
|
||||
) {
|
||||
return 'Sign in';
|
||||
}
|
||||
|
||||
if (
|
||||
signInUpMode === SignInUpMode.SignUp &&
|
||||
signInUpStep === SignInUpStep.Password
|
||||
) {
|
||||
return 'Sign up';
|
||||
}
|
||||
|
||||
return 'Continue';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
const shouldWaitForCaptchaToken =
|
||||
signInUpStep !== SignInUpStep.Init &&
|
||||
isDefined(captchaProvider?.provider) &&
|
||||
isRequestingCaptchaToken;
|
||||
|
||||
const isEmailStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Email &&
|
||||
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
|
||||
// We make the isValid check synchronous and update a reactState to make sure this does not happen
|
||||
const isPasswordStepSubmitButtonDisabledCondition =
|
||||
signInUpStep === SignInUpStep.Password &&
|
||||
(!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
const isSubmitButtonDisabled =
|
||||
isEmailStepSubmitButtonDisabledCondition ||
|
||||
isPasswordStepSubmitButtonDisabledCondition;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(signInUpStep === SignInUpStep.Password ||
|
||||
signInUpStep === SignInUpStep.Email ||
|
||||
signInUpStep === SignInUpStep.Init) && (
|
||||
<>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<FormProvider {...form}>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
{signInUpStep !== SignInUpStep.Init && (
|
||||
<SignInUpEmailField showErrors={showErrors} />
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<SignInUpPasswordField
|
||||
showErrors={showErrors}
|
||||
signInUpMode={signInUpMode}
|
||||
/>
|
||||
)}
|
||||
<MainButton
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
variant={
|
||||
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
|
||||
}
|
||||
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</FormProvider>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { IconGoogle, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { memo } from 'react';
|
||||
|
||||
const GoogleIcon = memo(() => {
|
||||
const theme = useTheme();
|
||||
return <IconGoogle size={theme.icon.size.md} />;
|
||||
});
|
||||
|
||||
export const SignInUpWithGoogle = () => {
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const { signInWithGoogle } = useSignInWithGoogle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={GoogleIcon}
|
||||
title="Continue with Google"
|
||||
onClick={signInWithGoogle}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import { IconMicrosoft, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const SignInUpWithMicrosoft = () => {
|
||||
const theme = useTheme();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
const { signInWithMicrosoft } = useSignInWithMicrosoft();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconMicrosoft size={theme.icon.size.md} />}
|
||||
title="Continue with Microsoft"
|
||||
onClick={signInWithMicrosoft}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import { IconLock, MainButton, HorizontalSeparator } from 'twenty-ui';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
|
||||
export const SignInUpWithSSO = () => {
|
||||
const theme = useTheme();
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
|
||||
const signInWithSSO = () => {
|
||||
if (authProviders.sso.length === 1) {
|
||||
return redirectToSSOLoginPage(authProviders.sso[0].id);
|
||||
}
|
||||
|
||||
setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconLock size={theme.icon.size.md} />}
|
||||
title="Single sign-on (SSO)"
|
||||
onClick={signInWithSSO}
|
||||
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ActionLink, HorizontalSeparator } from 'twenty-ui';
|
||||
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
|
||||
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
|
||||
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO';
|
||||
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SignInUpWorkspaceScopeForm = () => {
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
const { handleResetPassword } = useHandleResetPassword();
|
||||
|
||||
const { signInUpStep, continueWithEmail, continueWithCredentials } =
|
||||
useSignInUp(form);
|
||||
const location = useLocation();
|
||||
|
||||
const checkAuthProviders = useCallback(() => {
|
||||
if (
|
||||
signInUpStep === SignInUpStep.Init &&
|
||||
!authProviders.google &&
|
||||
!authProviders.microsoft &&
|
||||
!authProviders.sso
|
||||
) {
|
||||
return continueWithEmail();
|
||||
}
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const email = searchParams.get('email');
|
||||
if (isDefined(email) && authProviders.password) {
|
||||
return continueWithCredentials();
|
||||
}
|
||||
}, [
|
||||
continueWithCredentials,
|
||||
location.search,
|
||||
authProviders.google,
|
||||
authProviders.microsoft,
|
||||
authProviders.password,
|
||||
authProviders.sso,
|
||||
continueWithEmail,
|
||||
signInUpStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthProviders();
|
||||
}, [checkAuthProviders]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{authProviders.google && <SignInUpWithGoogle />}
|
||||
|
||||
{authProviders.microsoft && <SignInUpWithMicrosoft />}
|
||||
|
||||
{authProviders.sso.length > 0 && <SignInUpWithSSO />}
|
||||
|
||||
{(authProviders.google ||
|
||||
authProviders.microsoft ||
|
||||
authProviders.sso.length > 0) &&
|
||||
authProviders.password ? (
|
||||
<HorizontalSeparator visible />
|
||||
) : null}
|
||||
|
||||
{authProviders.password && <SignInUpWithCredentials />}
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -3,9 +3,7 @@
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
FindAvailableSsoIdentityProvidersMutationVariables,
|
||||
GetAuthorizationUrlMutationVariables,
|
||||
useFindAvailableSsoIdentityProvidersMutation,
|
||||
useGetAuthorizationUrlMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
@ -13,20 +11,8 @@ import { isDefined } from '~/utils/isDefined';
|
||||
export const useSSO = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [findAvailableSSOProviderByEmailMutation] =
|
||||
useFindAvailableSsoIdentityProvidersMutation();
|
||||
const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation();
|
||||
|
||||
const findAvailableSSOProviderByEmail = async ({
|
||||
email,
|
||||
}: FindAvailableSsoIdentityProvidersMutationVariables['input']) => {
|
||||
return await findAvailableSSOProviderByEmailMutation({
|
||||
variables: {
|
||||
input: { email },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getAuthorizationUrlForSSO = async ({
|
||||
identityProviderId,
|
||||
}: GetAuthorizationUrlMutationVariables['input']) => {
|
||||
@ -63,6 +49,5 @@ export const useSSO = () => {
|
||||
return {
|
||||
redirectToSSOLoginPage,
|
||||
getAuthorizationUrlForSSO,
|
||||
findAvailableSSOProviderByEmail,
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,36 +7,25 @@ import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
SignUp = 'sign-up',
|
||||
}
|
||||
import { signInUpModeState } from '@/auth/states/signInUpModeState';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
|
||||
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
|
||||
|
||||
const isMatchingLocation = useIsMatchingLocation();
|
||||
|
||||
const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO();
|
||||
const setAvailableWorkspacesForSSOState = useSetRecoilState(
|
||||
availableSSOIdentityProvidersState,
|
||||
);
|
||||
|
||||
const workspaceInviteHash = useParams().workspaceInviteHash;
|
||||
const [searchParams] = useSearchParams();
|
||||
const workspacePersonalInviteToken =
|
||||
@ -44,12 +33,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
|
||||
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
|
||||
|
||||
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
|
||||
return isMatchingLocation(AppPath.SignInUp)
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp;
|
||||
});
|
||||
|
||||
const {
|
||||
signInWithCredentials,
|
||||
signUpWithCredentials,
|
||||
@ -67,7 +50,12 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
}, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]);
|
||||
}, [
|
||||
isMatchingLocation,
|
||||
requestFreshCaptchaToken,
|
||||
setSignInUpMode,
|
||||
setSignInUpStep,
|
||||
]);
|
||||
|
||||
const continueWithCredentials = useCallback(async () => {
|
||||
const token = await readCaptchaToken();
|
||||
@ -101,47 +89,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
enqueueSnackBar,
|
||||
requestFreshCaptchaToken,
|
||||
setSignInUpStep,
|
||||
setSignInUpMode,
|
||||
]);
|
||||
|
||||
const continueWithSSO = () => {
|
||||
setSignInUpStep(SignInUpStep.SSOEmail);
|
||||
};
|
||||
|
||||
const submitSSOEmail = async (email: string) => {
|
||||
const result = await findAvailableSSOProviderByEmail({
|
||||
email,
|
||||
});
|
||||
|
||||
if (isDefined(result.errors)) {
|
||||
return enqueueSnackBar(result.errors[0].message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!result.data?.findAvailableSSOIdentityProviders ||
|
||||
result.data?.findAvailableSSOIdentityProviders.length === 0
|
||||
) {
|
||||
enqueueSnackBar('No workspaces with SSO found', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If only one workspace, redirect to SSO
|
||||
if (result.data?.findAvailableSSOIdentityProviders.length === 1) {
|
||||
return redirectToSSOLoginPage(
|
||||
result.data.findAvailableSSOIdentityProviders[0].id,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.data?.findAvailableSSOIdentityProviders.length > 1) {
|
||||
setAvailableWorkspacesForSSOState(
|
||||
result.data.findAvailableSSOIdentityProviders,
|
||||
);
|
||||
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
async (data) => {
|
||||
const token = await readCaptchaToken();
|
||||
@ -150,19 +100,21 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
|
||||
signInUpMode === SignInUpMode.SignIn && !isInviteMode
|
||||
? await signInWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
token,
|
||||
)
|
||||
: await signUpWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
token,
|
||||
);
|
||||
if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) {
|
||||
await signInWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await signUpWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
token,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
enqueueSnackBar(err?.message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
@ -189,8 +141,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitSSOEmail,
|
||||
submitCredentials,
|
||||
};
|
||||
};
|
||||
|
||||
@ -3,33 +3,50 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { z } from 'zod';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
|
||||
export const validationSchema = z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z.string().trim().email('Email must be a valid email'),
|
||||
password: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
captchaToken: z.string().default(''),
|
||||
})
|
||||
.required();
|
||||
const makeValidationSchema = (signInUpStep: SignInUpStep) =>
|
||||
z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z.string().trim().email('Email must be a valid email'),
|
||||
password:
|
||||
signInUpStep === SignInUpStep.Password
|
||||
? z
|
||||
.string()
|
||||
.regex(
|
||||
PASSWORD_REGEX,
|
||||
'Password must contain at least 8 characters',
|
||||
)
|
||||
: z.string().optional(),
|
||||
captchaToken: z.string().default(''),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type Form = z.infer<typeof validationSchema>;
|
||||
export type Form = z.infer<ReturnType<typeof makeValidationSchema>>;
|
||||
export const useSignInUpForm = () => {
|
||||
const location = useLocation();
|
||||
const signInUpStep = useRecoilValue(signInUpStepState);
|
||||
|
||||
const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step
|
||||
|
||||
const isDeveloperDefaultSignInPrefilled = useRecoilValue(
|
||||
isDeveloperDefaultSignInPrefilledState,
|
||||
);
|
||||
const [searchParams] = useSearchParams();
|
||||
const invitationPrefilledEmail = searchParams.get('email');
|
||||
const prefilledEmail = searchParams.get('email');
|
||||
|
||||
const form = useForm<Form>({
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
defaultValues: {
|
||||
exist: false,
|
||||
email: '',
|
||||
@ -40,12 +57,12 @@ export const useSignInUpForm = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(invitationPrefilledEmail)) {
|
||||
form.setValue('email', invitationPrefilledEmail);
|
||||
if (isDefined(prefilledEmail)) {
|
||||
form.setValue('email', prefilledEmail);
|
||||
} else if (isDeveloperDefaultSignInPrefilled === true) {
|
||||
form.setValue('email', 'tim@apple.dev');
|
||||
form.setValue('password', 'Applecar2025');
|
||||
}
|
||||
}, [form, isDeveloperDefaultSignInPrefilled, invitationPrefilledEmail]);
|
||||
}, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail, location.search]);
|
||||
return { form: form };
|
||||
};
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { UserExists } from '~/generated/graphql';
|
||||
|
||||
export const availableSSOIdentityProvidersForAuthState = createState<
|
||||
NonNullable<UserExists['availableWorkspaces']>[0]['sso']
|
||||
>({
|
||||
key: 'availableSSOIdentityProvidersForAuth',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql';
|
||||
|
||||
export const availableSSOIdentityProvidersState = createState<
|
||||
NonNullable<
|
||||
FindAvailableSsoIdentityProvidersMutationResult['data']
|
||||
>['findAvailableSSOIdentityProviders']
|
||||
>({
|
||||
key: 'availableSSOIdentityProviders',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -14,7 +14,11 @@ export type CurrentWorkspace = Pick<
|
||||
| 'currentBillingSubscription'
|
||||
| 'workspaceMembersCount'
|
||||
| 'isPublicInviteLinkEnabled'
|
||||
| 'isGoogleAuthEnabled'
|
||||
| 'isMicrosoftAuthEnabled'
|
||||
| 'isPasswordAuthEnabled'
|
||||
| 'hasValidEntrepriseKey'
|
||||
| 'subdomain'
|
||||
| 'metadataVersion'
|
||||
>;
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { cookieStorageEffect } from '~/utils/recoil-effects';
|
||||
import { Workspace } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const lastAuthenticateWorkspaceState = createState<
|
||||
| (Pick<Workspace, 'id' | 'subdomain'> & {
|
||||
cookieAttributes?: Cookies.CookieAttributes;
|
||||
})
|
||||
| null
|
||||
>({
|
||||
key: 'lastAuthenticateWorkspaceState',
|
||||
defaultValue: null,
|
||||
effects: [
|
||||
cookieStorageEffect('lastAuthenticateWorkspace', {
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
|
||||
|
||||
export const signInUpModeState = createState<SignInUpMode>({
|
||||
key: 'signInUpModeState',
|
||||
defaultValue: SignInUpMode.SignIn,
|
||||
});
|
||||
@ -4,8 +4,8 @@ export enum SignInUpStep {
|
||||
Init = 'init',
|
||||
Email = 'email',
|
||||
Password = 'password',
|
||||
SSOEmail = 'SSOEmail',
|
||||
SSOWorkspaceSelection = 'SSOWorkspaceSelection',
|
||||
WorkspaceSelection = 'workspaceSelection',
|
||||
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
|
||||
}
|
||||
|
||||
export const signInUpStepState = createState<SignInUpStep>({
|
||||
|
||||
@ -2,9 +2,17 @@ import { createState } from 'twenty-ui';
|
||||
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
import { cookieStorageEffect } from '~/utils/recoil-effects';
|
||||
|
||||
export const tokenPairState = createState<AuthTokenPair | null>({
|
||||
key: 'tokenPairState',
|
||||
defaultValue: null,
|
||||
effects: [cookieStorageEffect('tokenPair')],
|
||||
effects: [
|
||||
cookieStorageEffect(
|
||||
'tokenPair',
|
||||
{},
|
||||
{
|
||||
validateInitFn: (payload: AuthTokenPair) =>
|
||||
Boolean(payload['accessToken']),
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
|
||||
|
||||
export const workspacePublicDataState =
|
||||
createState<PublicWorkspaceDataOutput | null>({
|
||||
key: 'workspacePublicDataState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -2,7 +2,10 @@ import { createState } from 'twenty-ui';
|
||||
|
||||
import { Workspace } from '~/generated/graphql';
|
||||
|
||||
export type Workspaces = Pick<Workspace, 'id' | 'logo' | 'displayName'>;
|
||||
export type Workspaces = Pick<
|
||||
Workspace,
|
||||
'id' | 'logo' | 'displayName' | 'subdomain'
|
||||
>;
|
||||
|
||||
export const workspacesState = createState<Workspaces[] | null>({
|
||||
key: 'workspacesState',
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
SignUp = 'sign-up',
|
||||
}
|
||||
@ -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 <></>;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isMultiWorkspaceEnabledState = createState<boolean>({
|
||||
key: 'isMultiWorkspaceEnabled',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isSSOEnabledState = createState<boolean>({
|
||||
key: 'isSSOEnabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isSignUpDisabledState = createState<boolean>({
|
||||
key: 'isSignUpDisabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export type AuthProvidersKeys =
|
||||
| 'isGoogleAuthEnabled'
|
||||
| 'isMicrosoftAuthEnabled'
|
||||
| 'isPasswordAuthEnabled';
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: '',
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,6 +19,7 @@ export enum SettingsPath {
|
||||
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
|
||||
WorkspaceMembersPage = 'workspace-members',
|
||||
Workspace = 'workspace',
|
||||
Domain = 'domain',
|
||||
CRMMigration = 'crm-migration',
|
||||
Developers = 'developers',
|
||||
ServerlessFunctions = 'functions',
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -5,9 +5,14 @@ export const UPDATE_WORKSPACE = gql`
|
||||
updateWorkspace(data: $input) {
|
||||
id
|
||||
domainName
|
||||
subdomain
|
||||
displayName
|
||||
logo
|
||||
allowImpersonation
|
||||
isPublicInviteLinkEnabled
|
||||
isGoogleAuthEnabled
|
||||
isMicrosoftAuthEnabled
|
||||
isPasswordAuthEnabled
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user