From 88f5eb669e3845224756fd3a707363549d0796ed Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 20 May 2024 12:11:38 +0200 Subject: [PATCH] 4689 multi workspace i should be able to accept an invite if im already logged in (#5454) - split signInUp to separate Invitation from signInUp - update redirection logic - add a resolver for userWorkspace - add a mutation to add a user to a workspace - authorize /invite/hash while loggedIn - add a button to join a workspace ### Base functionnality https://github.com/twentyhq/twenty/assets/29927851/a1075a4e-a2af-4184-aa3e-e163711277a1 ### Error handling https://github.com/twentyhq/twenty/assets/29927851/1bdd78ce-933a-4860-a87a-3f1f7bda389e --- packages/twenty-front/src/App.tsx | 3 +- .../effect-components/PageChangeEffect.tsx | 34 +---- .../twenty-front/src/generated/graphql.tsx | 56 ++++++++ .../sign-in-up/components/SignInUpForm.tsx | 41 ++---- .../hooks/useWorkspaceFromInviteHash.ts | 13 +- .../modules/ui/layout/page/DefaultLayout.tsx | 1 + .../hooks/useWorkspaceSwitching.ts | 6 +- .../grapqhql/mutations/addUserToWorkspace.ts | 9 ++ .../twenty-front/src/pages/auth/Invite.tsx | 122 ++++++++++++++++++ .../twenty-front/src/pages/auth/SignInUp.tsx | 44 ++++++- .../auth/__stories__/SignInUp.stories.tsx | 18 ++- .../engine/core-modules/auth/auth.resolver.ts | 2 - .../auth/services/sign-in-up.service.ts | 24 +--- .../user-workspace/user-workspace.module.ts | 3 +- .../user-workspace/user-workspace.resolver.ts | 41 ++++++ .../user-workspace/user-workspace.service.ts | 22 ++++ .../workspace/workspace.module.ts | 2 + 17 files changed, 340 insertions(+), 101 deletions(-) create mode 100644 packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspace.ts create mode 100644 packages/twenty-front/src/pages/auth/Invite.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index 72e7f4105..ccff7d414 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -38,6 +38,7 @@ import { Authorize } from '~/pages/auth/Authorize'; import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan'; import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; +import { Invite } from '~/pages/auth/Invite'; import { PasswordReset } from '~/pages/auth/PasswordReset'; import { PaymentSuccess } from '~/pages/auth/PaymentSuccess'; import { SignInUp } from '~/pages/auth/SignInUp'; @@ -128,7 +129,7 @@ const createRouter = (isBillingEnabled?: boolean) => }> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index dba2d07ab..9f8186343 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { matchPath, useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { IconCheckbox } from 'twenty-ui'; @@ -20,10 +20,8 @@ import { SettingsPath } from '@/types/SettingsPath'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useGetWorkspaceFromInviteHashLazyQuery } from '~/generated/graphql'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isDefined } from '~/utils/isDefined'; -import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; - -import { useIsMatchingLocation } from '../hooks/useIsMatchingLocation'; // TODO: break down into smaller functions and / or hooks export const PageChangeEffect = () => { @@ -70,13 +68,6 @@ export const PageChangeEffect = () => { isMatchingLocation(AppPath.PlanRequired) || isMatchingLocation(AppPath.PlanRequiredSuccess); - const navigateToSignUp = () => { - enqueueSnackBar('workspace does not exist', { - variant: 'error', - }); - navigate(AppPath.SignInUp); - }; - if ( onboardingStatus === OnboardingStatus.OngoingUserCreation && !isMatchingOngoingUserCreationRoute && @@ -115,7 +106,8 @@ export const PageChangeEffect = () => { navigate(AppPath.CreateProfile); } else if ( onboardingStatus === OnboardingStatus.Completed && - isMatchingOnboardingRoute + isMatchingOnboardingRoute && + !isMatchingLocation(AppPath.Invite) ) { navigate(AppPath.Index); } else if ( @@ -124,24 +116,6 @@ export const PageChangeEffect = () => { !isMatchingLocation(AppPath.PlanRequired) ) { navigate(AppPath.Index); - } else if (isMatchingLocation(AppPath.Invite)) { - const inviteHash = - matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname) - ?.params.workspaceInviteHash || ''; - - workspaceFromInviteHashQuery({ - variables: { - inviteHash, - }, - onCompleted: (data) => { - if (isUndefinedOrNull(data.findWorkspaceFromInviteHash)) { - navigateToSignUp(); - } - }, - onError: (_) => { - navigateToSignUp(); - }, - }); } }, [ enqueueSnackBar, diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 00405467f..c096e728d 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -260,6 +260,7 @@ export type LoginToken = { export type Mutation = { __typename?: 'Mutation'; activateWorkspace: Workspace; + addUserToWorkspace: User; authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; @@ -294,6 +295,11 @@ export type MutationActivateWorkspaceArgs = { }; +export type MutationAddUserToWorkspaceArgs = { + inviteHash: Scalars['String']; +}; + + export type MutationAuthorizeAppArgs = { clientId: Scalars['String']; codeChallenge?: InputMaybe; @@ -539,6 +545,7 @@ export type RelationConnection = { export type RelationDefinition = { __typename?: 'RelationDefinition'; direction: RelationDefinitionType; + relationId: Scalars['UUID']; sourceFieldMetadata: Field; sourceObjectMetadata: Object; targetFieldMetadata: Field; @@ -578,6 +585,7 @@ export type RemoteTable = { id?: Maybe; name: Scalars['String']; schema?: Maybe; + schemaPendingUpdates?: Maybe>; status: RemoteTableStatus; }; @@ -617,6 +625,14 @@ export type Support = { supportFrontChatId?: Maybe; }; +/** Schema update on a table */ +export enum TableUpdate { + ColumnsAdded = 'COLUMNS_ADDED', + ColumnsDeleted = 'COLUMNS_DELETED', + ColumnsTypeChanged = 'COLUMNS_TYPE_CHANGED', + TableDeleted = 'TABLE_DELETED' +} + export type Telemetry = { __typename?: 'Telemetry'; anonymizationEnabled: Scalars['Boolean']; @@ -1191,6 +1207,13 @@ export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; +export type AddUserToWorkspaceMutationVariables = Exact<{ + inviteHash: Scalars['String']; +}>; + + +export type AddUserToWorkspaceMutation = { __typename?: 'Mutation', addUserToWorkspace: { __typename?: 'User', id: any } }; + export type ActivateWorkspaceMutationVariables = Exact<{ input: ActivateWorkspaceInput; }>; @@ -2456,6 +2479,39 @@ export function useGetCurrentUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt export type GetCurrentUserQueryHookResult = ReturnType; export type GetCurrentUserLazyQueryHookResult = ReturnType; export type GetCurrentUserQueryResult = Apollo.QueryResult; +export const AddUserToWorkspaceDocument = gql` + mutation AddUserToWorkspace($inviteHash: String!) { + addUserToWorkspace(inviteHash: $inviteHash) { + id + } +} + `; +export type AddUserToWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __useAddUserToWorkspaceMutation__ + * + * To run a mutation, you first call `useAddUserToWorkspaceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAddUserToWorkspaceMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [addUserToWorkspaceMutation, { data, loading, error }] = useAddUserToWorkspaceMutation({ + * variables: { + * inviteHash: // value for 'inviteHash' + * }, + * }); + */ +export function useAddUserToWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AddUserToWorkspaceDocument, options); + } +export type AddUserToWorkspaceMutationHookResult = ReturnType; +export type AddUserToWorkspaceMutationResult = Apollo.MutationResult; +export type AddUserToWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const ActivateWorkspaceDocument = gql` mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { activateWorkspace(data: $input) { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 80e5d1310..06a38993b 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -6,11 +6,17 @@ import { motion } from 'framer-motion'; import { useRecoilState, useRecoilValue } from 'recoil'; import { IconGoogle, IconMicrosoft } from 'twenty-ui'; +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; +import { + SignInUpMode, + SignInUpStep, + useSignInUp, +} from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; -import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState'; @@ -18,16 +24,8 @@ import { Loader } from '@/ui/feedback/loader/components/Loader'; import { MainButton } from '@/ui/input/button/components/MainButton'; import { TextInput } from '@/ui/input/components/TextInput'; import { ActionLink } from '@/ui/navigation/link/components/ActionLink'; -import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { isDefined } from '~/utils/isDefined'; -import { Logo } from '../../components/Logo'; -import { Title } from '../../components/Title'; -import { SignInUpMode, SignInUpStep, useSignInUp } from '../hooks/useSignInUp'; - -import { FooterNote } from './FooterNote'; -import { HorizontalSeparator } from './HorizontalSeparator'; - const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-top: ${({ theme }) => theme.spacing(4)}; @@ -55,14 +53,12 @@ export const SignInUpForm = () => { ); const [authProviders] = useRecoilState(authProvidersState); const [showErrors, setShowErrors] = useState(false); - const { handleResetPassword } = useHandleResetPassword(); - const workspace = useWorkspaceFromInviteHash(); const { signInWithGoogle } = useSignInWithGoogle(); const { signInWithMicrosoft } = useSignInWithMicrosoft(); const { form } = useSignInUpForm(); + const { handleResetPassword } = useHandleResetPassword(); const { - isInviteMode, signInUpStep, signInUpMode, continueWithCredentials, @@ -101,23 +97,6 @@ export const SignInUpForm = () => { return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; }, [signInUpMode, signInUpStep]); - const title = useMemo(() => { - if (isInviteMode) { - return `Join ${workspace?.displayName ?? ''} team`; - } - - if ( - signInUpStep === SignInUpStep.Init || - signInUpStep === SignInUpStep.Email - ) { - return 'Welcome to Twenty'; - } - - return signInUpMode === SignInUpMode.SignIn - ? 'Sign in to Twenty' - : 'Sign up to Twenty'; - }, [signInUpMode, workspace?.displayName, isInviteMode, signInUpStep]); - const theme = useTheme(); const shouldWaitForCaptchaToken = @@ -143,10 +122,6 @@ export const SignInUpForm = () => { return ( <> - - - - {title} {authProviders.google && ( <> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts index f5403e9b4..391730dde 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts @@ -4,8 +4,13 @@ import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql'; export const useWorkspaceFromInviteHash = () => { const workspaceInviteHash = useParams().workspaceInviteHash; - const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({ - variables: { inviteHash: workspaceInviteHash || '' }, - }); - return workspaceFromInviteHash?.findWorkspaceFromInviteHash; + const { data: workspaceFromInviteHash, loading } = + useGetWorkspaceFromInviteHashQuery({ + variables: { inviteHash: workspaceInviteHash || '' }, + }); + return { + workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash, + workspaceInviteHash, + loading, + }; }; diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx index 8cbe647b5..0b3ad1bcd 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx @@ -80,6 +80,7 @@ export const DefaultLayout = () => { OnboardingStatus.OngoingWorkspaceActivation, ].includes(onboardingStatus)) || isMatchingLocation(AppPath.ResetPassword) || + isMatchingLocation(AppPath.Invite) || (isMatchingLocation(AppPath.PlanRequired) && (OnboardingStatus.CompletedWithoutSubscription || OnboardingStatus.Canceled)) diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 4ba363686..a5c3de011 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -1,13 +1,12 @@ -import { useNavigate } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { tokenPairState } from '@/auth/states/tokenPairState'; +import { AppPath } from '@/types/AppPath'; import { useGenerateJwtMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; export const useWorkspaceSwitching = () => { - const navigate = useNavigate(); const setTokenPair = useSetRecoilState(tokenPairState); const [generateJWT] = useGenerateJwtMutation(); const currentWorkspace = useRecoilValue(currentWorkspaceState); @@ -30,8 +29,7 @@ export const useWorkspaceSwitching = () => { const { tokens } = jwt.data.generateJWT; setTokenPair(tokens); - navigate(`/objects/companies`); - window.location.reload(); + window.location.href = AppPath.Index; }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspace.ts b/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspace.ts new file mode 100644 index 000000000..a57f07931 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspace.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const ADD_USER_TO_WORKSPACE = gql` + mutation AddUserToWorkspace($inviteHash: String!) { + addUserToWorkspace(inviteHash: $inviteHash) { + id + } + } +`; diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx new file mode 100644 index 000000000..c6ea7afc7 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -0,0 +1,122 @@ +import { useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { Logo } from '@/auth/components/Logo'; +import { Title } from '@/auth/components/Title'; +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { AppPath } from '@/types/AppPath'; +import { Loader } from '@/ui/feedback/loader/components/Loader'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { MainButton } from '@/ui/input/button/components/MainButton'; +import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching'; +import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; +import { useAddUserToWorkspaceMutation } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +export const Invite = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); + const { + workspace: workspaceFromInviteHash, + loading: workspaceFromInviteHashLoading, + workspaceInviteHash, + } = useWorkspaceFromInviteHash(); + const { form } = useSignInUpForm(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const [addUserToWorkspace] = useAddUserToWorkspaceMutation(); + const { switchWorkspace } = useWorkspaceSwitching(); + + const title = useMemo(() => { + return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`; + }, [workspaceFromInviteHash?.displayName]); + + const handleUserJoinWorkspace = async () => { + if ( + !(isDefined(workspaceInviteHash) && isDefined(workspaceFromInviteHash)) + ) { + return; + } + await addUserToWorkspace({ + variables: { + inviteHash: workspaceInviteHash, + }, + }); + await switchWorkspace(workspaceFromInviteHash.id); + }; + + useEffect(() => { + if ( + !isDefined(workspaceFromInviteHash) && + !workspaceFromInviteHashLoading + ) { + enqueueSnackBar('workspace does not exist', { + variant: 'error', + }); + if (isDefined(currentWorkspace)) { + navigate(AppPath.Index); + } else { + navigate(AppPath.SignInUp); + } + } + if ( + isDefined(currentWorkspace) && + currentWorkspace.id === workspaceFromInviteHash?.id + ) { + enqueueSnackBar( + `You already belong to ${workspaceFromInviteHash?.displayName} workspace`, + { + variant: 'info', + }, + ); + navigate(AppPath.Index); + } + }, [ + navigate, + enqueueSnackBar, + currentWorkspace, + workspaceFromInviteHash, + workspaceFromInviteHashLoading, + ]); + + return ( + !workspaceFromInviteHashLoading && ( + <> + + + + {title} + {isDefined(currentWorkspace) && workspaceFromInviteHash ? ( + <> + + form.formState.isSubmitting && } + fullWidth + /> + + + By using Twenty, you agree to the Terms of Service and Privacy + Policy. + + + ) : ( + + )} + + ) + ); +}; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 8d4ffd3c2..f786fb543 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -1,3 +1,43 @@ -import { SignInUpForm } from '../../modules/auth/sign-in-up/components/SignInUpForm'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; -export const SignInUp = () => ; +import { Title } from '@/auth/components/Title'; +import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { + SignInUpMode, + SignInUpStep, + useSignInUp, +} from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isDefined } from '~/utils/isDefined'; + +export const SignInUp = () => { + const { form } = useSignInUpForm(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { signInUpStep, signInUpMode } = useSignInUp(form); + + const title = useMemo(() => { + if ( + signInUpStep === SignInUpStep.Init || + signInUpStep === SignInUpStep.Email + ) { + return 'Welcome to Twenty'; + } + return signInUpMode === SignInUpMode.SignIn + ? 'Sign in to Twenty' + : 'Sign up to Twenty'; + }, [signInUpMode, signInUpStep]); + + if (isDefined(currentWorkspace)) { + return <>; + } + + return ( + <> + {title} + + + ); +}; diff --git a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx index 9ff9f3035..3805d33ad 100644 --- a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx +++ b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx @@ -10,7 +10,6 @@ import { PageDecoratorArgs, } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; -import { mockedOnboardingUsersData } from '~/testing/mock-data/users'; import { SignInUp } from '../SignInUp'; @@ -24,14 +23,25 @@ const meta: Meta = { handlers: [ graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { return HttpResponse.json({ - data: { - currentUser: mockedOnboardingUsersData[0], - }, + data: null, + errors: [ + { + message: 'Unauthorized', + extensions: { + code: 'UNAUTHENTICATED', + response: { + statusCode: 401, + message: 'Unauthorized', + }, + }, + }, + ], }); }), graphqlMocks.handlers, ], }, + cookie: '', }, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index f3fb18a3d..9ce29e324 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -27,7 +27,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity'; import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; @@ -56,7 +55,6 @@ export class AuthResolver { private authService: AuthService, private tokenService: TokenService, private userService: UserService, - private userWorkspaceService: UserWorkspaceService, ) {} @UseGuards(CaptchaGuard) diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 954c649aa..3397f2603 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -150,26 +150,10 @@ export class SignInUpService { ); if (existingUser) { - const userWorkspaceExists = - await this.userWorkspaceService.checkUserWorkspaceExists( - existingUser.id, - workspace.id, - ); - - if (!userWorkspaceExists) { - await this.userWorkspaceService.create(existingUser.id, workspace.id); - - await this.userWorkspaceService.createWorkspaceMember( - workspace.id, - existingUser, - ); - } - - const updatedUser = await this.userRepository.save({ - id: existingUser.id, - defaultWorkspace: workspace, - updatedAt: new Date().toISOString(), - }); + const updatedUser = await this.userWorkspaceService.addUserToWorkspace( + existingUser, + workspace, + ); return Object.assign(existingUser, updatedUser); } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index bc827d9d3..09d3c621f 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -8,12 +8,13 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { User } from 'src/engine/core-modules/user/user.entity'; @Module({ imports: [ NestjsQueryGraphQLModule.forFeature({ imports: [ - NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'), + NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'), TypeORMModule, DataSourceModule, WorkspaceDataSourceModule, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts new file mode 100644 index 000000000..065e0c4bd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts @@ -0,0 +1,41 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceInviteHashValidInput } from 'src/engine/core-modules/auth/dto/workspace-invite-hash.input'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => UserWorkspace) +export class UserWorkspaceResolver { + constructor( + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + private readonly userWorkspaceService: UserWorkspaceService, + ) {} + + @Mutation(() => User) + async addUserToWorkspace( + @AuthUser() user: User, + @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, + ) { + const workspace = await this.workspaceRepository.findOneBy({ + inviteHash: workspaceInviteHashValidInput.inviteHash, + }); + + if (!workspace) { + return; + } + + return await this.userWorkspaceService.addUserToWorkspace(user, workspace); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 46653ee07..33aad64e7 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -12,11 +12,14 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { assert } from 'src/utils/assert'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; export class UserWorkspaceService extends TypeOrmQueryService { constructor( @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, private readonly dataSourceService: DataSourceService, private readonly typeORMService: TypeORMService, private readonly workspaceDataSourceService: WorkspaceDataSourceService, @@ -70,6 +73,25 @@ export class UserWorkspaceService extends TypeOrmQueryService { this.eventEmitter.emit('workspaceMember.created', payload); } + async addUserToWorkspace(user: User, workspace: Workspace) { + const userWorkspaceExists = await this.checkUserWorkspaceExists( + user.id, + workspace.id, + ); + + if (!userWorkspaceExists) { + await this.create(user.id, workspace.id); + + await this.createWorkspaceMember(workspace.id, user); + } + + return await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + updatedAt: new Date().toISOString(), + }); + } + public async getWorkspaceMemberCount( workspaceId: string, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index ad5ad20ed..d151781f4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -15,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; @@ -46,6 +47,7 @@ import { WorkspaceService } from './services/workspace.service'; providers: [ WorkspaceResolver, WorkspaceService, + UserWorkspaceResolver, WorkspaceWorkspaceMemberListener, ], })