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:
@ -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: '',
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user