refactor(auth): add workspaces selection (#12098)

This commit is contained in:
Antoine Moreaux
2025-06-13 16:17:35 +02:00
committed by GitHub
parent 836e2f792c
commit b1af98f93d
162 changed files with 3542 additions and 1340 deletions

View File

@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { previousUrlState } from '@/auth/states/previousUrlState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { workspacesState } from '@/auth/states/workspaces';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
@ -33,8 +32,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const [, setPreviousUrl] = useRecoilState(previousUrlState);
const setPreviousUrl = useSetRecoilState(previousUrlState);
const location = useLocation();
const apolloClient = useMemo(() => {
@ -65,7 +63,6 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentWorkspaceMember(null);
setCurrentWorkspace(null);
setCurrentUserWorkspace(null);
setWorkspaces([]);
if (
!isMatchingLocation(location, AppPath.Verify) &&
!isMatchingLocation(location, AppPath.SignInUp) &&
@ -89,7 +86,6 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentWorkspace,
setWorkspaces,
setPreviousUrl,
]);

View File

@ -11,6 +11,7 @@ type LogoProps = {
primaryLogo?: string | null;
secondaryLogo?: string | null;
placeholder?: string | null;
onClick?: () => void;
};
const StyledContainer = styled.div`
@ -53,6 +54,7 @@ export const Logo = ({
primaryLogo,
secondaryLogo,
placeholder,
onClick,
}: LogoProps) => {
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const defaultPrimaryLogoUrl = `${window.location.origin}/images/icons/android/android-launchericon-192-192.png`;
@ -72,7 +74,7 @@ export const Logo = ({
const isUsingDefaultLogo = !isDefined(primaryLogo);
return (
<StyledContainer>
<StyledContainer onClick={() => onClick?.()}>
{isUsingDefaultLogo ? (
<UndecoratedLink
to={AppPath.SignInUp}

View File

@ -17,3 +17,36 @@ export const AUTH_TOKENS = gql`
}
}
`;
export const AVAILABLE_WORKSPACE_FOR_AUTH_FRAGMENT = gql`
fragment AvailableWorkspaceFragment on AvailableWorkspace {
id
displayName
loginToken
inviteHash
personalInviteToken
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
id
issuer
name
status
}
}
`;
export const AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT = gql`
fragment AvailableWorkspacesFragment on AvailableWorkspaces {
availableWorkspacesForSignIn {
...AvailableWorkspaceFragment
}
availableWorkspacesForSignUp {
...AvailableWorkspaceFragment
}
}
`;

View File

@ -17,8 +17,7 @@ export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
...AuthTokenFragment
}
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -6,8 +6,7 @@ export const IMPERSONATE = gql`
impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace {
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
id
}

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const SIGN_IN = gql`
mutation SignIn($email: String!, $password: String!, $captchaToken: String) {
signIn(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -1,35 +1,13 @@
import { gql } from '@apollo/client';
export const SIGN_UP = gql`
mutation SignUp(
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
$workspaceId: String
$locale: String
$verifyEmailNextPath: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
mutation SignUp($email: String!, $password: String!, $captchaToken: String) {
signUp(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
tokens {
...AuthTokensFragment
}
}
}

View File

@ -9,8 +9,7 @@ export const SIGN_UP_IN_NEW_WORKSPACE = gql`
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -0,0 +1,36 @@
import { gql } from '@apollo/client';
export const SIGN_UP_IN_WORKSPACE = gql`
mutation SignUpInWorkspace(
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
$workspaceId: String
$locale: String
$verifyEmailNextPath: String
) {
signUpInWorkspace(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
`;

View File

@ -3,30 +3,9 @@ import { gql } from '@apollo/client';
export const CHECK_USER_EXISTS = gql`
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
__typename
... on UserExists {
exists
availableWorkspaces {
id
displayName
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
id
issuer
name
status
}
}
isEmailVerified
}
... on UserNotExists {
exists
}
exists
availableWorkspacesCount
isEmailVerified
}
}
`;

View File

@ -1,4 +1,5 @@
import { gql } from '@apollo/client';
import { WORKSPACE_URLS_FRAGMENT } from '@/users/graphql/fragments/workspaceUrlsFragment';
export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
query GetPublicWorkspaceDataByDomain($origin: String!) {
@ -7,8 +8,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
logo
displayName
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
authProviders {
sso {
@ -25,4 +25,5 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
}
}
}
${WORKSPACE_URLS_FRAGMENT}
`;

View File

