Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(*): add subdomain management for multiworkpsace #8680

Open
wants to merge 18 commits into
base: feat/allow-to-select-auth-methods-by-workspace
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5ba0266
refactor(auth): clean up and enhance sign-in-up logic
AMoreaux Nov 19, 2024
2598ab1
refactor: remove unused code and streamline checks
AMoreaux Nov 19, 2024
1433b24
refactor(auth, core): remove unused services and log debug info
AMoreaux Nov 19, 2024
8279057
refactor(auth): remove console.log statements
AMoreaux Nov 20, 2024
703a776
Merge remote-tracking branch 'upstream/main' into refacto/improve-sig…
AMoreaux Nov 21, 2024
595a713
Merge branch 'feat/allow-to-select-auth-methods-by-workspace' into re…
AMoreaux Nov 22, 2024
f6705aa
fix(workspace-invitation): remove redundant code property
AMoreaux Nov 22, 2024
dbcad4a
Merge branch 'feat/allow-to-select-auth-methods-by-workspace' into re…
AMoreaux Nov 22, 2024
2dfcc6f
fix(auth.service): correct parameter in saveDefaultWorkspace call
AMoreaux Nov 22, 2024
49fb416
chore: remove unused validation and legacy auth controllers
AMoreaux Nov 22, 2024
0b475ec
WIP
AMoreaux Nov 22, 2024
0d41daf
Merge branch 'refs/heads/feat/allow-to-select-auth-methods-by-workspa…
AMoreaux Nov 25, 2024
c782207
refactor(auth): integrate UrlManagerService for URL management
AMoreaux Nov 25, 2024
d3aef1d
Merge branch 'feat/allow-to-select-auth-methods-by-workspace' into fe…
AMoreaux Nov 25, 2024
dab3129
refactor(urls): remove workspace-url helper utility
AMoreaux Nov 26, 2024
57eac86
chore(auth): remove signup auth controller
AMoreaux Nov 26, 2024
c9716c5
[refactor]: Enhance form validation and URL management
AMoreaux Nov 26, 2024
ef08ed7
WIP
AMoreaux Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/twenty-e2e-testing/drivers/env_variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import path from 'path';
export const envVariables = (variables: string) => {
let payload = `
PG_DATABASE_URL=postgres://postgres:twenty@localhost:5432/default
FRONT_BASE_URL=http://localhost:3001
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
Expand Down
63 changes: 54 additions & 9 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 @@ -105,6 +105,12 @@ const SettingsWorkspace = lazy(() =>
})),
);

const SettingsDomain = lazy(() =>
import('~/pages/settings/workspace/SettingsDomain').then((module) => ({
default: module.SettingsDomain,
})),
);

const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({
default: module.SettingsWorkspaceMembers,
Expand Down Expand Up @@ -272,6 +278,8 @@ export const SettingsRoutes = ({
{isBillingEnabled && (
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
)}
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
Expand Down Expand Up @@ -366,14 +374,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 @@ -4,6 +4,7 @@ export const SWITCH_WORKSPACE = gql`
mutation SwitchWorkspace($workspaceId: String!) {
switchWorkspace(workspaceId: $workspaceId) {
id
subdomain
authProviders {
sso {
id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const CHECK_USER_EXISTS = gql`
availableWorkspaces {
id
displayName
subdomain
logo
sso {
type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
id
logo
displayName
subdomain
authProviders {
sso {
id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ describe('useAuth', () => {

expect(state.icons).toEqual({});
expect(state.authProviders).toEqual({
google: true,
google: false,
microsoft: false,
magicLink: false,
password: true,
sso: [],
password: false,
sso: false,
});
expect(state.billing).toBeNull();
expect(state.isSignInPrefilled).toBe(false);
Expand Down
67 changes: 49 additions & 18 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useGotoRecoilSnapshot,
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { iconsState } from 'twenty-ui';
Expand Down Expand Up @@ -42,10 +43,18 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';

import { urlManagerState } from '@/url-manager/state/url-manager.state';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';

export const useAuth = () => {
const [, setTokenPair] = useRecoilState(tokenPairState);
const setCurrentUser = useSetRecoilState(currentUserState);
const urlManager = useRecoilValue(urlManagerState);
const setLastAuthenticateWorkspaceState = useSetRecoilState(
lastAuthenticateWorkspaceState,
);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
Expand All @@ -60,6 +69,7 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager();
const [checkUserExistsQuery, { data: checkUserExistsData }] =
useCheckUserExistsLazyQuery();

Expand Down Expand Up @@ -157,6 +167,15 @@ export const useAuth = () => {
const workspace = user.defaultWorkspace ?? null;

setCurrentWorkspace(workspace);
if (isDefined(workspace) && isTwentyWorkspaceSubdomain) {
setLastAuthenticateWorkspaceState({
id: workspace.id,
subdomain: workspace.subdomain,
cookieAttributes: {
domain: `.${urlManager.frontDomain}`,
},
});
}

if (isDefined(verifyResult.data?.verify.user.workspaces)) {
const validWorkspaces = verifyResult.data?.verify.user.workspaces
Expand All @@ -183,6 +202,7 @@ export const useAuth = () => {
setCurrentWorkspace,
setCurrentWorkspaceMembers,
setCurrentWorkspaceMember,
setLastAuthenticateWorkspaceState,
setDateTimeFormat,
setWorkspaces,
],
Expand Down Expand Up @@ -297,23 +317,34 @@ export const useAuth = () => {
[setIsVerifyPendingState, signUp, handleVerify],
);

const buildRedirectUrl = (
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
const buildRedirectUrl = useCallback(
(
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
},
) => {
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set(
'inviteToken',
params.workspacePersonalInviteToken,
);
}
const subdomain = getWorkspaceSubdomain;

if (isDefined(subdomain)) {
url.searchParams.set('workspaceSubdomain', subdomain);
}

return url.toString();
},
) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
const url = new URL(`${authServerUrl}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
}
return url.toString();
};
[getWorkspaceSubdomain],
);

const handleGoogleLogin = useCallback(
(params: {
Expand All @@ -322,7 +353,7 @@ export const useAuth = () => {
}) => {
window.location.href = buildRedirectUrl('/auth/google', params);
},
[],
[buildRedirectUrl],
);

const handleMicrosoftLogin = useCallback(
Expand All @@ -332,7 +363,7 @@ export const useAuth = () => {
}) => {
window.location.href = buildRedirectUrl('/auth/microsoft', params);
},
[],
[buildRedirectUrl],
);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import styled from '@emotion/styled';
import {
HorizontalSeparator,
IconGoogle,
IconMicrosoft,
Loader,
MainButton,
HorizontalSeparator,
} from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
Expand Down Expand Up @@ -32,6 +32,7 @@ import {
signInUpModeState,
} from '@/auth/states/signInUpModeState';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';

const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
Expand All @@ -54,6 +55,7 @@ export const SignInUpGlobalScopeForm = () => {
const { signInWithMicrosoft } = useSignInWithMicrosoft();
const { checkUserExists } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const { redirectToWorkspace } = useUrlManager();

const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
Expand All @@ -64,7 +66,6 @@ export const SignInUpGlobalScopeForm = () => {
const [showErrors, setShowErrors] = useState(false);

const { form } = useSignInUpForm();

const { submitCredentials } = useSignInUp(form);

const handleSubmit = async () => {
Expand All @@ -91,26 +92,20 @@ export const SignInUpGlobalScopeForm = () => {
},
onCompleted: (data) => {
requestFreshCaptchaToken();
if (
data?.checkUserExists.exists &&
data.checkUserExists.__typename === 'UserExists'
) {
if (data.checkUserExists.__typename === 'UserExists') {
if (
isDefined(data?.checkUserExists.availableWorkspaces) &&
data.checkUserExists.availableWorkspaces.length >= 1
) {
// return redirectToWorkspace(
// data?.checkUserExists.availableWorkspaces[0].subdomain,
// {
// email: form.getValues('email'),
// },
// );
return redirectToWorkspace(
data?.checkUserExists.availableWorkspaces[0].subdomain,
{
email: form.getValues('email'),
},
);
AMoreaux marked this conversation as resolved.
Show resolved Hide resolved
}
}
AMoreaux marked this conversation as resolved.
Show resolved Hide resolved
if (
isDefined(data?.checkUserExists.exists) &&
data.checkUserExists.__typename === 'UserNotExists'
) {
if (data.checkUserExists.__typename === 'UserNotExists') {
if (!isMultiWorkspaceEnabled) {
return enqueueSnackBar('User not found', {
variant: SnackBarVariant.Error,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { Loader, MainButton } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import {
useSignInUpForm,
validationSchema,
} from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
Expand All @@ -27,7 +24,7 @@ const StyledForm = styled.form`
`;

export const SignInUpWithCredentials = () => {
const { form } = useSignInUpForm();
const { form, validationSchema } = useSignInUpForm();

const signInUpStep = useRecoilValue(signInUpStepState);
const [showErrors, setShowErrors] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,36 @@ import { useLocation } from 'react-router-dom';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { isDefined } from '~/utils/isDefined';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';

export const validationSchema = z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
captchaToken: z.string().default(''),
})
.required();
const makeValidationSchema = (signInUpStep: SignInUpStep) =>
z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password:
signInUpStep === SignInUpStep.Password
? z
.string()
.regex(
PASSWORD_REGEX,
'Password must contain at least 8 characters',
)
: z.string().optional(),
captchaToken: z.string().default(''),
})
.required();

export type Form = z.infer<typeof validationSchema>;
export type Form = z.infer<ReturnType<typeof makeValidationSchema>>;
export const useSignInUpForm = () => {
const location = useLocation();
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const signInUpStep = useRecoilValue(signInUpStepState);

const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step
const form = useForm<Form>({
mode: 'onSubmit',
defaultValues: {
Expand All @@ -45,5 +59,5 @@ export const useSignInUpForm = () => {
form.setValue('password', 'Applecar2025');
}
}, [form, isSignInPrefilled, location.search]);
return { form: form };
return { form: form, validationSchema };
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type CurrentWorkspace = Pick<
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled'
| 'hasValidEntrepriseKey'
| 'subdomain'
| 'metadataVersion'
>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cookieStorageEffect } from '~/utils/recoil-effects';
import { Workspace } from '~/generated/graphql';
import { createState } from 'twenty-ui';

export const lastAuthenticateWorkspaceState = createState<
| (Pick<Workspace, 'id' | 'subdomain'> & {
cookieAttributes?: Cookies.CookieAttributes;
})
| null
>({
key: 'lastAuthenticateWorkspaceState',
defaultValue: null,
effects: [
cookieStorageEffect('lastAuthenticateWorkspace', {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Cookie expiration calculated at state creation time rather than when cookie is set. Move calculation into effect.

}),
],
});
Loading