feat: onboarding & profile edition (#507)
* feat: wip onboarding * fix: generate graphql front * wip: onboarding * feat: login/register and edit profile * fix: unused import * fix: test * Use DEBUG_MODE instead of STAGE and mute typescript depth exceed errors * Fix seeds * Fix onboarding when coming from google * Fix * Fix lint * Fix ci * Fix tests --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -1,9 +1,16 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
|
||||
import {
|
||||
ApolloLink,
|
||||
InMemoryCache,
|
||||
NormalizedCacheObject,
|
||||
} from '@apollo/client';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { isMockModeState } from '@/auth/states/isMockModeState';
|
||||
import { tokenPairState } from '@/auth/states/tokenPairState';
|
||||
import { CommentThreadTarget } from '~/generated/graphql';
|
||||
import { mockedCompaniesData } from '~/testing/mock-data/companies';
|
||||
import { mockedUsersData } from '~/testing/mock-data/users';
|
||||
|
||||
import { ApolloFactory } from '../services/apollo.factory';
|
||||
|
||||
@ -11,8 +18,22 @@ export function useApolloFactory() {
|
||||
const apolloRef = useRef<ApolloFactory<NormalizedCacheObject> | null>(null);
|
||||
|
||||
const [tokenPair, setTokenPair] = useRecoilState(tokenPairState);
|
||||
const [isMockMode] = useRecoilState(isMockModeState);
|
||||
|
||||
const apolloClient = useMemo(() => {
|
||||
const mockLink = new ApolloLink((operation, forward) => {
|
||||
return forward(operation).map((response) => {
|
||||
if (operation.operationName === 'GetCompanies') {
|
||||
return { data: { companies: mockedCompaniesData } };
|
||||
}
|
||||
if (operation.operationName === 'GetCurrentUser') {
|
||||
return { data: { currentUser: mockedUsersData[0] } };
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
});
|
||||
|
||||
apolloRef.current = new ApolloFactory({
|
||||
uri: `${process.env.REACT_APP_API_URL}`,
|
||||
cache: new InMemoryCache({
|
||||
@ -42,10 +63,11 @@ export function useApolloFactory() {
|
||||
onUnauthenticatedError() {
|
||||
setTokenPair(null);
|
||||
},
|
||||
extraLinks: isMockMode ? [mockLink] : [],
|
||||
});
|
||||
|
||||
return apolloRef.current.getClient();
|
||||
}, [setTokenPair]);
|
||||
}, [isMockMode, setTokenPair]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apolloRef.current) {
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ApolloClient,
|
||||
ApolloLink,
|
||||
createHttpLink,
|
||||
from,
|
||||
InMemoryCache,
|
||||
} from '@apollo/client';
|
||||
|
||||
import { mockedCompaniesData } from '~/testing/mock-data/companies';
|
||||
import { mockedUsersData } from '~/testing/mock-data/users';
|
||||
|
||||
export default function useApolloMocked() {
|
||||
const mockedClient = useMemo(() => {
|
||||
const apiLink = createHttpLink({
|
||||
uri: `${process.env.REACT_APP_API_URL}`,
|
||||
});
|
||||
|
||||
const mockLink = new ApolloLink((operation, forward) => {
|
||||
return forward(operation).map((response) => {
|
||||
if (operation.operationName === 'GetCompanies') {
|
||||
return { data: { companies: mockedCompaniesData } };
|
||||
}
|
||||
if (operation.operationName === 'GetCurrentUser') {
|
||||
return { data: { users: [mockedUsersData[0]] } };
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
});
|
||||
|
||||
return new ApolloClient({
|
||||
link: from([mockLink, apiLink]),
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
query: {
|
||||
fetchPolicy: 'cache-first',
|
||||
},
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return mockedClient;
|
||||
}
|
||||
@ -11,31 +11,24 @@ import { GraphQLErrors } from '@apollo/client/errors';
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { onError } from '@apollo/client/link/error';
|
||||
import { RetryLink } from '@apollo/client/link/retry';
|
||||
import { Observable } from '@apollo/client/utilities';
|
||||
|
||||
import { renewToken } from '@/auth/services/AuthService';
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
|
||||
import { loggerLink } from '../../utils/apollo-logger';
|
||||
import { assertNotNull } from '../../utils/assert';
|
||||
import { promiseToObservable } from '../../utils/promise-to-observable';
|
||||
import { ApolloManager } from '../interfaces/apolloManager.interface';
|
||||
|
||||
const logger = loggerLink(() => 'Twenty');
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: (() => void)[] = [];
|
||||
|
||||
const resolvePendingRequests = () => {
|
||||
pendingRequests.map((callback) => callback());
|
||||
pendingRequests = [];
|
||||
};
|
||||
|
||||
export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
|
||||
onError?: (err: GraphQLErrors | undefined) => void;
|
||||
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
|
||||
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
|
||||
onUnauthenticatedError?: () => void;
|
||||
extraLinks?: ApolloLink[];
|
||||
}
|
||||
|
||||
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
@ -49,6 +42,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
onNetworkError,
|
||||
onTokenPairChange,
|
||||
onUnauthenticatedError,
|
||||
extraLinks,
|
||||
...options
|
||||
} = opts;
|
||||
|
||||
@ -86,38 +80,22 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
for (const graphQLError of graphQLErrors) {
|
||||
switch (graphQLError?.extensions?.code) {
|
||||
case 'UNAUTHENTICATED': {
|
||||
// error code is set to UNAUTHENTICATED
|
||||
// when AuthenticationError thrown in resolver
|
||||
let forward$: Observable<boolean>;
|
||||
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
forward$ = promiseToObservable(
|
||||
renewToken(uri, this.tokenPair)
|
||||
.then((tokens) => {
|
||||
onTokenPairChange?.(tokens);
|
||||
resolvePendingRequests();
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
pendingRequests = [];
|
||||
onUnauthenticatedError?.();
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
isRefreshing = false;
|
||||
}),
|
||||
).filter((value) => Boolean(value));
|
||||
} else {
|
||||
// Will only emit once the Promise is resolved
|
||||
forward$ = promiseToObservable(
|
||||
new Promise<boolean>((resolve) => {
|
||||
pendingRequests.push(() => resolve(true));
|
||||
}),
|
||||
);
|
||||
renewToken(uri, this.tokenPair)
|
||||
.then((tokens) => {
|
||||
onTokenPairChange?.(tokens);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
onUnauthenticatedError?.();
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
isRefreshing = false;
|
||||
});
|
||||
}
|
||||
|
||||
return forward$.flatMap(() => forward(operation));
|
||||
return forward(operation);
|
||||
}
|
||||
default:
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@ -148,6 +126,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
[
|
||||
errorLink,
|
||||
authLink,
|
||||
...(extraLinks ? extraLinks : []),
|
||||
// Only show logger in dev mode
|
||||
process.env.NODE_ENV !== 'production' ? logger : null,
|
||||
retryLink,
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useIsLogged } from '../hooks/useIsLogged';
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const FadeInStyle = styled.div`
|
||||
animation: ${fadeIn} 1s forwards;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
export function RequireAuth({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isLogged = useIsLogged();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogged) {
|
||||
navigate('/auth');
|
||||
}
|
||||
}, [isLogged, navigate]);
|
||||
|
||||
if (!isLogged) {
|
||||
return (
|
||||
<EmptyContainer>
|
||||
<FadeInStyle>
|
||||
Please hold on a moment, we're directing you to our login page...
|
||||
</FadeInStyle>
|
||||
</EmptyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
83
front/src/modules/auth/components/RequireOnboarded.tsx
Normal file
83
front/src/modules/auth/components/RequireOnboarded.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
|
||||
|
||||
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
|
||||
import { OnboardingStatus } from '../utils/getOnboardingStatus';
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const FadeInStyle = styled.div`
|
||||
animation: ${fadeIn} 1s forwards;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
export function RequireOnboarded({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setCaptureHotkeyTypeInFocus] = useRecoilState(
|
||||
captureHotkeyTypeInFocusState,
|
||||
);
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingStatus === OnboardingStatus.OngoingUserCreation) {
|
||||
navigate('/auth');
|
||||
} else if (onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation) {
|
||||
navigate('/auth/create/workspace');
|
||||
} else if (onboardingStatus === OnboardingStatus.OngoingProfileCreation) {
|
||||
navigate('/auth/create/profile');
|
||||
}
|
||||
}, [onboardingStatus, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingStatus === OnboardingStatus.Completed) {
|
||||
setCaptureHotkeyTypeInFocus(false);
|
||||
}
|
||||
}, [setCaptureHotkeyTypeInFocus, onboardingStatus]);
|
||||
|
||||
if (onboardingStatus !== OnboardingStatus.Completed) {
|
||||
return (
|
||||
<EmptyContainer>
|
||||
<FadeInStyle>
|
||||
{onboardingStatus === OnboardingStatus.OngoingUserCreation && (
|
||||
<div>
|
||||
Please hold on a moment, we're directing you to our login page...
|
||||
</div>
|
||||
)}
|
||||
{onboardingStatus !== OnboardingStatus.OngoingUserCreation && (
|
||||
<div>
|
||||
Please hold on a moment, we're directing you to our onboarding
|
||||
flow...
|
||||
</div>
|
||||
)}
|
||||
</FadeInStyle>
|
||||
</EmptyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useIsLogged } from '../hooks/useIsLogged';
|
||||
import { useOnboardingStatus } from '../hooks/useOnboardingStatus';
|
||||
import { OnboardingStatus } from '../utils/getOnboardingStatus';
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
align-items: center;
|
||||
@ -27,22 +28,22 @@ const FadeInStyle = styled.div`
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
export function RequireNotAuth({
|
||||
export function RequireOnboarding({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isLogged = useIsLogged();
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLogged) {
|
||||
if (onboardingStatus === OnboardingStatus.Completed) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isLogged, navigate]);
|
||||
}, [navigate, onboardingStatus]);
|
||||
|
||||
if (isLogged) {
|
||||
if (onboardingStatus === OnboardingStatus.Completed) {
|
||||
return (
|
||||
<EmptyContainer>
|
||||
<FadeInStyle>
|
||||
@ -1,13 +0,0 @@
|
||||
import jwt from 'jwt-decode';
|
||||
|
||||
import { AuthTokenPair, useGetCurrentUserQuery } from '~/generated/graphql';
|
||||
|
||||
export function useFetchCurrentUser(tokenPair: AuthTokenPair | null) {
|
||||
const userId = tokenPair?.accessToken.token
|
||||
? jwt<{ sub: string }>(tokenPair.accessToken.token).sub
|
||||
: null;
|
||||
const { data } = useGetCurrentUserQuery({
|
||||
variables: { uuid: userId },
|
||||
});
|
||||
return data?.users?.[0];
|
||||
}
|
||||
20
front/src/modules/auth/hooks/useOnboardingStatus.ts
Normal file
20
front/src/modules/auth/hooks/useOnboardingStatus.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useIsLogged } from '../hooks/useIsLogged';
|
||||
import { currentUserState } from '../states/currentUserState';
|
||||
import {
|
||||
getOnboardingStatus,
|
||||
OnboardingStatus,
|
||||
} from '../utils/getOnboardingStatus';
|
||||
|
||||
export function useOnboardingStatus(): OnboardingStatus {
|
||||
const [currentUser] = useRecoilState(currentUserState);
|
||||
const isLoggedIn = useIsLogged();
|
||||
const onboardingStatus = useMemo(
|
||||
() => getOnboardingStatus(isLoggedIn, currentUser),
|
||||
[currentUser, isLoggedIn],
|
||||
);
|
||||
|
||||
return onboardingStatus;
|
||||
}
|
||||
@ -1,14 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { User, Workspace, WorkspaceMember } from '~/generated/graphql';
|
||||
import { GetCurrentUserQuery } from '~/generated/graphql';
|
||||
|
||||
type CurrentUser = Pick<User, 'id' | 'email' | 'displayName'> & {
|
||||
workspaceMember?:
|
||||
| (Pick<WorkspaceMember, 'id'> & {
|
||||
workspace: Pick<Workspace, 'id' | 'displayName' | 'logo'>;
|
||||
})
|
||||
| null;
|
||||
};
|
||||
export type CurrentUser = GetCurrentUserQuery['currentUser'];
|
||||
|
||||
export const currentUserState = atom<CurrentUser | null>({
|
||||
key: 'currentUserState',
|
||||
|
||||
25
front/src/modules/auth/utils/getOnboardingStatus.ts
Normal file
25
front/src/modules/auth/utils/getOnboardingStatus.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { CurrentUser } from '../states/currentUserState';
|
||||
|
||||
export enum OnboardingStatus {
|
||||
OngoingUserCreation = 'ongoing_user_creation',
|
||||
OngoingWorkspaceCreation = 'ongoing_workspace_creation',
|
||||
OngoingProfileCreation = 'ongoing_profile_creation',
|
||||
Completed = 'completed',
|
||||
}
|
||||
|
||||
export function getOnboardingStatus(
|
||||
isLoggedIn: boolean,
|
||||
currentUser: CurrentUser | null,
|
||||
) {
|
||||
if (!isLoggedIn || !currentUser) {
|
||||
return OnboardingStatus.OngoingUserCreation;
|
||||
}
|
||||
if (!currentUser.workspaceMember?.workspace.displayName) {
|
||||
return OnboardingStatus.OngoingWorkspaceCreation;
|
||||
}
|
||||
if (!currentUser.firstName || !currentUser.lastName) {
|
||||
return OnboardingStatus.OngoingProfileCreation;
|
||||
}
|
||||
|
||||
return OnboardingStatus.Completed;
|
||||
}
|
||||
@ -26,8 +26,6 @@ export function BoardActionBarButtonDeletePipelineProgress() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('boardItems', boardItems);
|
||||
|
||||
setBoardItems(
|
||||
Object.fromEntries(
|
||||
Object.entries(boardItems).filter(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { debounce } from 'lodash';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
|
||||
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { debounce } from 'lodash';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
|
||||
@ -12,7 +12,11 @@ import {
|
||||
|
||||
type SelectStringKeys<T> = NonNullable<
|
||||
{
|
||||
[K in keyof T]: T[K] extends string ? K : never;
|
||||
[K in keyof T]: K extends '__typename'
|
||||
? never
|
||||
: T[K] extends string | undefined | null
|
||||
? K
|
||||
: never;
|
||||
}[keyof T]
|
||||
>;
|
||||
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { gql } from '@apollo/client';
|
||||
export * from './update';
|
||||
|
||||
export const GET_CURRENT_USER = gql`
|
||||
query GetCurrentUser($uuid: String) {
|
||||
users: findManyUser(where: { id: { equals: $uuid } }) {
|
||||
query GetCurrentUser {
|
||||
currentUser {
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
workspaceMember {
|
||||
id
|
||||
workspace {
|
||||
|
||||
14
front/src/modules/users/services/update.ts
Normal file
14
front/src/modules/users/services/update.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_USER = gql`
|
||||
mutation UpdateUser($data: UserUpdateInput!, $where: UserWhereUniqueInput!) {
|
||||
updateUser(data: $data, where: $where) {
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
1
front/src/modules/workspace/services/index.ts
Normal file
1
front/src/modules/workspace/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './update';
|
||||
12
front/src/modules/workspace/services/update.ts
Normal file
12
front/src/modules/workspace/services/update.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_WORKSPACE = gql`
|
||||
mutation UpdateWorkspace($data: WorkspaceUpdateInput!) {
|
||||
updateWorkspace(data: $data) {
|
||||
id
|
||||
domainName
|
||||
displayName
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user