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

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

View File

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

View File

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

View File

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