Skip to content

Commit

Permalink
feat(settings): allow to choose auth methods
Browse files Browse the repository at this point in the history
# Conflicts:
#	packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx
  • Loading branch information
AMoreaux committed Nov 7, 2024
1 parent eee2edf commit c68721c
Show file tree
Hide file tree
Showing 15 changed files with 241 additions and 49 deletions.
14 changes: 10 additions & 4 deletions packages/twenty-front/src/generated/graphql.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -373,14 +373,12 @@ export const SettingsRoutes = ({
element={<SettingsObjectFieldEdit />}
/>
<Route path={SettingsPath.Releases} element={<Releases />} />
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
{isSSOEnabled && (
<>
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
</>
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
)}
</Routes>
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export type CurrentWorkspace = Pick<
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'isGoogleAuthEnabled'
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled'
| 'hasValidEntrepriseKey'
| 'subdomain'
| 'metadataVersion'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
hasValidEntrepriseKey: false,
metadataVersion: 1,
isPublicInviteLinkEnabled: false,
isGoogleAuthEnabled: true,
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
});
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export const SettingsNavigationDrawerItems = () => {
);
const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED');
const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED');
const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED');
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;

Expand Down Expand Up @@ -188,13 +187,11 @@ export const SettingsNavigationDrawerItems = () => {
Icon={IconCode}
/>
)}
{isSSOEnabled && (
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
)}
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
</NavigationDrawerSection>
<AnimatePresence>
{isAdvancedModeEnabled && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@ const StyledCardContent = styled(CardContent)`
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
cursor: pointer;
position: relative;
&:hover {
background: ${({ theme }) => theme.background.transparent.lighter};
}
&:not(:last-child)::before {
content: '';
position: absolute;
bottom: 0;
left: ${({ theme }) => theme.spacing(4)};
right: ${({ theme }) => theme.spacing(4)};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
}
`;

const StyledTitle = styled.div`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,102 @@ import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionC
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { Card, IconLink, Toggle } from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
IconLink,
Toggle,
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 { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';

const StyledToggle = styled(Toggle)`
margin-left: auto;
`;

const StyledSettingsSecurityOptionsList = styled.div`
display: flex;
flex-direction: column; /* Arrange items in a column */
gap: ${({ theme }) => theme.spacing(4)};
`;

export const SettingsSecurityOptionsList = () => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();

const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const toggleColor = isAdvancedModeEnabled ? theme.color.yellow : undefined;
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);

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

if (
SSOIdentitiesProviders.length === 0 &&
currentWorkspace[key] &&
Object.entries(currentWorkspace).filter(
([key, value]) =>
key.startsWith('is') && key.endsWith('AuthEnabled') && value,
).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(() => {
// rollback optimistic update if err
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
});
};

const handleChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
Expand All @@ -44,17 +123,57 @@ export const SettingsSecurityOptionsList = () => {
};

return (
<Card>
<SettingsOptionCardContent
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
onClick={() =>
handleChange(!currentWorkspace?.isPublicInviteLinkEnabled)
}
>
<StyledToggle value={currentWorkspace?.isPublicInviteLinkEnabled} />
</SettingsOptionCardContent>
</Card>
<StyledSettingsSecurityOptionsList>
<Card>
<SettingsOptionCardContent
Icon={IconGoogle}
title="Google"
description="Allow logins through Google's single sign-on functionality."
onClick={() => toggleAuthMethod('google')}
>
<StyledToggle
value={currentWorkspace?.isGoogleAuthEnabled}
color={toggleColor}
/>
</SettingsOptionCardContent>
<SettingsOptionCardContent
Icon={IconMicrosoft}
title="Microsoft"
description="Allow logins through Microsoft's single sign-on functionality."
onClick={() => toggleAuthMethod('microsoft')}
>
<StyledToggle
value={currentWorkspace?.isMicrosoftAuthEnabled}
color={toggleColor}
/>
</SettingsOptionCardContent>
<SettingsOptionCardContent
Icon={IconPassword}
title="Password"
description="Allow users to sign in with an email and password."
onClick={() => toggleAuthMethod('password')}
>
<StyledToggle
value={currentWorkspace?.isPasswordAuthEnabled}
color={toggleColor}
/>
</SettingsOptionCardContent>
</Card>
<Card>
<SettingsOptionCardContent
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
onClick={() =>
handleChange(!currentWorkspace?.isPublicInviteLinkEnabled)
}
>
<StyledToggle
value={currentWorkspace?.isPublicInviteLinkEnabled}
color={toggleColor}
/>
</SettingsOptionCardContent>
</Card>
</StyledSettingsSecurityOptionsList>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const USER_QUERY_FRAGMENT = gql`
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
subdomain
hasValidEntrepriseKey
featureFlags {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ 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';

export const SettingsSecurity = () => {
const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED');

return (
<SubMenuTopBarContainer
title="Security"
Expand All @@ -22,24 +25,26 @@ export const SettingsSecurity = () => {
]}
>
<SettingsPageContainer>
{isSSOEnabled && (
<Section>
<H2Title
title="SSO"
description="Configure an SSO connection"
addornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</Section>
)}
<Section>
<H2Title
title="SSO"
description="Configure an SSO connection"
addornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</Section>
<Section>
<H2Title
title="Other"
title="Authentication"
description="Customize your workspace security"
/>
<SettingsSecurityOptionsList />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';

@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
Expand Down Expand Up @@ -57,6 +61,13 @@ export class GoogleAuthController {
fromSSO: true,
});

if (!user.defaultWorkspace.isGoogleAuthEnabled) {
throw new AuthException(
'Google auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}

const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';

@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
Expand Down Expand Up @@ -56,6 +60,13 @@ export class MicrosoftAuthController {
fromSSO: true,
});

if (!user.defaultWorkspace.isMicrosoftAuthEnabled) {
throw new AuthException(
'Microsoft auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}

const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export class AuthService {
}

async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) {
if (!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: challengeInput.email,
Expand Down
Loading

0 comments on commit c68721c

Please sign in to comment.