@ -3,6 +3,7 @@ import {
GetCurrentUserDocument,
GetLoginTokenFromCredentialsDocument,
SignUpDocument,
SignUpInWorkspaceDocument,
} from '~/generated/graphql';
export const queries = {
@ -10,6 +11,7 @@ export const queries = {
getAuthTokensFromLoginToken: GetAuthTokensFromLoginTokenDocument,
signup: SignUpDocument,
getCurrentUser: GetCurrentUserDocument,
signUpInWorkspace: SignUpInWorkspaceDocument,
};
export const email = 'test@test.com';
@ -29,7 +31,13 @@ export const variables = {
email,
password,
workspacePersonalInviteToken: null,
locale: "",
locale: '',
},
signUpInWorkspace: {
email,
password,
workspacePersonalInviteToken: null,
locale: '',
},
getCurrentUser: {},
};
@ -48,6 +56,16 @@ export const results = {
},
},
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
signUpInWorkspace: {
loginToken: { token, expiresAt: 'expiresAt' },
workspace: {
id: 'workspace-id',
workspaceUrls: {
subdomainUrl: 'https://subdomain.twenty.com',
customUrl: 'https://custom.twenty.com',
},
},
},
getCurrentUser: {
currentUser: {
id: 'id',
@ -67,6 +85,7 @@ export const results = {
avatarUrl: 'avatarUrl',
locale: 'locale',
},
availableWorkspaces: [],
currentWorkspace: {
id: 'id',
displayName: 'displayName',
@ -74,6 +93,11 @@ export const results = {
inviteHash: 'inviteHash',
allowImpersonation: true,
subscriptionStatus: 'subscriptionStatus',
customDomain: null,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://twenty.com',
},
featureFlags: {
id: 'id',
key: 'key',
@ -85,8 +109,8 @@ export const results = {
},
};
export const mocks = [
{
export const mocks = {
getLoginTokenFromCredentials: {
request: {
query: queries.getLoginTokenFromCredentials,
variables: variables.getLoginTokenFromCredentials,
@ -97,7 +121,7 @@ export const mocks = [
},
})),
},
{
getAuthTokensFromLoginToken: {
request: {
query: queries.getAuthTokensFromLoginToken,
variables: variables.getAuthTokensFromLoginToken,
@ -108,7 +132,7 @@ export const mocks = [
},
})),
},
{
signup: {
request: {
query: queries.signup,
variables: variables.signup,
@ -119,7 +143,7 @@ export const mocks = [
},
})),
},
{
getCurrentUser: {
request: {
query: queries.getCurrentUser,
variables: variables.getCurrentUser,
@ -128,4 +152,15 @@ export const mocks = [
data: results.getCurrentUser,
})),
},
];
signUpInWorkspace: {
request: {
query: queries.signUpInWorkspace,
variables: variables.signUpInWorkspace,
},
result: jest.fn(() => ({
data: {
signUpInWorkspace: results.signUpInWorkspace,
},
})),
},
};

View File

