Add support chat (#1066)

* Add support chat

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Refactor the chat logic

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add HMAC signing

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Update the button styles

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Update the button styles

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Refactor the chat logic

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Fix the chat not loading

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Fix the chat not loading

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add requested changes

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add requested changes

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add requested changes

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add requested changes

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

* Add requested changes

Co-authored-by: v1b3m <vibenjamin6@gmail.com>

---------

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
This commit is contained in:
gitstart-twenty
2023-08-05 07:52:59 +08:00
committed by GitHub
parent 5e6351e099
commit 57c465176a
17 changed files with 239 additions and 11 deletions

View File

@ -548,6 +548,7 @@ export type ClientConfig = {
authProviders: AuthProviders;
debugMode: Scalars['Boolean'];
signInPrefilled: Scalars['Boolean'];
supportChat: SupportChat;
telemetry: Telemetry;
};
@ -1906,6 +1907,12 @@ export type StringNullableFilter = {
startsWith?: InputMaybe<Scalars['String']>;
};
export type SupportChat = {
__typename?: 'SupportChat';
supportDriver: Scalars['String'];
supportFrontendKey?: Maybe<Scalars['String']>;
};
export type Telemetry = {
__typename?: 'Telemetry';
anonymizationEnabled: Scalars['Boolean'];
@ -1935,6 +1942,7 @@ export type User = {
phoneNumber?: Maybe<Scalars['String']>;
settings: UserSettings;
settingsId: Scalars['String'];
supportHMACKey?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
workspaceMember?: Maybe<WorkspaceMember>;
};
@ -2448,7 +2456,7 @@ export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __ty
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean } } };
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, supportChat: { __typename?: 'SupportChat', supportDriver: string, supportFrontendKey?: string | null } } };
export type GetCompaniesQueryVariables = Exact<{
orderBy?: InputMaybe<Array<CompanyOrderByWithRelationInput> | CompanyOrderByWithRelationInput>;
@ -2677,7 +2685,7 @@ export type SearchActivityQuery = { __typename?: 'Query', searchResults: Array<{
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, canImpersonate: boolean, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, canImpersonate: boolean, supportHMACKey?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } };
export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>;
@ -3648,6 +3656,10 @@ export const GetClientConfigDocument = gql`
enabled
anonymizationEnabled
}
supportChat {
supportDriver
supportFrontendKey
}
}
}
`;
@ -4895,6 +4907,7 @@ export const GetCurrentUserDocument = gql`
locale
colorScheme
}
supportHMACKey
}
}
`;

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { telemetryState } from '@/client-config/states/telemetryState';
import { useGetClientConfigQuery } from '~/generated/graphql';
@ -15,6 +16,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
const [, setSignInPrefilled] = useRecoilState(isSignInPrefilledState);
const [, setTelemetry] = useRecoilState(telemetryState);
const [isLoading, setIsLoading] = useState(true);
const setSupportChat = useSetRecoilState(supportChatState);
const { data, loading } = useGetClientConfigQuery();
@ -31,6 +33,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
setDebugMode(data?.clientConfig.debugMode);
setSignInPrefilled(data?.clientConfig.signInPrefilled);
setTelemetry(data?.clientConfig.telemetry);
setSupportChat(data?.clientConfig.supportChat);
}
}, [
data,
@ -40,6 +43,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
setTelemetry,
setIsLoading,
loading,
setSupportChat,
]);
return isLoading ? <></> : <>{children}</>;

View File

@ -13,6 +13,10 @@ export const GET_CLIENT_CONFIG = gql`
enabled
anonymizationEnabled
}
supportChat {
supportDriver
supportFrontendKey
}
}
}
`;

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil';
import { SupportChat } from '~/generated/graphql';
export const supportChatState = atom<SupportChat>({
key: 'supportChatState',
default: {
supportDriver: 'front',
supportFrontendKey: null,
},
});

3
front/src/modules/types/custom.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare interface Window {
FrontChat?: (method: string, ...args: any[]) => void;
}

View File

