feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)

## Summary
Add support for multi-workspace feature and adjust configurations and
states accordingly.
- Introduced new state isMultiWorkspaceEnabledState.
- Updated ClientConfigProviderEffect component to handle
multi-workspace.
- Modified GraphQL schema and queries to include multi-workspace related
configurations.
- Adjusted server environment variables and their respective
documentation to support multi-workspace toggle.
- Updated server-side logic to handle new multi-workspace configurations
and conditions.
This commit is contained in:
Antoine Moreaux
2024-12-03 19:06:28 +01:00
committed by GitHub
parent 9a65e80566
commit 7943141d03
167 changed files with 5180 additions and 1901 deletions

View File

@ -1,7 +1,7 @@
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -16,6 +16,7 @@ import {
useAddUserToWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { currentUserState } from '@/auth/states/currentUserState';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -28,6 +29,7 @@ export const Invite = () => {
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentUser = useRecoilValue(currentUserState);
const [addUserToWorkspace] = useAddUserToWorkspaceMutation();
const [addUserToWorkspaceByInviteToken] =
useAddUserToWorkspaceByInviteTokenMutation();
@ -77,7 +79,7 @@ export const Invite = () => {
<Logo secondaryLogo={workspaceFromInviteHash?.logo} />
</AnimatedEaseIn>
<Title animate>{title}</Title>
{isDefined(currentWorkspace) ? (
{isDefined(currentUser) ? (
<>
<StyledContentContainer>
<MainButton
@ -91,7 +93,7 @@ export const Invite = () => {
<FooterNote />
</>
) : (
<SignInUpForm />
<SignInUpWorkspaceScopeForm />
)}
</>
);

View File

@ -19,7 +19,7 @@ import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useNavigate, useParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { AnimatedEaseIn, MainButton } from 'twenty-ui';
import { z } from 'zod';
import {
@ -27,6 +27,7 @@ import {
useValidatePasswordResetTokenQuery,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
const validationSchema = z
.object({
@ -71,6 +72,8 @@ const StyledInputContainer = styled.div`
export const PasswordReset = () => {
const { enqueueSnackBar } = useSnackBar();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const navigate = useNavigate();
const [email, setEmail] = useState('');
@ -163,7 +166,7 @@ export const PasswordReset = () => {
isTokenValid && (
<StyledMainContainer>
<AnimatedEaseIn>
<Logo />
<Logo secondaryLogo={workspacePublicData?.logo} />
</AnimatedEaseIn>
<Title animate>Reset Password</Title>
<StyledContentContainer>

View File

@ -1,68 +0,0 @@
/* @license Enterprise */
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { HorizontalSeparator, MainButton } from 'twenty-ui';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
export const SSOWorkspaceSelection = () => {
const availableSSOIdentityProviders = useRecoilValue(
availableSSOIdentityProvidersState,
);
const { redirectToSSOLoginPage } = useSSO();
const availableWorkspacesForSSOGroupByWorkspace =
availableSSOIdentityProviders.reduce(
(acc, idp) => {
acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp];
return acc;
},
{} as Record<string, typeof availableSSOIdentityProviders>,
);
return (
<>
<StyledContentContainer>
{Object.values(availableWorkspacesForSSOGroupByWorkspace).map(
(idps) => (
<>
<StyledTitle>
{idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME}
</StyledTitle>
<HorizontalSeparator visible={false} />
{idps.map((idp) => (
<>
<MainButton
title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
))}
</>
),
)}
</StyledContentContainer>
<FooterNote />
</>
);
};

View File

@ -1,58 +1,70 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
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 { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { AnimatedEaseIn } from 'twenty-ui';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { IconLockCustom } from '@ui/display/icon/components/IconLock';
import { AnimatedEaseIn } from 'twenty-ui';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { useMemo } from 'react';
import { isDefined } from '~/utils/isDefined';
import { SSOWorkspaceSelection } from './SSOWorkspaceSelection';
export const SignInUp = () => {
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { signInUpStep } = useSignInUp(form);
const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager();
const { signInUpStep, signInUpMode } = useSignInUp(form);
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const signInUpForm = useMemo(() => {
if (isTwentyHomePage && isMultiWorkspaceEnabled) {
return <SignInUpGlobalScopeForm />;
}
const title = useMemo(() => {
if (
signInUpStep === SignInUpStep.Init ||
signInUpStep === SignInUpStep.Email
(!isMultiWorkspaceEnabled ||
(isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) &&
signInUpStep === SignInUpStep.SSOIdentityProviderSelection
) {
return 'Welcome to Twenty';
return <SignInUpSSOIdentityProviderSelection />;
}
if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) {
return 'Choose SSO connection';
}
return signInUpMode === SignInUpMode.SignIn
? 'Sign in to Twenty'
: 'Sign up to Twenty';
}, [signInUpMode, signInUpStep]);
if (isDefined(currentWorkspace)) {
return <></>;
}
if (
isDefined(workspacePublicData) &&
(!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain)
) {
return <SignInUpWorkspaceScopeForm />;
}
return <SignInUpGlobalScopeForm />;
}, [
isTwentyHomePage,
isMultiWorkspaceEnabled,
isTwentyWorkspaceSubdomain,
signInUpStep,
workspacePublicData,
]);
return (
<>
<AnimatedEaseIn>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<IconLockCustom size={40} />
) : (
<Logo />
)}
<Logo secondaryLogo={workspacePublicData?.logo} />
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<SSOWorkspaceSelection />
) : (
<SignInUpForm />
)}
<Title animate>
{`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`}
</Title>
{signInUpForm}
{signInUpStep !== SignInUpStep.Password && <FooterNote />}
</>
);
};

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { H2Title, Loader, MainButton } from 'twenty-ui';
import { z } from 'zod';
@ -22,6 +22,9 @@ import {
useActivateWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { AppPath } from '@/types/AppPath';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
const StyledContentContainer = styled.div`
width: 100%;
@ -47,6 +50,8 @@ type Form = z.infer<typeof validationSchema>;
export const CreateWorkspace = () => {
const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { redirectToWorkspace } = useUrlManager();
const [activateWorkspace] = useActivateWorkspaceMutation();
const apolloMetadataClient = useApolloMetadataClient();
@ -75,8 +80,19 @@ export const CreateWorkspace = () => {
},
},
});
setIsCurrentUserLoaded(false);
if (isDefined(result.data) && isMultiWorkspaceEnabled) {
return redirectToWorkspace(
result.data.activateWorkspace.workspace.subdomain,
AppPath.Verify,
{
loginToken: result.data.activateWorkspace.loginToken.token,
},
);
}
await apolloMetadataClient?.refetchQueries({
include: [FIND_MANY_OBJECT_METADATA_ITEMS],
});
@ -93,7 +109,9 @@ export const CreateWorkspace = () => {
[
activateWorkspace,
setIsCurrentUserLoaded,
isMultiWorkspaceEnabled,
apolloMetadataClient,
redirectToWorkspace,
enqueueSnackBar,
],
);

View File

@ -1,4 +1,8 @@
import { GithubVersionLink, H2Title, Section } from 'twenty-ui';
import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui';
import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
@ -9,39 +13,61 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import packageJson from '../../../package.json';
export const SettingsWorkspace = () => (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'General' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />
</Section>
<Section>
<H2Title title="Name" description="Name of your workspace" />
<NameField />
</Section>
<Section>
<H2Title
title="Support"
adornment={<ToggleImpersonate />}
description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time."
/>
</Section>
<Section>
<DeleteWorkspace />
</Section>
<Section>
<GithubVersionLink version={packageJson.version} />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
import { SettingsCard } from '@/settings/components/SettingsCard';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsWorkspace = () => {
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
return (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'General' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />
</Section>
<Section>
<H2Title title="Name" description="Name of your workspace" />
<NameField />
</Section>
{isMultiWorkspaceEnabled && (
<Section>
<H2Title
title="Domain"
description="Edit your subdomain name or set a custom domain."
/>
<StyledLink to={getSettingsPagePath(SettingsPath.Domain)}>
<SettingsCard title="Customize Domain" Icon={<IconWorld />} />
</StyledLink>
</Section>
)}
<Section>
<H2Title
title="Support"
adornment={<ToggleImpersonate />}
description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time."
/>
</Section>
<Section>
<DeleteWorkspace />
</Section>
<Section>
<GithubVersionLink version={packageJson.version} />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -9,6 +9,9 @@ import { SettingsSecurityOptionsList } from '@/settings/security/components/Sett
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState';
import { useRecoilValue } from 'recoil';
const StyledContainer = styled.div`
width: 100%;
@ -26,6 +29,10 @@ const StyledSSOSection = styled(Section)`
`;
export const SettingsSecurity = () => {
const isSSOEnabled = useRecoilValue(isSSOEnabledState);
const isSSOSectionDisplay =
useIsFeatureEnabled('IS_SSO_ENABLED') && isSSOEnabled;
return (
<SubMenuTopBarContainer
title="Security"
@ -40,26 +47,28 @@ export const SettingsSecurity = () => {
>
<SettingsPageContainer>
<StyledMainContent>
<StyledSSOSection>
<H2Title
title="SSO"
description="Configure an SSO connection"
adornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</StyledSSOSection>
{isSSOSectionDisplay && (
<StyledSSOSection>
<H2Title
title="SSO"
description="Configure an SSO connection"
adornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</StyledSSOSection>
)}
<Section>
<AdvancedSettingsWrapper>
<StyledContainer>
<H2Title
title="Other"
title="Authentication"
description="Customize your workspace security"
/>
<SettingsSecurityOptionsList />

View File

@ -0,0 +1,154 @@
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { zodResolver } from '@hookform/resolvers/zod';
import { H2Title, Section } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilState, useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useNavigate } from 'react-router-dom';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { isDefined } from '~/utils/isDefined';
const validationSchema = z
.object({
subdomain: z
.string()
.min(1, { message: 'Subdomain can not be empty' })
.max(63, { message: 'Subdomain can not be longer than 63 characters' }),
})
.required();
type Form = z.infer<typeof validationSchema>;
const StyledDomainFromWrapper = styled.div`
align-items: center;
display: flex;
`;
const StyledDomain = styled.h2`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: 8px;
`;
export const SettingsDomain = () => {
const navigate = useNavigate();
const urlManager = useRecoilValue(urlManagerState);
const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { buildWorkspaceUrl } = useUrlManager();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const handleSave = async () => {
try {
const values = getValues();
if (!values || !isValid || !currentWorkspace) {
throw new Error('Invalid form values');
}
await updateWorkspace({
variables: {
input: {
subdomain: values.subdomain,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
subdomain: values.subdomain,
});
window.location.href = buildWorkspaceUrl(values.subdomain);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
const {
control,
getValues,
formState: { isValid },
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
},
resolver: zodResolver(validationSchema),
});
return (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{
children: 'General',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'Domain' },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isValid}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Workspace))}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Domain"
description="Set the name of your subdomain"
/>
{currentWorkspace?.subdomain && (
<StyledDomainFromWrapper>
<Controller
name="subdomain"
control={control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInputV2
value={value}
type="text"
onChange={onChange}
error={error?.message}
fullWidth
/>
)}
/>
{isDefined(urlManager) && isDefined(urlManager.frontDomain) && (
<StyledDomain>.{urlManager.frontDomain}</StyledDomain>
)}
</StyledDomainFromWrapper>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};