@ -2,6 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
@ -30,9 +31,13 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}>
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
{children}
</SnackBarProviderScope>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
);
@ -63,7 +68,7 @@ describe('useAuth', () => {
).toStrictEqual(results.getLoginTokenFromCredentials);
});
expect(mocks[0].result).toHaveBeenCalled();
expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled();
});
it('should verify user', async () => {
@ -73,19 +78,19 @@ describe('useAuth', () => {
await result.current.getAuthTokensFromLoginToken(token);
});
expect(mocks[1].result).toHaveBeenCalled();
expect(mocks[3].result).toHaveBeenCalled();
expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled();
expect(mocks.getCurrentUser.result).toHaveBeenCalled();
});
it('should handle credential sign-in', async () => {
const { result } = renderHooks();
await act(async () => {
await result.current.signInWithCredentials(email, password);
await result.current.signInWithCredentialsInWorkspace(email, password);
});
expect(mocks[0].result).toHaveBeenCalled();
expect(mocks[1].result).toHaveBeenCalled();
expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled();
expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled();
});
it('should handle google sign-in', async () => {
@ -94,6 +99,7 @@ describe('useAuth', () => {
await act(async () => {
await result.current.signInWithGoogle({
workspaceInviteHash: 'workspaceInviteHash',
action: 'join-workspace',
});
});
@ -163,9 +169,12 @@ describe('useAuth', () => {
const { result } = renderHooks();
await act(async () => {
await result.current.signUpWithCredentials({ email, password });
await result.current.signUpWithCredentialsInWorkspace({
email,
password,
});
});
expect(mocks[2].result).toHaveBeenCalled();
expect(mocks.signUpInWorkspace.result).toHaveBeenCalled();
});
});

View File

@ -11,19 +11,21 @@ import {
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { workspacesState } from '@/auth/states/workspaces';
import { billingState } from '@/client-config/states/billingState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
AuthTokenPair,
useCheckUserExistsLazyQuery,
useGetAuthTokensFromLoginTokenMutation,
useGetCurrentUserLazyQuery,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
useSignUpMutation,
useSignInMutation,
useSignUpInWorkspaceMutation,
} from '~/generated/graphql';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
@ -68,10 +70,17 @@ import { iconsState } from 'twenty-ui/display';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import {
countAvailableWorkspaces,
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setCurrentUser = useSetRecoilState(currentUserState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
@ -87,16 +96,18 @@ export const useAuth = () => {
);
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [getLoginTokenFromCredentials] =
useGetLoginTokenFromCredentialsMutation();
const [signIn] = useSignInMutation();
const [signUp] = useSignUpMutation();
const [signUpInWorkspace] = useSignUpInWorkspaceMutation();
const [getAuthTokensFromLoginToken] =
useGetAuthTokensFromLoginTokenMutation();
const [getLoginTokenFromEmailVerificationToken] =
@ -280,6 +291,10 @@ export const useAuth = () => {
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.availableWorkspaces)) {
setAvailableWorkspaces(user.availableWorkspaces);
}
if (isDefined(user.currentUserWorkspace)) {
setCurrentUserWorkspace(user.currentUserWorkspace);
}
@ -326,17 +341,6 @@ export const useAuth = () => {
});
}
if (isDefined(user.workspaces)) {
const validWorkspaces = user.workspaces
.filter(
({ workspace }) => workspace !== null && workspace !== undefined,
)
.map((validWorkspace) => validWorkspace.workspace)
.filter(isDefined);
setWorkspaces(validWorkspaces);
}
return {
user,
workspaceMember,
@ -352,9 +356,17 @@ export const useAuth = () => {
setCurrentWorkspaceMembers,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setWorkspaces,
setAvailableWorkspaces,
]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
setTokenPair(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
},
[setTokenPair],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
@ -372,14 +384,8 @@ export const useAuth = () => {
throw new Error('No getAuthTokensFromLoginToken result');
}
setTokenPair(
getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens,
);
cookieStorage.setItem(
'tokenPair',
JSON.stringify(
getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens,
),
handleSetAuthTokens(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
@ -390,14 +396,97 @@ export const useAuth = () => {
},
[
getAuthTokensFromLoginToken,
setTokenPair,
loadCurrentUser,
origin,
handleSetAuthTokens,
refreshObjectMetadataItems,
],
);
const handleCredentialsSignIn = useCallback(
async (email: string, password: string, captchaToken?: string) => {
signIn({
variables: { email, password, captchaToken },
onCompleted: async (data) => {
handleSetAuthTokens(data.signIn.tokens);
const { user } = await loadCurrentUser();
const availableWorkspacesCount = countAvailableWorkspaces(
user.availableWorkspaces,
);
if (availableWorkspacesCount === 0) {
return createWorkspace();
}
if (availableWorkspacesCount === 1) {
const targetWorkspace = getFirstAvailableWorkspaces(
user.availableWorkspaces,
);
return await redirectToWorkspaceDomain(
getWorkspaceUrl(targetWorkspace.workspaceUrls),
targetWorkspace.loginToken ? AppPath.Verify : AppPath.SignInUp,
{
...(targetWorkspace.loginToken && {
loginToken: targetWorkspace.loginToken,
}),
email: user.email,
},
);
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
onError: (error) => {
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
}
throw error;
},
});
},
[
handleSetAuthTokens,
redirectToWorkspaceDomain,
signIn,
loadCurrentUser,
setSearchParams,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, captchaToken?: string) => {
signUp({
variables: { email, password, captchaToken },
onCompleted: async (data) => {
handleSetAuthTokens(data.signUp.tokens);
const { user } = await loadCurrentUser();
if (countAvailableWorkspaces(user.availableWorkspaces) === 0) {
return createWorkspace();
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
});
},
[
handleSetAuthTokens,
signUp,
loadCurrentUser,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignInInWorkspace = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const { loginToken } = await handleGetLoginTokenFromCredentials(
email,
@ -413,7 +502,7 @@ export const useAuth = () => {
await clearSession();
}, [clearSession]);
const handleCredentialsSignUp = useCallback(
const handleCredentialsSignUpInWorkspace = useCallback(
async ({
email,
password,
@ -429,7 +518,7 @@ export const useAuth = () => {
captchaToken?: string;
verifyEmailNextPath?: string;
}) => {
const signUpResult = await signUp({
const signUpInWorkspaceResult = await signUpInWorkspace({
variables: {
email,
password,
@ -444,11 +533,11 @@ export const useAuth = () => {
},
});
if (isDefined(signUpResult.errors)) {
throw signUpResult.errors;
if (isDefined(signUpInWorkspaceResult.errors)) {
throw signUpInWorkspaceResult.errors;
}
if (!signUpResult.data?.signUp) {
if (!signUpInWorkspaceResult.data?.signUpInWorkspace) {
throw new Error('No login token');
}
@ -460,11 +549,15 @@ export const useAuth = () => {
if (isMultiWorkspaceEnabled) {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(signUpResult.data.signUp.workspace.workspaceUrls),
getWorkspaceUrl(
signUpInWorkspaceResult.data.signUpInWorkspace.workspace
.workspaceUrls,
),
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{
...(!isEmailVerificationRequired && {
loginToken: signUpResult.data.signUp.loginToken.token,
loginToken:
signUpInWorkspaceResult.data.signUpInWorkspace.loginToken.token,
}),
email,
},
@ -472,11 +565,11 @@ export const useAuth = () => {
}
await handleGetAuthTokensFromLoginToken(
signUpResult.data?.signUp.loginToken.token,
signUpInWorkspaceResult.data?.signUpInWorkspace.loginToken.token,
);
},
[
signUp,
signUpInWorkspace,
workspacePublicData,
isMultiWorkspaceEnabled,
handleGetAuthTokensFromLoginToken,
@ -494,6 +587,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action?: string;
},
) => {
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
@ -513,6 +607,10 @@ export const useAuth = () => {
);
}
if (isDefined(params.action)) {
url.searchParams.set('action', params.action);
}
if (isDefined(workspacePublicData)) {
url.searchParams.set('workspaceId', workspacePublicData.id);
}
@ -527,6 +625,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/google', params));
},
@ -538,6 +637,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/microsoft', params));
},
@ -556,8 +656,11 @@ export const useAuth = () => {
clearSession,
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signUpWithCredentialsInWorkspace: handleCredentialsSignUpInWorkspace,
signInWithCredentialsInWorkspace: handleCredentialsSignInInWorkspace,
signInWithCredentials: handleCredentialsSignIn,
signInWithGoogle: handleGoogleLogin,
signInWithMicrosoft: handleMicrosoftLogin,
setAuthTokens: handleSetAuthTokens,
};
};

View File

@ -2,14 +2,17 @@ import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { FormProvider } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { useAuth } from '@/auth/hooks/useAuth';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { signInUpModeState } from '@/auth/states/signInUpModeState';
@ -17,19 +20,23 @@ import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator } from 'twenty-ui/display';
import {
HorizontalSeparator,
IconChevronRight,
IconPlus,
Avatar,
} from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { AvailableWorkspace } from '~/generated/graphql';
const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -44,29 +51,101 @@ const StyledForm = styled.form`
width: 100%;
`;
const StyledWorkspaceContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
width: 100%;
`;
const StyledWorkspaceItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: ${({ theme }) => theme.spacing(15)};
padding: 0;
overflow: hidden;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
cursor: pointer;
justify-content: space-between;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
&:last-child {
border-bottom: none;
}
`;
const StyledWorkspaceContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
padding: 0 ${({ theme }) => theme.spacing(4)};
`;
const StyledWorkspaceTextContainer = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
`;
const StyledWorkspaceLogo = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
height: ${({ theme }) => theme.spacing(6)};
width: ${({ theme }) => theme.spacing(6)};
background-color: ${({ theme }) => theme.background.transparent.light};
display: flex;
justify-content: center;
align-items: center;
`;
const StyledWorkspaceName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledWorkspaceUrl = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledChevronIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
`;
export const SignInUpGlobalScopeForm = () => {
const authProviders = useRecoilValue(authProvidersState);
const signInUpStep = useRecoilValue(signInUpStepState);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { checkUserExists } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const [signInUpMode] = useRecoilState(signInUpModeState);
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const theme = useTheme();
const { t } = useLingui();
const isRequestingCaptchaToken = useRecoilValue(
isRequestingCaptchaTokenState,
);
const { enqueueSnackBar } = useSnackBar();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const [showErrors, setShowErrors] = useState(false);
const { form } = useSignInUpForm();
const { pathname } = useLocation();
const { submitCredentials } = useSignInUp(form);
const { submitCredentials, continueWithCredentials } = useSignInUp(form);
const handleSubmit = async () => {
if (isDefined(form?.formState?.errors?.email)) {
@ -79,38 +158,7 @@ export const SignInUpGlobalScopeForm = () => {
return;
}
const token = await readCaptchaToken();
await checkUserExists.checkUserExistsQuery({
variables: {
email: form.getValues('email').toLowerCase().trim(),
captchaToken: token,
},
onError: (error) => {
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
},
onCompleted: async (data) => {
requestFreshCaptchaToken();
const response = data.checkUserExists;
if (response.__typename === 'UserExists') {
if (response.availableWorkspaces.length >= 1) {
const workspace = response.availableWorkspaces[0];
return await redirectToWorkspaceDomain(
getWorkspaceUrl(workspace.workspaceUrls),
pathname,
{
email: form.getValues('email'),
},
);
}
}
if (response.__typename === 'UserNotExists') {
setSignInUpMode(SignInUpMode.SignUp);
setSignInUpStep(SignInUpStep.Password);
}
},
});
continueWithCredentials();
};
const onEmailChange = (email: string) => {
@ -119,42 +167,118 @@ export const SignInUpGlobalScopeForm = () => {
}
};
const getAvailableWorkspaceUrl = (availableWorkspace: AvailableWorkspace) => {
const { pathname, searchParams } = getAvailableWorkspacePathAndSearchParams(
availableWorkspace,
{ email: form.getValues('email') },
);
return buildWorkspaceUrl(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
);
};
return (
<>
<StyledContentContainer>
{authProviders.google && <SignInUpWithGoogle />}
{authProviders.microsoft && <SignInUpWithMicrosoft />}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
<SignInUpEmailField
showErrors={showErrors}
onInputChange={onEmailChange}
/>
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<StyledWorkspaceContainer>
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
].map((availableWorkspace) => (
<UndecoratedLink
key={availableWorkspace.id}
to={getAvailableWorkspaceUrl(availableWorkspace)}
>
<StyledWorkspaceItem>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
size="lg"
/>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName || availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
))}
<StyledWorkspaceItem onClick={() => createWorkspace()}>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</StyledWorkspaceContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledContentContainer>
{authProviders.google && (
<SignInUpWithGoogle action="list-available-workspaces" />
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft action="list-available-workspaces" />
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
<SignInUpEmailField
showErrors={showErrors}
signInUpMode={signInUpMode}
onInputChange={onEmailChange}
/>
)}
<MainButton
disabled={isRequestingCaptchaToken}
title={
signInUpStep === SignInUpStep.Password ? 'Sign Up' : 'Continue'
}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledForm>
</FormProvider>
</StyledContentContainer>
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
showErrors={showErrors}
signInUpMode={signInUpMode}
/>
)}
<MainButton
disabled={isRequestingCaptchaToken}
title={
signInUpStep === SignInUpStep.Password
? signInUpMode === SignInUpMode.SignIn
? t`Sign In`
: t`Sign Up`
: t`Continue`
}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledForm>
</FormProvider>
</StyledContentContainer>
)}
</>
);
};

View File

@ -1,7 +1,7 @@
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO';
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpWithSSO';
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';
@ -36,9 +36,13 @@ export const SignInUpWorkspaceScopeForm = () => {
return (
<>
<StyledContentContainer>
{workspaceAuthProviders.google && <SignInUpWithGoogle />}
{workspaceAuthProviders.google && (
<SignInUpWithGoogle action="join-workspace" />
)}
{workspaceAuthProviders.microsoft && <SignInUpWithMicrosoft />}
{workspaceAuthProviders.microsoft && (
<SignInUpWithMicrosoft action="join-workspace" />
)}
{workspaceAuthProviders.sso.length > 0 && <SignInUpWithSSO />}

View File

@ -0,0 +1,34 @@
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useAuth } from '@/auth/hooks/useAuth';
import { useSearchParams } from 'react-router-dom';
export const SignInUpGlobalScopeFormEffect = () => {
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [searchParams, setSearchParams] = useSearchParams();
const { setAuthTokens, loadCurrentUser } = useAuth();
useEffect(() => {
const tokenPair = searchParams.get('tokenPair');
if (isDefined(tokenPair)) {
setAuthTokens(JSON.parse(tokenPair));
searchParams.delete('tokenPair');
setSearchParams(searchParams);
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
}
}, [
searchParams,
setSearchParams,
setSignInUpStep,
loadCurrentUser,
setAuthTokens,
]);
return <></>;
};

View File

@ -5,8 +5,8 @@ import {
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { captchaState } from '@/client-config/states/captchaState';

View File

@ -9,23 +9,27 @@ import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { HorizontalSeparator, IconGoogle } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
const GoogleIcon = memo(() => {
const theme = useTheme();
return <IconGoogle size={theme.icon.size.md} />;
});
export const SignInUpWithGoogle = () => {
export const SignInUpWithGoogle = ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) => {
const { t } = useLingui();
const signInUpStep = useRecoilValue(signInUpStepState);
const { signInWithGoogle } = useSignInWithGoogle();
return (
<>
<MainButton
Icon={GoogleIcon}
title={t`Continue with Google`}
onClick={signInWithGoogle}
onClick={() => signInWithGoogle({ action })}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>

View File

@ -8,8 +8,13 @@ import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { HorizontalSeparator, IconMicrosoft } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const SignInUpWithMicrosoft = () => {
export const SignInUpWithMicrosoft = ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) => {
const theme = useTheme();
const { t } = useLingui();
@ -21,7 +26,7 @@ export const SignInUpWithMicrosoft = () => {
<MainButton
Icon={() => <IconMicrosoft size={theme.icon.size.md} />}
title={t`Continue with Microsoft`}
onClick={signInWithMicrosoft}
onClick={() => signInWithMicrosoft({ action })}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>

View File

@ -43,9 +43,12 @@ describe('useSignInWithGoogle', () => {
const { result } = renderHook(() => useSignInWithGoogle(), {
wrapper: Wrapper,
});
result.current.signInWithGoogle();
result.current.signInWithGoogle({
action: 'join-workspace',
});
expect(signInWithGoogleMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: 'testHash',
workspacePersonalInviteToken: 'testToken',
billingCheckoutSession: mockBillingCheckoutSession,
@ -66,9 +69,12 @@ describe('useSignInWithGoogle', () => {
const { result } = renderHook(() => useSignInWithGoogle(), {
wrapper: Wrapper,
});
result.current.signInWithGoogle();
result.current.signInWithGoogle({
action: 'join-workspace',
});
expect(signInWithGoogleMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: 'testHash',
workspacePersonalInviteToken: undefined,
billingCheckoutSession: mockBillingCheckoutSession,

View File

@ -43,9 +43,12 @@ describe('useSignInWithMicrosoft', () => {
const { result } = renderHook(() => useSignInWithMicrosoft(), {
wrapper: Wrapper,
});
result.current.signInWithMicrosoft();
result.current.signInWithMicrosoft({
action: 'join-workspace',
});
expect(signInWithMicrosoftMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: workspaceInviteHashMock,
workspacePersonalInviteToken: inviteTokenMock,
billingCheckoutSession: mockBillingCheckoutSession,
@ -67,9 +70,12 @@ describe('useSignInWithMicrosoft', () => {
const { result } = renderHook(() => useSignInWithMicrosoft(), {
wrapper: Wrapper,
});
result.current.signInWithMicrosoft();
result.current.signInWithMicrosoft({
action: 'join-workspace',
});
expect(signInWithMicrosoftMock).toHaveBeenCalledWith({
action: 'join-workspace',
billingCheckoutSession: mockBillingCheckoutSession,
workspaceInviteHash: workspaceInviteHashMock,
workspacePersonalInviteToken: undefined,

View File

@ -19,12 +19,14 @@ import { useRecoilState } from 'recoil';
import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const useSignInUp = (form: UseFormReturn<Form>) => {
const { enqueueSnackBar } = useSnackBar();
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const location = useLocation();
@ -38,7 +40,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
);
const {
signInWithCredentialsInWorkspace,
signInWithCredentials,
signUpWithCredentialsInWorkspace,
signUpWithCredentials,
checkUserExists: { checkUserExistsQuery },
} = useAuth();
@ -71,11 +75,11 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
},
onCompleted: (data) => {
requestFreshCaptchaToken();
if (data?.checkUserExists.exists) {
setSignInUpMode(SignInUpMode.SignIn);
} else {
setSignInUpMode(SignInUpMode.SignUp);
}
setSignInUpMode(
data?.checkUserExists.exists
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
setSignInUpStep(SignInUpStep.Password);
},
});
@ -97,31 +101,60 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
throw new Error('Email and password are required');
}
if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) {
await signInWithCredentials(
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignIn &&
isOnAWorkspace
) {
return await signInWithCredentialsInWorkspace(
data.email.toLowerCase().trim(),
data.password,
token,
);
} else {
const verifyEmailNextPath = buildAppPathWithQueryParams(
AppPath.PlanRequired,
await buildSearchParamsFromUrlSyncedStates(),
);
await signUpWithCredentials({
email: data.email.toLowerCase().trim(),
password: data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken: token,
verifyEmailNextPath,
});
}
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignIn &&
!isOnAWorkspace
) {
return await signInWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
);
}
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignUp &&
!isOnAWorkspace
) {
return await signUpWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
);
}
const verifyEmailNextPath = buildAppPathWithQueryParams(
AppPath.PlanRequired,
await buildSearchParamsFromUrlSyncedStates(),
);
await signUpWithCredentialsInWorkspace({
email: data.email.toLowerCase().trim(),
password: data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken: token,
verifyEmailNextPath,
});
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
});
} finally {
requestFreshCaptchaToken();
}
},
@ -129,13 +162,16 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
readCaptchaToken,
signInUpMode,
isInviteMode,
signInWithCredentialsInWorkspace,
signInWithCredentials,
signUpWithCredentials,
signUpWithCredentialsInWorkspace,
workspaceInviteHash,
workspacePersonalInviteToken,
enqueueSnackBar,
requestFreshCaptchaToken,
buildSearchParamsFromUrlSyncedStates,
isOnAWorkspace,
],
);

View File

@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
@ -30,7 +30,6 @@ const makeValidationSchema = (signInUpStep: SignInUpStep) =>
export type Form = z.infer<ReturnType<typeof makeValidationSchema>>;
export const useSignInUpForm = () => {
const location = useLocation();
const signInUpStep = useRecoilValue(signInUpStepState);
const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step
@ -61,11 +60,6 @@ export const useSignInUpForm = () => {
form.setValue('email', prefilledEmail ?? 'tim@apple.dev');
form.setValue('password', 'tim@apple.dev');
}
}, [
form,
isDeveloperDefaultSignInPrefilled,
prefilledEmail,
location.search,
]);
}, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail]);
return { form: form };
};

View File

@ -2,6 +2,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
@ -15,12 +16,14 @@ export const useSignInWithGoogle = () => {
} as BillingCheckoutSession;
const { signInWithGoogle } = useAuth();
return {
signInWithGoogle: () =>
signInWithGoogle: ({ action }: { action: SocialSSOSignInUpActionType }) =>
signInWithGoogle({
workspaceInviteHash,
workspacePersonalInviteToken,
billingCheckoutSession,
action,
}),
};
};

View File

@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { useRecoilValue } from 'recoil';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const useSignInWithMicrosoft = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
@ -13,11 +14,16 @@ export const useSignInWithMicrosoft = () => {
const { signInWithMicrosoft } = useAuth();
return {
signInWithMicrosoft: () =>
signInWithMicrosoft: ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) =>
signInWithMicrosoft({
workspaceInviteHash,
workspacePersonalInviteToken,
billingCheckoutSession,
action,
}),
};
};

View File

@ -0,0 +1,37 @@
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { AppPath } from '@/types/AppPath';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { enqueueSnackBar } = useSnackBar();
const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation();
const createWorkspace = () => {
signUpInNewWorkspaceMutation({
onCompleted: async (data) => {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
'_blank',
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
};
return {
createWorkspace,
};
};

View File

@ -1,9 +0,0 @@
import { UserExists } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export const availableSSOIdentityProvidersForAuthState = createState<
NonNullable<UserExists['availableWorkspaces']>[0]['sso']
>({
key: 'availableSSOIdentityProvidersForAuth',
defaultValue: [],
});

View File

@ -0,0 +1,10 @@
import { createState } from 'twenty-ui/utilities';
import { AvailableWorkspaces } from '~/generated/graphql';
export const availableWorkspacesState = createState<AvailableWorkspaces>({
key: 'availableWorkspacesState',
defaultValue: {
availableWorkspacesForSignIn: [],
availableWorkspacesForSignUp: [],
},
});

View File

@ -1,12 +0,0 @@
import { Workspace } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export type Workspaces = Pick<
Workspace,
'id' | 'logo' | 'displayName' | 'workspaceUrls'
>[];
export const workspacesState = createState<Workspaces>({
key: 'workspacesState',
defaultValue: [],
});

View File

@ -0,0 +1,4 @@
export type SocialSSOSignInUpActionType =
| 'create-new-workspace'
| 'list-available-workspaces'
| 'join-workspace';

View File

@ -0,0 +1,70 @@
import { AvailableWorkspaces, AvailableWorkspace } from '~/generated/graphql';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-shared/utils';
import { generatePath } from 'react-router-dom';
export const countAvailableWorkspaces = ({
availableWorkspacesForSignIn,
availableWorkspacesForSignUp,
}: AvailableWorkspaces): number => {
return (
availableWorkspacesForSignIn.length + availableWorkspacesForSignUp.length
);
};
export const getFirstAvailableWorkspaces = ({
availableWorkspacesForSignIn,
availableWorkspacesForSignUp,
}: AvailableWorkspaces): AvailableWorkspace => {
return availableWorkspacesForSignIn[0] ?? availableWorkspacesForSignUp[0];
};
const getAvailableWorkspacePathname = (
availableWorkspace: AvailableWorkspace,
) => {
if (isDefined(availableWorkspace.loginToken)) {
return AppPath.Verify;
}
if (
isDefined(availableWorkspace.personalInviteToken) &&
isDefined(availableWorkspace.inviteHash)
) {
return generatePath(AppPath.Invite, {
workspaceInviteHash: availableWorkspace.inviteHash,
});
}
return AppPath.SignInUp;
};
const getAvailableWorkspaceSearchParams = (
availableWorkspace: AvailableWorkspace,
defaultSearchParams: Record<string, string> = {},
) => {
const searchParams: Record<string, string> = defaultSearchParams;
if (isDefined(availableWorkspace.loginToken)) {
searchParams.loginToken = availableWorkspace.loginToken;
return searchParams;
}
if (isDefined(availableWorkspace.personalInviteToken)) {
searchParams.inviteToken = availableWorkspace.personalInviteToken;
}
return searchParams;
};
export const getAvailableWorkspacePathAndSearchParams = (
availableWorkspace: AvailableWorkspace,
defaultSearchParams: Record<string, string> = {},
): { pathname: string; searchParams: Record<string, string> } => {
return {
pathname: getAvailableWorkspacePathname(availableWorkspace),
searchParams: getAvailableWorkspaceSearchParams(
availableWorkspace,
defaultSearchParams,
),
};
};

View File

@ -0,0 +1,43 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
describe('useReadCaptchaToken', () => {
it('should return undefined when no token exists', async () => {
const { result } = renderHook(() => useReadCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
const token = await result.current.readCaptchaToken();
expect(token).toBeUndefined();
});
});
it('should return the token when it exists', async () => {
const { result } = renderHook(
() => {
const hook = useReadCaptchaToken();
return hook;
},
{
wrapper: ({ children }) => (
<RecoilRoot
initializeState={({ set }) => {
set(captchaTokenState, 'test-token');
}}
>
{children}
</RecoilRoot>
),
},
);
await act(async () => {
const token = await result.current.readCaptchaToken();
expect(token).toBe('test-token');
});
});
});

View File

@ -0,0 +1,66 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import * as ReactRouterDom from 'react-router-dom';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
describe('useRequestFreshCaptchaToken', () => {
beforeEach(() => {
// Mock useLocation to return a path that requires captcha
(ReactRouterDom.useLocation as jest.Mock).mockReturnValue({
pathname: '/sign-in',
});
// Mock window.grecaptcha
window.grecaptcha = {
execute: jest.fn().mockImplementation(() => {
return Promise.resolve('google-recaptcha-token');
}),
};
// Mock window.turnstile
window.turnstile = {
render: jest.fn().mockReturnValue('turnstile-widget-id'),
execute: jest.fn().mockImplementation((widgetId, options) => {
return options?.callback('turnstile-token');
}),
};
});
afterEach(() => {
jest.clearAllMocks();
delete window.grecaptcha;
delete window.turnstile;
});
it('should not request a token if captcha is not required for the path', async () => {
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
await result.current.requestFreshCaptchaToken();
});
expect(window.grecaptcha.execute).not.toHaveBeenCalled();
expect(window.turnstile.execute).not.toHaveBeenCalled();
});
it('should not request a token if captcha provider is not defined', async () => {
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
await result.current.requestFreshCaptchaToken();
});
expect(window.grecaptcha.execute).not.toHaveBeenCalled();
expect(window.turnstile.execute).not.toHaveBeenCalled();
});
});

View File

@ -18,8 +18,9 @@ export const useIsCurrentLocationOnAWorkspace = () => {
throw new Error('frontDomain and defaultSubdomain are required');
}
const isOnAWorkspace =
isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain;
const isOnAWorkspace = !isMultiWorkspaceEnabled
? true
: window.location.hostname !== defaultDomain;
return {
isOnAWorkspace,

View File

@ -150,8 +150,7 @@ export const queries = {
hasValidEnterpriseKey
customDomain
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
featureFlags {
id
@ -179,8 +178,7 @@ export const queries = {
subdomain
customDomain
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -14,6 +14,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
const mockCurrentUser = {
id: 'fake-user-id',
@ -41,7 +42,11 @@ const initializeState = ({ set }: MutableSnapshot) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
{children}
</SnackBarProviderScope>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
);

View File

@ -2,7 +2,6 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import { useAuth } from '@/auth/hooks/useAuth';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath';
@ -37,9 +36,14 @@ import {
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui/navigation';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import {
useSignUpInNewWorkspaceMutation,
AvailableWorkspace,
} from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light};
@ -50,7 +54,9 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { t } = useLingui();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const workspaces = useRecoilValue(workspacesState);
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
const { signOut } = useAuth();
@ -63,8 +69,10 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
multiWorkspaceDropdownState,
);
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
const handleChange = async (availableWorkspace: AvailableWorkspace) => {
redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
);
};
const createWorkspace = () => {
@ -127,36 +135,41 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
>
{currentWorkspace?.displayName}
</DropdownMenuHeader>
{workspaces.length > 1 && (
{availableWorkspacesCount > 1 && (
<>
<DropdownMenuItemsContainer>
{workspaces
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
]
.filter(({ id }) => id !== currentWorkspace?.id)
.slice(0, 3)
.map((workspace) => (
.map((availableWorkspace) => (
<UndecoratedLink
key={workspace.id}
key={availableWorkspace.id}
to={buildWorkspaceUrl(
getWorkspaceUrl(workspace.workspaceUrls),
getWorkspaceUrl(availableWorkspace.workspaceUrls),
)}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
handleChange(availableWorkspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
text={availableWorkspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
/>
}
selected={false}
/>
</UndecoratedLink>
))}
{workspaces.length > 4 && (
{availableWorkspacesCount > 4 && (
<MenuItem
LeftIcon={IconSwitchHorizontal}
text={t`Other workspaces`}

View File

@ -1,32 +1,23 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Avatar, IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelectAvatar, UndecoratedLink } from 'twenty-ui/navigation';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { IconChevronLeft } from 'twenty-ui/display';
import { WorkspacesForSignIn } from './components/WorkspacesForSignIn';
import { WorkspacesForSignUp } from './components/WorkspacesForSignUp';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
export const MultiWorkspaceDropdownWorkspacesListComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const workspaces = useRecoilValue(workspacesState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { t } = useLingui();
const handleChange = async (workspace: Workspaces[0]) => {
await redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const setMultiWorkspaceDropdownState = useSetRecoilState(
multiWorkspaceDropdownState,
);
@ -52,37 +43,10 @@ export const MultiWorkspaceDropdownWorkspacesListComponents = () => {
}}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{workspaces
.filter(
(workspace) =>
workspace.id !== currentWorkspace?.id &&
workspace.displayName
?.toLowerCase()
.includes(searchValue.toLowerCase()),
)
.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={currentWorkspace?.id === workspace.id}
/>
</UndecoratedLink>
))}
</DropdownMenuItemsContainer>
<WorkspacesForSignIn searchValue={searchValue} />
{availableWorkspaces.availableWorkspacesForSignUp.length > 0 && (
<WorkspacesForSignUp searchValue={searchValue} />
)}
</DropdownContent>
);
};

View File

@ -0,0 +1,58 @@
import { Avatar } from 'twenty-ui/display';
import { MenuItemSelectAvatar, UndecoratedLink } from 'twenty-ui/navigation';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { AvailableWorkspace } from '~/generated/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import React from 'react';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
export const AvailableWorkspaceItem = ({
availableWorkspace,
isSelected,
}: {
availableWorkspace: AvailableWorkspace;
isSelected: boolean;
}) => {
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { pathname, searchParams } =
getAvailableWorkspacePathAndSearchParams(availableWorkspace);
const handleChange = async () => {
await redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
);
};
return (
<UndecoratedLink
key={availableWorkspace.id}
to={buildWorkspaceUrl(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
)}
onClick={(event) => {
event.preventDefault();
handleChange();
}}
>
<MenuItemSelectAvatar
text={availableWorkspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={isSelected}
/>
</UndecoratedLink>
);
};

View File

@ -0,0 +1,39 @@
import { useLingui } from '@lingui/react/macro';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem';
export const WorkspacesForSignIn = ({
searchValue,
}: {
searchValue: string;
}) => {
const { t } = useLingui();
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces();
return (
<>
<StyledDropdownMenuSubheader>{t`Member of`}</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{searchAvailableWorkspaces(
searchValue,
availableWorkspaces.availableWorkspacesForSignIn,
).map((availableWorkspace) => (
<AvailableWorkspaceItem
key={availableWorkspace.id}
availableWorkspace={availableWorkspace}
isSelected={currentWorkspace?.id === availableWorkspace.id}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,39 @@
import { useLingui } from '@lingui/react/macro';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces';
import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
export const WorkspacesForSignUp = ({
searchValue,
}: {
searchValue: string;
}) => {
const { t } = useLingui();
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces();
return (
<>
<StyledDropdownMenuSubheader>{t`Invitations`}</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer scrollable={false}>
{searchAvailableWorkspaces(
searchValue,
availableWorkspaces.availableWorkspacesForSignUp,
).map((availableWorkspace) => (
<AvailableWorkspaceItem
key={availableWorkspace.id}
availableWorkspace={availableWorkspace}
isSelected={currentWorkspace?.id === availableWorkspace.id}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,25 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { AvailableWorkspace } from '~/generated/graphql';
export const useFilteredAvailableWorkspaces = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const searchAvailableWorkspaces = (
searchValue: string,
availableWorkspaces: Array<AvailableWorkspace>,
) => {
return availableWorkspaces.filter(
(availableWorkspace) =>
currentWorkspace?.id &&
availableWorkspace.id !== currentWorkspace.id &&
availableWorkspace.displayName
?.toLowerCase()
.includes(searchValue.toLowerCase()),
);
};
return {
searchAvailableWorkspaces,
};
};

View File

@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { workspacesState } from '@/auth/states/workspaces';
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
@ -30,6 +29,7 @@ import { useGetCurrentUserQuery } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
export const UserProviderEffect = () => {
const location = useLocation();
@ -40,7 +40,7 @@ export const UserProviderEffect = () => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const isLoggedIn = useIsLogged();
@ -102,7 +102,7 @@ export const UserProviderEffect = () => {
workspaceMember,
workspaceMembers,
deletedWorkspaceMembers,
workspaces: userWorkspaces,
availableWorkspaces,
} = queryData.currentUser;
const affectDefaultValuesOnEmptyWorkspaceMemberFields = (
@ -153,12 +153,8 @@ export const UserProviderEffect = () => {
setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers);
}
if (isDefined(userWorkspaces)) {
const workspaces = userWorkspaces
.map(({ workspace }) => workspace)
.filter(isDefined);
setWorkspaces(workspaces);
if (isDefined(availableWorkspaces)) {
setAvailableWorkspaces(availableWorkspaces);
}
}, [
queryLoading,
@ -166,9 +162,9 @@ export const UserProviderEffect = () => {
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspaceMembers,
setAvailableWorkspaces,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setWorkspaces,
setIsCurrentUserLoaded,
setDateTimeFormat,
setCurrentWorkspaceMembersWithDeleted,

View File

@ -3,6 +3,7 @@ import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment';
import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client';
import { AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT } from '@/auth/graphql/fragments/authFragments';
export const USER_QUERY_FRAGMENT = gql`
${ROLE_FRAGMENT}
@ -48,8 +49,7 @@ export const USER_QUERY_FRAGMENT = gql`
customDomain
isCustomDomainEnabled
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
featureFlags {
key
@ -86,22 +86,13 @@ export const USER_QUERY_FRAGMENT = gql`
...RoleFragment
}
}
workspaces {
workspace {
id
logo
displayName
subdomain
customDomain
workspaceUrls {
subdomainUrl
customUrl
}
}
availableWorkspaces {
...AvailableWorkspacesFragment
}
userVars
}
${AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT}
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT}
`;

View File

@ -0,0 +1,8 @@
import { gql } from '@apollo/client';
export const WORKSPACE_URLS_FRAGMENT = gql`
fragment WorkspaceUrlsFragment on WorkspaceUrls {
subdomainUrl
customUrl
}
`;