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

View File

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

View File

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

View File

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

View File

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