@ -2,20 +2,29 @@ import styled from '@emotion/styled';
import NavItemsContainer from './NavItemsContainer';
import NavWorkspaceButton from './NavWorkspaceButton';
import SupportChat from './SupportChat';
type OwnProps = {
children: React.ReactNode;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(2.5)};
width: 100%;
`;
export default function MainNavbar({ children }: OwnProps) {
return (
<StyledContainer>
<NavWorkspaceButton />
<NavItemsContainer>{children}</NavItemsContainer>
<div>
<NavWorkspaceButton />
<NavItemsContainer>{children}</NavItemsContainer>
</div>
<SupportChat />
</StyledContainer>
);
}

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { supportChatState } from '@/client-config/states/supportChatState';
import {
Button,
ButtonSize,
ButtonVariant,
} from '@/ui/button/components/Button';
const StyledButtonContainer = styled.div`
display: flex;
`;
const StyledQuestionMark = styled.div`
align-items: center;
border-radius: 50%;
border-style: solid;
border-width: ${({ theme }) => theme.spacing(0.25)};
display: flex;
height: ${({ theme }) => theme.spacing(3.5)};
justify-content: center;
margin-right: ${({ theme }) => theme.spacing(1)};
width: ${({ theme }) => theme.spacing(3.5)};
`;
// insert a script tag into the DOM right before the closing body tag
function insertScript({
src,
innerHTML,
onLoad,
}: {
src?: string;
innerHTML?: string;
onLoad?: (...args: any[]) => void;
}) {
const script = document.createElement('script');
if (src) script.src = src;
if (innerHTML) script.innerHTML = innerHTML;
if (onLoad) script.onload = onLoad;
document.body.appendChild(script);
}
function configureFront(chatId: string) {
const url = 'https://chat-assets.frontapp.com/v1/chat.bundle.js';
// check if Front Chat script is already loaded
const script = document.querySelector(`script[src="${url}"]`);
if (!script) {
// insert script and initialize Front Chat when it loads
insertScript({
src: url,
onLoad: () => {
window.FrontChat?.('init', {
chatId,
useDefaultLauncher: false,
});
},
});
}
}
export default function SupportChat() {
const user = useRecoilValue(currentUserState);
const supportChatConfig = useRecoilValue(supportChatState);
const [isFrontChatLoaded, setIsFrontChatLoaded] = useState(false);
const [isChatShowing, setIsChatShowing] = useState(false);
useEffect(() => {
if (
supportChatConfig?.supportDriver === 'front' &&
supportChatConfig.supportFrontendKey &&
!isFrontChatLoaded
) {
configureFront(supportChatConfig.supportFrontendKey);
setIsFrontChatLoaded(true);
}
if (user?.email && isFrontChatLoaded) {
window.FrontChat?.('identity', {
email: user.email,
name: user.displayName,
userHash: user?.supportHMACKey,
});
}
}, [
isFrontChatLoaded,
supportChatConfig?.supportDriver,
supportChatConfig.supportFrontendKey,
user?.displayName,
user?.email,
user?.supportHMACKey,
]);
function handleSupportClick() {
if (supportChatConfig?.supportDriver === 'front') {
const action = isChatShowing ? 'hide' : 'show';
setIsChatShowing(!isChatShowing);
window.FrontChat?.(action);
}
}
return isFrontChatLoaded ? (
<StyledButtonContainer>
<Button
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
title="Support"
icon={<StyledQuestionMark>?</StyledQuestionMark>}
onClick={handleSupportClick}
/>
</StyledButtonContainer>
) : null;
}

View File

@ -27,6 +27,7 @@ export const GET_CURRENT_USER = gql`
locale
colorScheme
}
supportHMACKey
}
}
`;

View File

@ -207,6 +207,10 @@ export const graphqlMocks = [
debugMode: false,
authProviders: { google: true, password: true, magicLink: false },
telemetry: { enabled: false, anonymizationEnabled: true },
supportChat: {
supportDriver: 'front',
supportFrontendKey: null,
},
},
}),
);

View File

@ -17,4 +17,7 @@ SIGN_IN_PREFILLED=true
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
# AUTH_GOOGLE_ENABLED=false
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front
# SUPPORT_HMAC_KEY=replace_me_with_a_random_string
# SUPPORT_FRONTEND_KEY=replace_me_with_a_random_string

View File

@ -21,6 +21,15 @@ class Telemetry {
anonymizationEnabled: boolean;
}
@ObjectType()
class SupportChat {
@Field(() => String)
supportDriver: string;
@Field(() => String, { nullable: true })
supportFrontendKey: string | null;
}
@ObjectType()
export class ClientConfig {
@Field(() => AuthProviders, { nullable: false })
@ -34,4 +43,7 @@ export class ClientConfig {
@Field(() => Boolean)
debugMode: boolean;
@Field(() => SupportChat)
supportChat: SupportChat;
}

View File

@ -23,6 +23,10 @@ export class ClientConfigResolver {
},
signInPrefilled: this.environmentService.isSignInPrefilled(),
debugMode: this.environmentService.isDebugMode(),
supportChat: {
supportDriver: this.environmentService.getSupportDriver(),
supportFrontendKey: this.environmentService.getSupportFrontendKey(),
},
};
return Promise.resolve(clientConfig);

View File

@ -0,0 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { User } from 'src/core/@generated/user/user.model';
@ObjectType()
export class UserWithHMACKey extends User {
@Field(() => String, { nullable: true })
supportHMACKey: string | null;
}

View File

@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { FileModule } from 'src/core/file/file.module';
import { WorkspaceModule } from 'src/core/workspace/workspace.module';
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
@Module({
imports: [FileModule, WorkspaceModule],
imports: [FileModule, WorkspaceModule, EnvironmentModule],
providers: [UserService, UserResolver],
exports: [UserService],
})

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AbilityFactory } from 'src/ability/ability.factory';
import { FileUploadService } from 'src/core/file/services/file-upload.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
@ -25,6 +26,10 @@ describe('UserResolver', () => {
provide: FileUploadService,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
],
}).compile();

View File

@ -8,6 +8,8 @@ import {
} from '@nestjs/graphql';
import { UseFilters, UseGuards } from '@nestjs/common';
import crypto from 'crypto';
import { accessibleBy } from '@casl/prisma';
import { Prisma, Workspace } from '@prisma/client';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
@ -37,32 +39,48 @@ import { UpdateOneUserArgs } from 'src/core/@generated/user/update-one-user.args
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { FileUploadService } from 'src/core/file/services/file-upload.service';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { UserService } from './user.service';
import { UserWithHMACKey } from './dto/user-with-HMAC';
function getHMACKey(email?: string, key?: string | null) {
if (!email || !key) return null;
const hmac = crypto.createHmac('sha256', key);
return hmac.update(email).digest('hex');
}
@UseGuards(JwtAuthGuard)
@Resolver(() => User)
export class UserResolver {
constructor(
private readonly userService: UserService,
private readonly fileUploadService: FileUploadService,
private environmentService: EnvironmentService,
) {}
@Query(() => User)
@Query(() => UserWithHMACKey)
async currentUser(
@AuthUser() { id }: User,
@AuthUser() { id, email }: User,
@PrismaSelector({ modelName: 'User' })
prismaSelect: PrismaSelect<'User'>,
) {
const key = this.environmentService.getSupportHMACKey();
const select = prismaSelect.value;
delete select['supportHMACKey'];
const user = await this.userService.findUnique({
where: {
id,
},
select: prismaSelect.value,
select,
});
assert(user, 'User not found');
return user;
return { ...user, supportHMACKey: getHMACKey(email, key) };
}
@UseFilters(ExceptionFilter)

View File

@ -101,4 +101,16 @@ export class EnvironmentService {
this.configService.get<string>('STORAGE_LOCAL_PATH') ?? '.local-storage'
);
}
getSupportDriver(): string {
return this.configService.get<string>('SUPPORT_DRIVER') ?? 'front';
}
getSupportFrontendKey(): string | null {
return this.configService.get<string>('SUPPORT_FRONTEND_KEY') ?? null;
}
getSupportHMACKey(): string | null {
return this.configService.get<string>('SUPPORT_HMAC_KEY') ?? null;
}
}