Introduce SSO bypass permission. (#15417)
Closes [Core Issue #1772](https://github.com/twentyhq/core-team-issues/issues/1772). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces SSO bypass with a new permission flag and workspace-level provider toggles, enabling permitted users to log in via Google/Microsoft/Password when SSO-only, with backend enforcement and frontend UI/hooks/queries. > > - **Backend**: > - **Permission & Enforcement**: Add `PermissionFlagType.SSO_BYPASS`; update `AuthService` to allow login via non-SSO providers when workspace bypass is enabled and user has `SSO_BYPASS`. > - **Workspace Model**: Add `isGoogleAuthBypassEnabled`, `isMicrosoftAuthBypassEnabled`, `isPasswordAuthBypassEnabled` (migration, entity, update input, service validation). > - **Public API**: Extend `PublicWorkspaceDataOutput` with `authBypassProviders`; resolver computes it; permissions defaults include `SSO_BYPASS`. > - **Frontend**: > - **GraphQL/State**: Generate new types/fields; add `authBypassProviders` to `GetPublicWorkspaceDataByDomain`; new states `workspaceAuthBypassProvidersState`, `workspaceBypassModeState`. > - **Auth UI/Logic**: Add `useWorkspaceBypass`; update sign-in form and footer to offer "Bypass SSO" and use merged providers when enabled; remove auto-redirect when single SSO. > - **Settings**: Add Security section to toggle bypass methods per provider; conditionally show Change Password via `useCanChangePassword`. > - **Tests/Mocks**: Update mocks and tests to include bypass flags/providers. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8c393b2bad387fb6e8b8f40027f8637dd6e85723. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -240,6 +240,13 @@ export type ApprovedAccessDomain = {
|
||||
isValidated: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type AuthBypassProviders = {
|
||||
__typename?: 'AuthBypassProviders';
|
||||
google: Scalars['Boolean'];
|
||||
microsoft: Scalars['Boolean'];
|
||||
password: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type AuthProviders = {
|
||||
__typename?: 'AuthProviders';
|
||||
google: Scalars['Boolean'];
|
||||
@@ -2944,6 +2951,7 @@ export enum PermissionFlagType {
|
||||
ROLES = 'ROLES',
|
||||
SECURITY = 'SECURITY',
|
||||
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
|
||||
SSO_BYPASS = 'SSO_BYPASS',
|
||||
WORKFLOWS = 'WORKFLOWS',
|
||||
WORKSPACE = 'WORKSPACE',
|
||||
WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS'
|
||||
@@ -3003,6 +3011,7 @@ export type PublicFeatureFlagMetadata = {
|
||||
|
||||
export type PublicWorkspaceDataOutput = {
|
||||
__typename?: 'PublicWorkspaceDataOutput';
|
||||
authBypassProviders?: Maybe<AuthBypassProviders>;
|
||||
authProviders: AuthProviders;
|
||||
displayName?: Maybe<Scalars['String']>;
|
||||
id: Scalars['UUID'];
|
||||
@@ -4211,8 +4220,11 @@ export type UpdateWorkspaceInput = {
|
||||
defaultRoleId?: InputMaybe<Scalars['UUID']>;
|
||||
displayName?: InputMaybe<Scalars['String']>;
|
||||
inviteHash?: InputMaybe<Scalars['String']>;
|
||||
isGoogleAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isMicrosoftAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPasswordAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
|
||||
@@ -4535,8 +4547,11 @@ export type Workspace = {
|
||||
id: Scalars['UUID'];
|
||||
inviteHash?: Maybe<Scalars['String']>;
|
||||
isCustomDomainEnabled: Scalars['Boolean'];
|
||||
isGoogleAuthBypassEnabled: Scalars['Boolean'];
|
||||
isGoogleAuthEnabled: Scalars['Boolean'];
|
||||
isMicrosoftAuthBypassEnabled: Scalars['Boolean'];
|
||||
isMicrosoftAuthEnabled: Scalars['Boolean'];
|
||||
isPasswordAuthBypassEnabled: Scalars['Boolean'];
|
||||
isPasswordAuthEnabled: Scalars['Boolean'];
|
||||
isPublicInviteLinkEnabled: Scalars['Boolean'];
|
||||
isTwoFactorAuthenticationEnforced: Scalars['Boolean'];
|
||||
|
||||
@@ -49,6 +49,9 @@ const mockWorkspace = {
|
||||
isMicrosoftAuthEnabled: false,
|
||||
isPasswordAuthEnabled: false,
|
||||
isCustomDomainEnabled: false,
|
||||
isGoogleAuthBypassEnabled: false,
|
||||
isPasswordAuthBypassEnabled: false,
|
||||
isMicrosoftAuthBypassEnabled: false,
|
||||
hasValidEnterpriseKey: false,
|
||||
subdomain: 'test',
|
||||
customDomain: 'test.com',
|
||||
|
||||
+5
@@ -23,6 +23,11 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
authBypassProviders {
|
||||
google
|
||||
password
|
||||
microsoft
|
||||
}
|
||||
}
|
||||
}
|
||||
${WORKSPACE_URLS_FRAGMENT}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
|
||||
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
||||
|
||||
const StyledCopyContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
@@ -18,24 +21,93 @@ const StyledContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const FooterNote = () => (
|
||||
<StyledContainer>
|
||||
<Trans>By using Twenty, you agree to the</Trans>{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</a>{' '}
|
||||
<Trans>and</Trans>{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</a>
|
||||
.
|
||||
</StyledContainer>
|
||||
);
|
||||
const StyledLinksContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
& > a,
|
||||
& > button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSeparator = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
`;
|
||||
|
||||
export const FooterNote = () => {
|
||||
const isOnAWorkspace = useIsCurrentLocationOnAWorkspace();
|
||||
|
||||
const { shouldOfferBypass, shouldUseBypass, enableBypass } =
|
||||
useWorkspaceBypass();
|
||||
|
||||
if (!isOnAWorkspace) {
|
||||
return (
|
||||
<StyledCopyContainer>
|
||||
<Trans>By using Twenty, you agree to the</Trans>{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</a>{' '}
|
||||
<Trans>and</Trans>{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</a>
|
||||
.
|
||||
</StyledCopyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledLinksContainer>
|
||||
{shouldOfferBypass && !shouldUseBypass && (
|
||||
<>
|
||||
<button type="button" onClick={enableBypass}>
|
||||
<Trans>Bypass SSO</Trans>
|
||||
</button>
|
||||
<StyledSeparator>•</StyledSeparator>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://twenty.com/legal/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</a>
|
||||
<StyledSeparator>•</StyledSeparator>
|
||||
<a
|
||||
href="https://twenty.com/legal/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</a>
|
||||
</StyledLinksContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-10
@@ -5,7 +5,9 @@ import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpW
|
||||
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 { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
import styled from '@emotion/styled';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -22,6 +24,10 @@ const StyledContentContainer = styled.div`
|
||||
|
||||
export const SignInUpWorkspaceScopeForm = () => {
|
||||
const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState);
|
||||
const workspaceAuthBypassProviders = useRecoilValue(
|
||||
workspaceAuthBypassProvidersState,
|
||||
);
|
||||
const { shouldOfferBypass, shouldUseBypass } = useWorkspaceBypass();
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
|
||||
@@ -33,26 +39,32 @@ export const SignInUpWorkspaceScopeForm = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providers =
|
||||
shouldOfferBypass && shouldUseBypass
|
||||
? {
|
||||
...workspaceAuthBypassProviders,
|
||||
sso: [],
|
||||
}
|
||||
: workspaceAuthProviders;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContentContainer>
|
||||
{workspaceAuthProviders.google && (
|
||||
<SignInUpWithGoogle action="join-workspace" />
|
||||
)}
|
||||
{providers.google && <SignInUpWithGoogle action="join-workspace" />}
|
||||
|
||||
{workspaceAuthProviders.microsoft && (
|
||||
{providers.microsoft && (
|
||||
<SignInUpWithMicrosoft action="join-workspace" />
|
||||
)}
|
||||
|
||||
{workspaceAuthProviders.sso.length > 0 && <SignInUpWithSSO />}
|
||||
{providers.sso.length > 0 && <SignInUpWithSSO />}
|
||||
|
||||
{(workspaceAuthProviders.google ||
|
||||
workspaceAuthProviders.microsoft ||
|
||||
workspaceAuthProviders.sso.length > 0) &&
|
||||
workspaceAuthProviders.password ? (
|
||||
{(providers.google ||
|
||||
providers.microsoft ||
|
||||
providers.sso.length > 0) &&
|
||||
providers.password ? (
|
||||
<HorizontalSeparator />
|
||||
) : null}
|
||||
{workspaceAuthProviders.password && (
|
||||
{providers.password && (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<FormProvider {...form}>
|
||||
<SignInUpWithCredentials />
|
||||
|
||||
+1
-7
@@ -1,4 +1,3 @@
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import {
|
||||
@@ -35,7 +34,6 @@ export const SignInUpWorkspaceScopeFormEffect = () => {
|
||||
);
|
||||
|
||||
const { form } = useSignInUpForm();
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
|
||||
const { signInUpStep, continueWithEmail, continueWithCredentials } =
|
||||
useSignInUp(form);
|
||||
@@ -55,11 +53,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => {
|
||||
if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length > 1) {
|
||||
return setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection);
|
||||
}
|
||||
|
||||
if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length === 1) {
|
||||
redirectToSSOLoginPage(workspaceAuthProviders.sso[0].id);
|
||||
}
|
||||
}, [redirectToSSOLoginPage, setSignInUpStep, workspaceAuthProviders]);
|
||||
}, [setSignInUpStep, workspaceAuthProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingStatus === LoadingStatus.Done) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
||||
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
import { workspaceBypassModeState } from '@/workspace/states/workspaceBypassModeState';
|
||||
|
||||
export const useWorkspaceBypass = () => {
|
||||
const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState);
|
||||
const workspaceAuthBypassProviders = useRecoilValue(
|
||||
workspaceAuthBypassProvidersState,
|
||||
);
|
||||
const [workspaceBypassMode, setWorkspaceBypassMode] = useRecoilState(
|
||||
workspaceBypassModeState,
|
||||
);
|
||||
|
||||
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
|
||||
|
||||
const hasOnlySSOProvidersEnabled = (() => {
|
||||
if (!workspaceAuthProviders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { sso, google, microsoft, password } = workspaceAuthProviders;
|
||||
|
||||
return sso.length > 0 && !google && !microsoft && !password;
|
||||
})();
|
||||
|
||||
const hasBypassProvidersAvailable = (() => {
|
||||
if (!workspaceAuthBypassProviders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { google, microsoft, password } = workspaceAuthBypassProviders;
|
||||
|
||||
return google || microsoft || password;
|
||||
})();
|
||||
|
||||
const shouldOfferBypass =
|
||||
isOnAWorkspace && hasOnlySSOProvidersEnabled && hasBypassProvidersAvailable;
|
||||
|
||||
const shouldUseBypass = shouldOfferBypass ? workspaceBypassMode : false;
|
||||
|
||||
const enableBypass = () => {
|
||||
if (shouldOfferBypass) {
|
||||
setWorkspaceBypassMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
shouldOfferBypass,
|
||||
shouldUseBypass,
|
||||
enableBypass,
|
||||
};
|
||||
};
|
||||
@@ -15,8 +15,11 @@ export type CurrentWorkspace = Pick<
|
||||
| 'workspaceMembersCount'
|
||||
| 'isPublicInviteLinkEnabled'
|
||||
| 'isGoogleAuthEnabled'
|
||||
| 'isGoogleAuthBypassEnabled'
|
||||
| 'isMicrosoftAuthEnabled'
|
||||
| 'isMicrosoftAuthBypassEnabled'
|
||||
| 'isPasswordAuthEnabled'
|
||||
| 'isPasswordAuthBypassEnabled'
|
||||
| 'isCustomDomainEnabled'
|
||||
| 'hasValidEnterpriseKey'
|
||||
| 'subdomain'
|
||||
|
||||
+7
@@ -5,6 +5,7 @@ import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useI
|
||||
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
|
||||
import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain';
|
||||
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
|
||||
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated-metadata/graphql';
|
||||
@@ -16,6 +17,9 @@ export const useGetPublicWorkspaceDataByDomain = () => {
|
||||
const setWorkspaceAuthProviders = useSetRecoilState(
|
||||
workspaceAuthProvidersState,
|
||||
);
|
||||
const setWorkspaceAuthBypassProviders = useSetRecoilState(
|
||||
workspaceAuthBypassProvidersState,
|
||||
);
|
||||
const workspacePublicData = useRecoilValue(workspacePublicDataState);
|
||||
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
|
||||
const setWorkspacePublicDataState = useSetRecoilState(
|
||||
@@ -35,6 +39,9 @@ export const useGetPublicWorkspaceDataByDomain = () => {
|
||||
setWorkspaceAuthProviders(
|
||||
data.getPublicWorkspaceDataByDomain.authProviders,
|
||||
);
|
||||
setWorkspaceAuthBypassProviders(
|
||||
data.getPublicWorkspaceDataByDomain.authBypassProviders ?? null,
|
||||
);
|
||||
setWorkspacePublicDataState(data.getPublicWorkspaceDataByDomain);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
isMicrosoftAuthEnabled: false,
|
||||
isPasswordAuthEnabled: true,
|
||||
isCustomDomainEnabled: false,
|
||||
isGoogleAuthBypassEnabled: false,
|
||||
isMicrosoftAuthBypassEnabled: false,
|
||||
isPasswordAuthBypassEnabled: false,
|
||||
customDomain: 'my-custom-domain.com',
|
||||
workspaceUrls: {
|
||||
subdomainUrl: 'https://twenty.twenty.com',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCanChangePassword = () => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
|
||||
|
||||
const isPasswordAuthEnabled =
|
||||
currentWorkspace?.isPasswordAuthEnabled === true;
|
||||
|
||||
if (isPasswordAuthEnabled) {
|
||||
return { canChangePassword: true };
|
||||
}
|
||||
|
||||
const hasBypassPermission = currentUserWorkspace?.permissionFlags?.includes(
|
||||
PermissionFlagType.SSO_BYPASS,
|
||||
);
|
||||
|
||||
if (!hasBypassPermission) {
|
||||
return { canChangePassword: false };
|
||||
}
|
||||
|
||||
const canChangePassword =
|
||||
currentWorkspace?.isPasswordAuthBypassEnabled === true;
|
||||
|
||||
return { canChangePassword };
|
||||
};
|
||||
+7
@@ -14,6 +14,7 @@ import {
|
||||
IconLockOpen,
|
||||
IconSettings,
|
||||
IconSettingsAutomation,
|
||||
IconShield,
|
||||
IconSpy,
|
||||
IconUsers,
|
||||
} from 'twenty-ui/display';
|
||||
@@ -90,6 +91,12 @@ export const SettingsRolePermissionsSettingsSection = ({
|
||||
description: t`Manage workflows`,
|
||||
Icon: IconSettingsAutomation,
|
||||
},
|
||||
{
|
||||
key: PermissionFlagType.SSO_BYPASS,
|
||||
name: t`SSO Bypass`,
|
||||
description: t`Enable bypass options`,
|
||||
Icon: IconShield,
|
||||
},
|
||||
{
|
||||
key: PermissionFlagType.IMPERSONATE,
|
||||
name: t`Impersonate`,
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { IconGoogle, IconMicrosoft, IconPassword } from 'twenty-ui/display';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import {
|
||||
type AuthProviders,
|
||||
useUpdateWorkspaceMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledSettingsSecurityOptionsList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SettingsSecurityAuthBypassOptionsList = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
|
||||
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
||||
|
||||
const isValidAuthProvider = (
|
||||
key: string,
|
||||
): key is Exclude<keyof typeof currentWorkspace, '__typename'> => {
|
||||
if (!currentWorkspace) return false;
|
||||
return Reflect.has(currentWorkspace, key);
|
||||
};
|
||||
|
||||
const toggleAuthBypassMethod = async (
|
||||
authProvider: keyof Omit<AuthProviders, '__typename' | 'magicLink' | 'sso'>,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) {
|
||||
throw new Error(t`User is not logged in`);
|
||||
}
|
||||
|
||||
const key = `is${capitalize(authProvider)}AuthBypassEnabled`;
|
||||
|
||||
if (!isValidAuthProvider(key)) {
|
||||
throw new Error(t`Invalid auth bypass provider`);
|
||||
}
|
||||
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
[key]: !currentWorkspace[key],
|
||||
});
|
||||
|
||||
updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
[key]: !currentWorkspace[key],
|
||||
},
|
||||
},
|
||||
}).catch((err) => {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
[key]: currentWorkspace[key],
|
||||
});
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: err instanceof ApolloError ? err : undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSettingsSecurityOptionsList>
|
||||
<Card rounded>
|
||||
{authProviders.google === true && (
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconGoogle}
|
||||
title={t`Google`}
|
||||
description={t`Allow Google-based login for users with SSO bypass permissions.`}
|
||||
checked={currentWorkspace.isGoogleAuthBypassEnabled}
|
||||
advancedMode
|
||||
divider
|
||||
onChange={() => toggleAuthBypassMethod('google')}
|
||||
/>
|
||||
)}
|
||||
{authProviders.microsoft === true && (
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconMicrosoft}
|
||||
title={t`Microsoft`}
|
||||
description={t`Allow Microsoft-based login for users with SSO bypass permissions.`}
|
||||
checked={currentWorkspace.isMicrosoftAuthBypassEnabled}
|
||||
advancedMode
|
||||
divider
|
||||
onChange={() => toggleAuthBypassMethod('microsoft')}
|
||||
/>
|
||||
)}
|
||||
{authProviders.password && (
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconPassword}
|
||||
title={t`Password`}
|
||||
description={t`Allow email & password login for SSO bypass users.`}
|
||||
checked={currentWorkspace.isPasswordAuthBypassEnabled}
|
||||
advancedMode
|
||||
onChange={() => toggleAuthBypassMethod('password')}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</StyledSettingsSecurityOptionsList>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,9 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
isGoogleAuthEnabled
|
||||
isMicrosoftAuthEnabled
|
||||
isPasswordAuthEnabled
|
||||
isGoogleAuthBypassEnabled
|
||||
isMicrosoftAuthBypassEnabled
|
||||
isPasswordAuthBypassEnabled
|
||||
subdomain
|
||||
hasValidEnterpriseKey
|
||||
customDomain
|
||||
|
||||
@@ -4,12 +4,14 @@ import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceSta
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
||||
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
|
||||
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
|
||||
import { coreViewsState } from '@/views/states/coreViewState';
|
||||
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
|
||||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -36,6 +38,10 @@ export const useLoadCurrentUser = () => {
|
||||
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||
const { initializeFormatPreferences } = useInitializeFormatPreferences();
|
||||
const setCoreViews = useSetRecoilState(coreViewsState);
|
||||
const setWorkspaceAuthBypassProviders = useSetRecoilState(
|
||||
workspaceAuthBypassProvidersState,
|
||||
);
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
|
||||
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
|
||||
|
||||
@@ -103,6 +109,16 @@ export const useLoadCurrentUser = () => {
|
||||
|
||||
setCurrentWorkspace(workspace);
|
||||
|
||||
if (isDefined(workspace)) {
|
||||
setWorkspaceAuthBypassProviders({
|
||||
google: authProviders.google && workspace.isGoogleAuthBypassEnabled,
|
||||
microsoft:
|
||||
authProviders.microsoft && workspace.isMicrosoftAuthBypassEnabled,
|
||||
password:
|
||||
authProviders.password && workspace.isPasswordAuthBypassEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(workspace) && isOnAWorkspace) {
|
||||
setLastAuthenticateWorkspaceDomain({
|
||||
workspaceId: workspace.id,
|
||||
@@ -132,6 +148,8 @@ export const useLoadCurrentUser = () => {
|
||||
initializeFormatPreferences,
|
||||
setLastAuthenticateWorkspaceDomain,
|
||||
setCoreViews,
|
||||
authProviders,
|
||||
setWorkspaceAuthBypassProviders,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type AuthBypassProviders } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const workspaceAuthBypassProvidersState =
|
||||
createState<AuthBypassProviders | null>({
|
||||
key: 'workspaceAuthBypassProvidersState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const workspaceBypassModeState = createState<boolean>({
|
||||
key: 'workspaceBypassModeState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
|
||||
@@ -7,8 +5,10 @@ import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
|
||||
import { EmailField } from '@/settings/profile/components/EmailField';
|
||||
import { NameFields } from '@/settings/profile/components/NameFields';
|
||||
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
|
||||
import { useCanChangePassword } from '@/settings/profile/hooks/useCanChangePassword';
|
||||
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { H2Title, IconShield, Status } from 'twenty-ui/display';
|
||||
@@ -25,6 +25,8 @@ export const SettingsProfile = () => {
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
|
||||
'VERIFIED';
|
||||
|
||||
const { canChangePassword } = useCanChangePassword();
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Profile`}
|
||||
@@ -79,9 +81,11 @@ export const SettingsProfile = () => {
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</Section>
|
||||
<Section>
|
||||
<ChangePassword />
|
||||
</Section>
|
||||
{canChangePassword && (
|
||||
<Section>
|
||||
<ChangePassword />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<DeleteAccount />
|
||||
</Section>
|
||||
|
||||
@@ -3,11 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
|
||||
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
|
||||
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
||||
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
|
||||
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
@@ -40,6 +43,8 @@ export const SettingsSecurity = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
||||
const authProviders = useRecoilValue(authProvidersState);
|
||||
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
@@ -82,6 +87,20 @@ export const SettingsSecurity = () => {
|
||||
saveWorkspace(value);
|
||||
};
|
||||
|
||||
const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0;
|
||||
const hasDirectAuthEnabled =
|
||||
currentWorkspace?.isGoogleAuthEnabled ||
|
||||
currentWorkspace?.isMicrosoftAuthEnabled ||
|
||||
currentWorkspace?.isPasswordAuthEnabled;
|
||||
const hasBypassProviderAvailable =
|
||||
authProviders?.google ||
|
||||
authProviders?.microsoft ||
|
||||
authProviders?.password;
|
||||
const shouldShowBypassSection =
|
||||
hasSsoIdentityProviders &&
|
||||
!hasDirectAuthEnabled &&
|
||||
hasBypassProviderAvailable;
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Security`}
|
||||
@@ -120,6 +139,17 @@ export const SettingsSecurity = () => {
|
||||
<SettingsSecurityAuthProvidersOptionsList />
|
||||
</StyledContainer>
|
||||
</Section>
|
||||
{shouldShowBypassSection && (
|
||||
<Section>
|
||||
<StyledContainer>
|
||||
<H2Title
|
||||
title={t`SSO Bypass`}
|
||||
description={t`Configure fallback login methods for users with SSO bypass permissions`}
|
||||
/>
|
||||
<SettingsSecurityAuthBypassOptionsList />
|
||||
</StyledContainer>
|
||||
</Section>
|
||||
)}
|
||||
{isMultiWorkspaceEnabled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
|
||||
@@ -19,4 +19,10 @@ export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomai
|
||||
password: true,
|
||||
microsoft: false,
|
||||
},
|
||||
authBypassProviders: {
|
||||
__typename: 'AuthBypassProviders',
|
||||
google: false,
|
||||
password: false,
|
||||
microsoft: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,12 +65,15 @@ export const mockCurrentWorkspace: Workspace = {
|
||||
hasValidEnterpriseKey: false,
|
||||
isGoogleAuthEnabled: true,
|
||||
isPasswordAuthEnabled: true,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
isCustomDomainEnabled: false,
|
||||
isPasswordAuthBypassEnabled: false,
|
||||
isGoogleAuthBypassEnabled: false,
|
||||
isMicrosoftAuthBypassEnabled: false,
|
||||
workspaceUrls: {
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'twenty.twenty.com',
|
||||
},
|
||||
isMicrosoftAuthEnabled: false,
|
||||
featureFlags: [
|
||||
{
|
||||
key: FeatureFlagKey.IS_AIRTABLE_INTEGRATION_ENABLED,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSsoBypassFlag1761651107128 implements MigrationInterface {
|
||||
name = 'AddSsoBypassFlag1761651107128';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" ADD "isGoogleAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" ADD "isPasswordAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthBypassEnabled" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthBypassEnabled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthBypassEnabled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthBypassEnabled"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@@ -44,6 +45,10 @@ describe('AuthService', () => {
|
||||
let authSsoService: AuthSsoService;
|
||||
let userWorkspaceService: UserWorkspaceService;
|
||||
let workspaceInvitationService: WorkspaceInvitationService;
|
||||
let permissionsService: PermissionsService;
|
||||
let signInUpServiceMock: jest.Mocked<
|
||||
Pick<SignInUpService, 'validatePassword'>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -94,7 +99,10 @@ describe('AuthService', () => {
|
||||
},
|
||||
{
|
||||
provide: SignInUpService,
|
||||
useValue: {},
|
||||
useValue: {
|
||||
validatePassword: jest.fn().mockResolvedValue(undefined),
|
||||
generateHash: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
@@ -153,6 +161,14 @@ describe('AuthService', () => {
|
||||
provide: AuditService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
userHasWorkspaceSettingPermission: jest
|
||||
.fn()
|
||||
.mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -170,10 +186,15 @@ describe('AuthService', () => {
|
||||
userRepository = module.get<Repository<UserEntity>>(
|
||||
getRepositoryToken(UserEntity),
|
||||
);
|
||||
permissionsService = module.get<PermissionsService>(PermissionsService);
|
||||
signInUpServiceMock = module.get(SignInUpService) as jest.Mocked<
|
||||
Pick<SignInUpService, 'validatePassword'>
|
||||
>;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
twentyConfigServiceGetMock.mockReturnValue(false);
|
||||
signInUpServiceMock.validatePassword.mockClear();
|
||||
});
|
||||
|
||||
it('should be defined', async () => {
|
||||
@@ -216,6 +237,92 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('allows password login through SSO bypass when user has permission', async () => {
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
isPasswordAuthEnabled: false,
|
||||
isPasswordAuthBypassEnabled: true,
|
||||
} as WorkspaceEntity;
|
||||
|
||||
const userEntity = {
|
||||
id: 'user-id',
|
||||
email: 'email',
|
||||
passwordHash: 'password-hash',
|
||||
userWorkspaces: [
|
||||
{
|
||||
id: 'user-workspace-id',
|
||||
workspaceId: workspace.id,
|
||||
} as any,
|
||||
],
|
||||
} as unknown as UserEntity;
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
|
||||
jest
|
||||
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
|
||||
.mockResolvedValueOnce({ id: 'user-workspace-id' } as any);
|
||||
jest
|
||||
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
const response = await service.validateLoginWithPassword(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captcha-token',
|
||||
},
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(response).toBe(userEntity);
|
||||
});
|
||||
|
||||
it('throws when bypass permission is missing for disabled password auth', async () => {
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
isPasswordAuthEnabled: false,
|
||||
isPasswordAuthBypassEnabled: true,
|
||||
} as WorkspaceEntity;
|
||||
|
||||
const userEntity = {
|
||||
id: 'user-id',
|
||||
email: 'email',
|
||||
passwordHash: 'password-hash',
|
||||
userWorkspaces: [
|
||||
{
|
||||
id: 'user-workspace-id',
|
||||
workspaceId: workspace.id,
|
||||
} as any,
|
||||
],
|
||||
} as unknown as UserEntity;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
|
||||
jest
|
||||
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
|
||||
.mockResolvedValueOnce(null);
|
||||
jest
|
||||
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.validateLoginWithPassword(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captcha-token',
|
||||
},
|
||||
workspace,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Email/Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
expect(signInUpServiceMock.validatePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('challenge - user who have an invitation', async () => {
|
||||
const user = {
|
||||
email: 'email',
|
||||
|
||||
@@ -64,6 +64,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@@ -81,6 +83,7 @@ export class AuthService {
|
||||
private readonly authSsoService: AuthSsoService,
|
||||
private readonly userService: UserService,
|
||||
private readonly signInUpService: SignInUpService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
@@ -135,13 +138,6 @@ export class AuthService {
|
||||
input: UserCredentialsInput,
|
||||
targetWorkspace?: WorkspaceEntity,
|
||||
) {
|
||||
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
|
||||
throw new AuthException(
|
||||
'Email/Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: input.email,
|
||||
@@ -156,6 +152,21 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
|
||||
const canBypass = await this.canUserBypassAuthProvider({
|
||||
user,
|
||||
workspace: targetWorkspace,
|
||||
provider: AuthProviderEnum.Password,
|
||||
});
|
||||
|
||||
if (!canBypass) {
|
||||
throw new AuthException(
|
||||
'Email/Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetWorkspace) {
|
||||
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
|
||||
}
|
||||
@@ -230,10 +241,74 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (isDefined(workspace)) {
|
||||
const isProviderEnabled = workspaceValidator.isAuthEnabled(
|
||||
authParams.provider,
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (isProviderEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser =
|
||||
userData.type === 'existingUser' ? userData.existingUser : undefined;
|
||||
|
||||
if (
|
||||
existingUser &&
|
||||
(await this.canUserBypassAuthProvider({
|
||||
user: existingUser,
|
||||
workspace,
|
||||
provider: authParams.provider,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
private async canUserBypassAuthProvider({
|
||||
user,
|
||||
workspace,
|
||||
provider,
|
||||
}: {
|
||||
user: UserEntity;
|
||||
workspace: WorkspaceEntity;
|
||||
provider: AuthProviderEnum;
|
||||
}): Promise<boolean> {
|
||||
const bypassEnabled = (() => {
|
||||
switch (provider) {
|
||||
case AuthProviderEnum.Password:
|
||||
return workspace.isPasswordAuthBypassEnabled;
|
||||
case AuthProviderEnum.Google:
|
||||
return workspace.isGoogleAuthBypassEnabled;
|
||||
case AuthProviderEnum.Microsoft:
|
||||
return workspace.isMicrosoftAuthBypassEnabled;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!bypassEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userWorkspace = user.userWorkspaces?.find(
|
||||
(userWorkspace) => userWorkspace.workspaceId === workspace.id,
|
||||
);
|
||||
|
||||
if (!userWorkspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.permissionsService.userHasWorkspaceSettingPermission({
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceId: workspace.id,
|
||||
setting: PermissionFlagType.SSO_BYPASS,
|
||||
});
|
||||
}
|
||||
|
||||
async signInUp(
|
||||
params: SignInUpBaseParams &
|
||||
ExistingUserOrNewUser &
|
||||
@@ -775,7 +850,8 @@ export class AuthService {
|
||||
? await this.countAvailableWorkspacesByEmail(email)
|
||||
: 0;
|
||||
|
||||
const existingUser = await this.userService.findUserByEmail(email);
|
||||
const existingUser =
|
||||
await this.userService.findUserByEmailWithWorkspaces(email);
|
||||
|
||||
if (
|
||||
!workspaceId &&
|
||||
|
||||
@@ -205,6 +205,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByEmailWithWorkspaces(email: string) {
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
relations: { userWorkspaces: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
|
||||
+15
@@ -44,6 +44,18 @@ export class AuthProvidersDTO {
|
||||
microsoft: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('AuthBypassProviders')
|
||||
export class AuthBypassProvidersDTO {
|
||||
@Field(() => Boolean)
|
||||
google: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
password: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
microsoft: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('PublicWorkspaceDataOutput')
|
||||
export class PublicWorkspaceDataOutput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@@ -52,6 +64,9 @@ export class PublicWorkspaceDataOutput {
|
||||
@Field(() => AuthProvidersDTO)
|
||||
authProviders: AuthProvidersDTO;
|
||||
|
||||
@Field(() => AuthBypassProvidersDTO, { nullable: true })
|
||||
authBypassProviders?: AuthBypassProvidersDTO;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo: WorkspaceEntity['logo'];
|
||||
|
||||
|
||||
+15
@@ -67,6 +67,21 @@ export class UpdateWorkspaceInput {
|
||||
@IsOptional()
|
||||
isPasswordAuthEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isGoogleAuthBypassEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isMicrosoftAuthBypassEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPasswordAuthBypassEnabled?: boolean;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
|
||||
+24
@@ -160,6 +160,30 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
|
||||
);
|
||||
}
|
||||
if (payload.isGoogleAuthBypassEnabled && !authProvidersBySystem.google) {
|
||||
throw new WorkspaceException(
|
||||
'Google auth is not enabled in the system.',
|
||||
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
|
||||
);
|
||||
}
|
||||
if (
|
||||
payload.isMicrosoftAuthBypassEnabled &&
|
||||
!authProvidersBySystem.microsoft
|
||||
) {
|
||||
throw new WorkspaceException(
|
||||
'Microsoft auth is not enabled in the system.',
|
||||
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
|
||||
);
|
||||
}
|
||||
if (
|
||||
payload.isPasswordAuthBypassEnabled &&
|
||||
!authProvidersBySystem.password
|
||||
) {
|
||||
throw new WorkspaceException(
|
||||
'Password auth is not enabled in the system.',
|
||||
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.workspaceRepository.save({
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type AuthBypassProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const getAuthBypassProvidersByWorkspace = ({
|
||||
workspace,
|
||||
systemEnabledProviders,
|
||||
}: {
|
||||
workspace: Pick<
|
||||
WorkspaceEntity,
|
||||
| 'isGoogleAuthBypassEnabled'
|
||||
| 'isPasswordAuthBypassEnabled'
|
||||
| 'isMicrosoftAuthBypassEnabled'
|
||||
>;
|
||||
systemEnabledProviders: AuthBypassProvidersDTO;
|
||||
}) => {
|
||||
return {
|
||||
google:
|
||||
workspace.isGoogleAuthBypassEnabled && systemEnabledProviders.google,
|
||||
password:
|
||||
workspace.isPasswordAuthBypassEnabled && systemEnabledProviders.password,
|
||||
microsoft:
|
||||
workspace.isMicrosoftAuthBypassEnabled &&
|
||||
systemEnabledProviders.microsoft,
|
||||
};
|
||||
};
|
||||
@@ -230,6 +230,10 @@ export class WorkspaceEntity {
|
||||
@Column({ default: true })
|
||||
isGoogleAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isGoogleAuthBypassEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isTwoFactorAuthenticationEnforced: boolean;
|
||||
@@ -238,10 +242,18 @@ export class WorkspaceEntity {
|
||||
@Column({ default: true })
|
||||
isPasswordAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isPasswordAuthBypassEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isMicrosoftAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isMicrosoftAuthBypassEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isCustomDomainEnabled: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
|
||||
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { getAuthBypassProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util';
|
||||
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
|
||||
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -367,6 +368,10 @@ export class WorkspaceResolver {
|
||||
workspace,
|
||||
systemEnabledProviders,
|
||||
}),
|
||||
authBypassProviders: getAuthBypassProvidersByWorkspace({
|
||||
workspace,
|
||||
systemEnabledProviders,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
workspaceGraphqlApiExceptionHandler(err);
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ export enum PermissionFlagType {
|
||||
SECURITY = 'SECURITY',
|
||||
WORKFLOWS = 'WORKFLOWS',
|
||||
IMPERSONATE = 'IMPERSONATE',
|
||||
SSO_BYPASS = 'SSO_BYPASS',
|
||||
|
||||
// Tool permissions
|
||||
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
|
||||
|
||||
@@ -105,6 +105,7 @@ export class PermissionsService {
|
||||
[PermissionFlagType.IMPORT_CSV]: false,
|
||||
[PermissionFlagType.EXPORT_CSV]: false,
|
||||
[PermissionFlagType.IMPERSONATE]: false,
|
||||
[PermissionFlagType.SSO_BYPASS]: false,
|
||||
},
|
||||
objectsPermissions: {},
|
||||
}) as const satisfies UserWorkspacePermissions;
|
||||
|
||||
Reference in New Issue
Block a user