From 7dcbc56e69d0aff4fa7bf0f38c0e6f11cd55f673 Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Thu, 10 Aug 2023 06:00:07 +0800 Subject: [PATCH] feat: Add the workspace logo on Twenty logo on the invited route (#1136) * Add the workspace logo on Twenty logo on the invited route Co-authored-by: v1b3m Co-authored-by: Mael FOSSO * Add minor refactors Co-authored-by: v1b3m Co-authored-by: Mael FOSSO * Refactor the invite logic Co-authored-by: v1b3m Co-authored-by: Mael FOSSO --------- Co-authored-by: v1b3m Co-authored-by: Mael FOSSO --- front/src/generated/graphql.tsx | 50 +++++++++++++++++++ front/src/modules/auth/components/Logo.tsx | 25 +++++++++- .../sign-in-up/components/SignInUpForm.tsx | 19 ++++--- .../auth/sign-in-up/hooks/useSignInUp.tsx | 19 +++++-- front/src/modules/workspace/queries/select.ts | 10 ++++ front/src/sync-hooks/AuthAutoRouter.tsx | 32 ++++++++++++ server/src/core/auth/auth.resolver.spec.ts | 6 +++ server/src/core/auth/auth.resolver.ts | 14 ++++++ 8 files changed, 163 insertions(+), 12 deletions(-) diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index f7c6095d0..9a34f7b19 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1769,6 +1769,7 @@ export type Query = { findManyWorkspaceMember: Array; findUniqueCompany: Company; findUniquePerson: Person; + findWorkspaceFromInviteHash: Workspace; }; @@ -1881,6 +1882,11 @@ export type QueryFindUniquePersonArgs = { id: Scalars['String']; }; + +export type QueryFindWorkspaceFromInviteHashArgs = { + inviteHash: Scalars['String']; +}; + export enum QueryMode { Default = 'default', Insensitive = 'insensitive' @@ -2787,6 +2793,13 @@ export type GetWorkspaceMembersQueryVariables = Exact<{ [key: string]: never; }> export type GetWorkspaceMembersQuery = { __typename?: 'Query', workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, user: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null, firstName?: string | null, lastName?: string | null, displayName: string } }> }; +export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ + inviteHash: Scalars['String']; +}>; + + +export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null } }; + export type UpdateWorkspaceMutationVariables = Exact<{ data: WorkspaceUpdateInput; }>; @@ -5444,6 +5457,43 @@ export function useGetWorkspaceMembersLazyQuery(baseOptions?: Apollo.LazyQueryHo export type GetWorkspaceMembersQueryHookResult = ReturnType; export type GetWorkspaceMembersLazyQueryHookResult = ReturnType; export type GetWorkspaceMembersQueryResult = Apollo.QueryResult; +export const GetWorkspaceFromInviteHashDocument = gql` + query GetWorkspaceFromInviteHash($inviteHash: String!) { + findWorkspaceFromInviteHash(inviteHash: $inviteHash) { + id + displayName + logo + } +} + `; + +/** + * __useGetWorkspaceFromInviteHashQuery__ + * + * To run a query within a React component, call `useGetWorkspaceFromInviteHashQuery` and pass it any options that fit your needs. + * When your component renders, `useGetWorkspaceFromInviteHashQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetWorkspaceFromInviteHashQuery({ + * variables: { + * inviteHash: // value for 'inviteHash' + * }, + * }); + */ +export function useGetWorkspaceFromInviteHashQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetWorkspaceFromInviteHashDocument, options); + } +export function useGetWorkspaceFromInviteHashLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetWorkspaceFromInviteHashDocument, options); + } +export type GetWorkspaceFromInviteHashQueryHookResult = ReturnType; +export type GetWorkspaceFromInviteHashLazyQueryHookResult = ReturnType; +export type GetWorkspaceFromInviteHashQueryResult = Apollo.QueryResult; export const UpdateWorkspaceDocument = gql` mutation UpdateWorkspace($data: WorkspaceUpdateInput!) { updateWorkspace(data: $data) { diff --git a/front/src/modules/auth/components/Logo.tsx b/front/src/modules/auth/components/Logo.tsx index 4a33ea49b..0d60d69bc 100644 --- a/front/src/modules/auth/components/Logo.tsx +++ b/front/src/modules/auth/components/Logo.tsx @@ -1,6 +1,10 @@ import styled from '@emotion/styled'; -type Props = React.ComponentProps<'div'>; +import { getImageAbsoluteURIOrBase64 } from '@/users/utils/getProfilePictureAbsoluteURI'; + +type Props = React.ComponentProps<'div'> & { + workspaceLogo?: string | null; +}; const StyledLogo = styled.div` height: 48px; @@ -12,12 +16,29 @@ const StyledLogo = styled.div` width: 100%; } + position: relative; width: 48px; `; -export function Logo(props: Props) { +type StyledWorkspaceLogoProps = { + logo?: string | null; +}; + +const StyledWorkspaceLogo = styled.div` + background: url(${(props) => props.logo}); + background-size: cover; + border-radius: ${({ theme }) => theme.border.radius.xs}; + bottom: ${({ theme }) => `-${theme.spacing(3)}`}; + height: ${({ theme }) => theme.spacing(6)}; + position: absolute; + right: ${({ theme }) => `-${theme.spacing(3)}`}; + width: ${({ theme }) => theme.spacing(6)}; +`; + +export function Logo({ workspaceLogo, ...props }: Props) { return ( + logo ); diff --git a/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 054731ed5..614893a8d 100644 --- a/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -58,6 +58,7 @@ export function SignInUpForm() { handleSubmit, formState: { isSubmitting }, }, + workspace, } = useSignInUp(); const theme = useTheme(); @@ -73,16 +74,22 @@ export function SignInUpForm() { return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; }, [signInUpMode, signInUpStep]); + const title = useMemo(() => { + if (signInUpMode === SignInUpMode.Invite) { + return `Join ${workspace?.displayName ?? ''} Team`; + } + + return signInUpMode === SignInUpMode.SignIn + ? 'Sign in to Twenty' + : 'Sign up to Twenty'; + }, [signInUpMode, workspace?.displayName]); + return ( <> - + - - {signInUpMode === SignInUpMode.SignIn - ? 'Sign in to Twenty' - : 'Sign up to Twenty'} - + {title} {authProviders.google && ( <> diff --git a/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index ef29692b5..945dc7398 100644 --- a/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -11,6 +11,7 @@ import { AppPath } from '@/types/AppPath'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useAuth } from '../../hooks/useAuth'; @@ -19,6 +20,7 @@ import { PASSWORD_REGEX } from '../../utils/passwordRegex'; export enum SignInUpMode { SignIn = 'sign-in', SignUp = 'sign-up', + Invite = 'invite', } export enum SignInUpStep { @@ -50,13 +52,21 @@ export function useSignInUp() { const [signInUpStep, setSignInUpStep] = useState( SignInUpStep.Init, ); - const [signInUpMode, setSignInUpMode] = useState( - isMatchingLocation(AppPath.SignIn) + const [signInUpMode, setSignInUpMode] = useState(() => { + if (isMatchingLocation(AppPath.Invite)) { + return SignInUpMode.Invite; + } + + return isMatchingLocation(AppPath.SignIn) ? SignInUpMode.SignIn - : SignInUpMode.SignUp, - ); + : SignInUpMode.SignUp; + }); const [showErrors, setShowErrors] = useState(false); + const { data: workspace } = useGetWorkspaceFromInviteHashQuery({ + variables: { inviteHash: workspaceInviteHash || '' }, + }); + const form = useForm
({ mode: 'onChange', defaultValues: { @@ -171,5 +181,6 @@ export function useSignInUp() { goBackToEmailStep, submitCredentials, form, + workspace: workspace?.findWorkspaceFromInviteHash, }; } diff --git a/front/src/modules/workspace/queries/select.ts b/front/src/modules/workspace/queries/select.ts index 5a670d7c5..dd248175c 100644 --- a/front/src/modules/workspace/queries/select.ts +++ b/front/src/modules/workspace/queries/select.ts @@ -15,3 +15,13 @@ export const GET_WORKSPACE_MEMBERS = gql` } } `; + +export const GET_WORKSPACE_FROM_INVITE_HASH = gql` + query GetWorkspaceFromInviteHash($inviteHash: String!) { + findWorkspaceFromInviteHash(inviteHash: $inviteHash) { + id + displayName + logo + } + } +`; diff --git a/front/src/sync-hooks/AuthAutoRouter.tsx b/front/src/sync-hooks/AuthAutoRouter.tsx index 1aa39e585..2ff66c3f3 100644 --- a/front/src/sync-hooks/AuthAutoRouter.tsx +++ b/front/src/sync-hooks/AuthAutoRouter.tsx @@ -12,8 +12,10 @@ import { AppBasePath } from '@/types/AppBasePath'; import { AppPath } from '@/types/AppPath'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { SettingsPath } from '@/types/SettingsPath'; +import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar'; import { TableHotkeyScope } from '@/ui/table/types/TableHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useGetWorkspaceFromInviteHashLazyQuery } from '~/generated/graphql'; import { ActivityType, CommentableType } from '~/generated/graphql'; import { useIsMatchingLocation } from '../hooks/useIsMatchingLocation'; @@ -21,6 +23,7 @@ import { useIsMatchingLocation } from '../hooks/useIsMatchingLocation'; export function AuthAutoRouter() { const navigate = useNavigate(); const isMatchingLocation = useIsMatchingLocation(); + const { enqueueSnackBar } = useSnackBar(); const [previousLocation, setPreviousLocation] = useState(''); @@ -32,6 +35,8 @@ export function AuthAutoRouter() { const eventTracker = useEventTracker(); + const [workspaceFromInviteHashQuery] = + useGetWorkspaceFromInviteHashLazyQuery(); const { addToCommandMenu, setToIntitialCommandMenu } = useCommandMenu(); const openCreateActivity = useOpenCreateActivityDrawer(); @@ -57,6 +62,13 @@ export function AuthAutoRouter() { isMatchingLocation(AppPath.CreateWorkspace) || isMatchingLocation(AppPath.CreateProfile); + function navigateToSignUp() { + enqueueSnackBar('workspace does not exist', { + variant: 'error', + }); + navigate(AppPath.SignUp); + } + if ( onboardingStatus === OnboardingStatus.OngoingUserCreation && !isMachinOngoingUserCreationRoute @@ -77,6 +89,24 @@ export function AuthAutoRouter() { isMatchingOnboardingRoute ) { navigate('/'); + } else if (isMatchingLocation(AppPath.Invite)) { + const inviteHash = + matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname) + ?.params.workspaceInviteHash || ''; + + workspaceFromInviteHashQuery({ + variables: { + inviteHash, + }, + onCompleted: (data) => { + if (!data.findWorkspaceFromInviteHash) { + navigateToSignUp(); + } + }, + onError: (_) => { + navigateToSignUp(); + }, + }); } switch (true) { @@ -222,6 +252,8 @@ export function AuthAutoRouter() { location, previousLocation, eventTracker, + workspaceFromInviteHashQuery, + enqueueSnackBar, addToCommandMenu, openCreateActivity, setToIntitialCommandMenu, diff --git a/server/src/core/auth/auth.resolver.spec.ts b/server/src/core/auth/auth.resolver.spec.ts index f2e06f421..1cfe7e4d6 100644 --- a/server/src/core/auth/auth.resolver.spec.ts +++ b/server/src/core/auth/auth.resolver.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspaceService } from 'src/core/workspace/services/workspace.service'; + import { AuthResolver } from './auth.resolver'; import { TokenService } from './services/token.service'; @@ -12,6 +14,10 @@ describe('AuthResolver', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthResolver, + { + provide: WorkspaceService, + useValue: {}, + }, { provide: AuthService, useValue: {}, diff --git a/server/src/core/auth/auth.resolver.ts b/server/src/core/auth/auth.resolver.ts index d90fde61e..3f2add630 100644 --- a/server/src/core/auth/auth.resolver.ts +++ b/server/src/core/auth/auth.resolver.ts @@ -15,6 +15,8 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { AuthUser } from 'src/decorators/auth-user.decorator'; import { assert } from 'src/utils/assert'; import { User } from 'src/core/@generated/user/user.model'; +import { Workspace } from 'src/core/@generated/workspace/workspace.model'; +import { WorkspaceService } from 'src/core/workspace/services/workspace.service'; import { AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -34,6 +36,7 @@ import { ImpersonateInput } from './dto/impersonate.input'; @Resolver() export class AuthResolver { constructor( + private workspaceService: WorkspaceService, private authService: AuthService, private tokenService: TokenService, ) {} @@ -57,6 +60,17 @@ export class AuthResolver { ); } + @Query(() => Workspace) + async findWorkspaceFromInviteHash( + @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, + ) { + return await this.workspaceService.findFirst({ + where: { + inviteHash: workspaceInviteHashValidInput.inviteHash, + }, + }); + } + @Mutation(() => LoginToken) async challenge(@Args() challengeInput: ChallengeInput): Promise { const user = await this.authService.challenge(challengeInput);