feat: rewrite auth (#364)

* feat: wip rewrite auth

* feat: restructure folders and fix stories and tests

* feat: remove auth provider and fix tests
This commit is contained in:
Jérémy M
2023-06-23 17:49:50 +02:00
committed by GitHub
parent 1c7980b270
commit c6708b2c1f
54 changed files with 1268 additions and 584 deletions

1
front/.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.6.0

View File

@@ -19,6 +19,7 @@
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"graphql": "^16.6.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.26",
"luxon": "^3.3.0",
@@ -107,6 +108,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/js-cookie": "^3.0.3",
"@types/luxon": "^3.3.0",
"@types/react-datepicker": "^4.11.2",
"@types/scroll-into-view": "^1.16.0",

View File

@@ -1,27 +1,19 @@
import { StrictMode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ApolloProvider } from '@apollo/client';
import { useRecoilState } from 'recoil';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { AppThemeProvider } from './providers/AppThemeProvider';
import { AuthProvider } from './providers/AuthProvider';
import { apiClient, mockClient } from './apollo';
import { ApolloProvider } from './providers/apollo/ApolloProvider';
import { AppThemeProvider } from './providers/theme/AppThemeProvider';
import { App } from './App';
export function AppWrapper() {
const [isMockMode] = useRecoilState(isMockModeState);
return (
<ApolloProvider client={isMockMode ? mockClient : apiClient}>
<ApolloProvider>
<BrowserRouter>
<AuthProvider>
<AppThemeProvider>
<StrictMode>
<App />
</StrictMode>
</AppThemeProvider>
</AuthProvider>
<AppThemeProvider>
<StrictMode>
<App />
</StrictMode>
</AppThemeProvider>
</BrowserRouter>
</ApolloProvider>
);

View File

@@ -1,22 +1,34 @@
import { MemoryRouter } from 'react-router-dom';
import { ApolloProvider } from '@apollo/client';
import { ThemeProvider } from '@emotion/react';
import { RecoilRoot } from 'recoil';
import { RecoilRoot, useRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { isAuthenticatingState } from '@/auth/states/isAuthenticatingState';
import { darkTheme } from '@/ui/layout/styles/themes';
import { App } from '~/App';
import { AuthProvider } from '~/providers/AuthProvider';
import { FullHeightStorybookLayout } from '~/testing/FullHeightStorybookLayout';
import { mockedUsersData } from '~/testing/mock-data/users';
import { mockedClient } from '~/testing/mockedClient';
export const render = () => renderWithDarkMode(false);
const MockedAuth: React.FC<React.PropsWithChildren> = ({ children }) => {
const [, setCurrentUser] = useRecoilState(currentUserState);
const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState);
setCurrentUser(mockedUsersData[0]);
setIsAuthenticating(false);
return <>{children}</>;
};
export const renderWithDarkMode = (forceDarkMode?: boolean) => {
const AppInStoryBook = (
<FullHeightStorybookLayout>
<AuthProvider>
<MockedAuth>
<App />
</AuthProvider>
</MockedAuth>
</FullHeightStorybookLayout>
);

View File

@@ -1,125 +0,0 @@
import {
ApolloClient,
ApolloLink,
createHttpLink,
from,
InMemoryCache,
Observable,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RestLink } from 'apollo-link-rest';
import { CommentThreadTarget } from './generated/graphql';
import { getTokensFromRefreshToken } from './modules/auth/services/AuthService';
import { mockedCompaniesData } from './testing/mock-data/companies';
import { mockedUsersData } from './testing/mock-data/users';
const apiLink = createHttpLink({
uri: `${process.env.REACT_APP_API_URL}`,
});
const withAuthHeadersLink = setContext((_, { headers }) => {
const token = localStorage.getItem('accessToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
return new Observable((observer) => {
(async () => {
try {
await getTokensFromRefreshToken();
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${localStorage.getItem(
'accessToken',
)}`,
},
});
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
};
forward(operation).subscribe(subscriber);
} catch (error) {
observer.error(error);
}
})();
});
}
}
}
});
export const apiClient = new ApolloClient({
link: from([errorLink, withAuthHeadersLink, apiLink]),
cache: new InMemoryCache({
typePolicies: {
CommentThread: {
fields: {
commentThreadTargets: {
merge(
existing: CommentThreadTarget[] = [],
incoming: CommentThreadTarget[],
) {
return [...incoming];
},
},
},
},
},
}),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
});
const authLink = new RestLink({
uri: `${process.env.REACT_APP_AUTH_URL}`,
credentials: 'same-origin',
});
export const authClient = new ApolloClient({
link: authLink,
cache: new InMemoryCache(),
});
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;
});
});
export const mockClient = new ApolloClient({
link: from([mockLink, apiLink]),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
});

View File

@@ -22,6 +22,23 @@ export type AffectedRows = {
count: Scalars['Int'];
};
export type AuthToken = {
__typename?: 'AuthToken';
expiresAt: Scalars['DateTime'];
token: Scalars['String'];
};
export type AuthTokenPair = {
__typename?: 'AuthTokenPair';
accessToken: AuthToken;
refreshToken: AuthToken;
};
export type AuthTokens = {
__typename?: 'AuthTokens';
tokens: AuthTokenPair;
};
export type BoolFieldUpdateOperationsInput = {
set?: InputMaybe<Scalars['Boolean']>;
};
@@ -681,8 +698,14 @@ export type JsonNullableFilter = {
string_starts_with?: InputMaybe<Scalars['String']>;
};
export type LoginToken = {
__typename?: 'LoginToken';
loginToken: AuthToken;
};
export type Mutation = {
__typename?: 'Mutation';
challenge: LoginToken;
createOneComment: Comment;
createOneCommentThread: CommentThread;
createOneCompany: Company;
@@ -691,10 +714,18 @@ export type Mutation = {
deleteManyCompany: AffectedRows;
deleteManyPerson: AffectedRows;
deleteManyPipelineProgress: AffectedRows;
renewToken: AuthTokens;
updateOneCommentThread: CommentThread;
updateOneCompany?: Maybe<Company>;
updateOnePerson?: Maybe<Person>;
updateOnePipelineProgress?: Maybe<PipelineProgress>;
verify: Verify;
};
export type MutationChallengeArgs = {
email: Scalars['String'];
password: Scalars['String'];
};
@@ -738,6 +769,11 @@ export type MutationDeleteManyPipelineProgressArgs = {
};
export type MutationRenewTokenArgs = {
refreshToken: Scalars['String'];
};
export type MutationUpdateOneCommentThreadArgs = {
data: CommentThreadUpdateInput;
where: CommentThreadWhereUniqueInput;
@@ -761,6 +797,11 @@ export type MutationUpdateOnePipelineProgressArgs = {
where: PipelineProgressWhereUniqueInput;
};
export type MutationVerifyArgs = {
loginToken: Scalars['String'];
};
export type NestedBoolFilter = {
equals?: InputMaybe<Scalars['Boolean']>;
not?: InputMaybe<NestedBoolFilter>;
@@ -1489,6 +1530,12 @@ export type UserWhereUniqueInput = {
id?: InputMaybe<Scalars['String']>;
};
export type Verify = {
__typename?: 'Verify';
tokens: AuthTokenPair;
user: User;
};
export type Workspace = {
__typename?: 'Workspace';
commentThreads?: Maybe<Array<CommentThread>>;
@@ -1519,6 +1566,28 @@ export type WorkspaceMember = {
workspace: Workspace;
};
export type ChallengeMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
}>;
export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', expiresAt: string, token: string } } };
export type VerifyMutationVariables = Exact<{
loginToken: Scalars['String'];
}>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName: string, displayName: string, logo?: string | null } } | null }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type RenewTokenMutationVariables = Exact<{
refreshToken: Scalars['String'];
}>;
export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', expiresAt: string, token: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type CreateCommentMutationVariables = Exact<{
commentId: Scalars['String'];
commentText: Scalars['String'];
@@ -1730,6 +1799,141 @@ export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>;
export type GetUsersQuery = { __typename?: 'Query', findManyUser: Array<{ __typename?: 'User', id: string, email: string, displayName: string }> };
export const ChallengeDocument = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
loginToken {
expiresAt
token
}
}
}
`;
export type ChallengeMutationFn = Apollo.MutationFunction<ChallengeMutation, ChallengeMutationVariables>;
/**
* __useChallengeMutation__
*
* To run a mutation, you first call `useChallengeMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useChallengeMutation` 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 [challengeMutation, { data, loading, error }] = useChallengeMutation({
* variables: {
* email: // value for 'email'
* password: // value for 'password'
* },
* });
*/
export function useChallengeMutation(baseOptions?: Apollo.MutationHookOptions<ChallengeMutation, ChallengeMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ChallengeMutation, ChallengeMutationVariables>(ChallengeDocument, options);
}
export type ChallengeMutationHookResult = ReturnType<typeof useChallengeMutation>;
export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>;
export type ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>;
export const VerifyDocument = gql`
mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) {
user {
id
email
displayName
workspaceMember {
id
workspace {
id
domainName
displayName
logo
}
}
}
tokens {
accessToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
}
`;
export type VerifyMutationFn = Apollo.MutationFunction<VerifyMutation, VerifyMutationVariables>;
/**
* __useVerifyMutation__
*
* To run a mutation, you first call `useVerifyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useVerifyMutation` 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 [verifyMutation, { data, loading, error }] = useVerifyMutation({
* variables: {
* loginToken: // value for 'loginToken'
* },
* });
*/
export function useVerifyMutation(baseOptions?: Apollo.MutationHookOptions<VerifyMutation, VerifyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<VerifyMutation, VerifyMutationVariables>(VerifyDocument, options);
}
export type VerifyMutationHookResult = ReturnType<typeof useVerifyMutation>;
export type VerifyMutationResult = Apollo.MutationResult<VerifyMutation>;
export type VerifyMutationOptions = Apollo.BaseMutationOptions<VerifyMutation, VerifyMutationVariables>;
export const RenewTokenDocument = gql`
mutation RenewToken($refreshToken: String!) {
renewToken(refreshToken: $refreshToken) {
tokens {
accessToken {
expiresAt
token
}
refreshToken {
token
expiresAt
}
}
}
}
`;
export type RenewTokenMutationFn = Apollo.MutationFunction<RenewTokenMutation, RenewTokenMutationVariables>;
/**
* __useRenewTokenMutation__
*
* To run a mutation, you first call `useRenewTokenMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRenewTokenMutation` 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 [renewTokenMutation, { data, loading, error }] = useRenewTokenMutation({
* variables: {
* refreshToken: // value for 'refreshToken'
* },
* });
*/
export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions<RenewTokenMutation, RenewTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RenewTokenMutation, RenewTokenMutationVariables>(RenewTokenDocument, options);
}
export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>;
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const CreateCommentDocument = gql`
mutation CreateComment($commentId: String!, $commentText: String!, $authorId: String!, $commentThreadId: String!, $createdAt: DateTime!) {
createOneComment(

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { hasAccessToken } from '../services/AuthService';
import { useIsLogged } from '../hooks/useIsLogged';
const EmptyContainer = styled.div`
align-items: center;
@@ -34,13 +34,15 @@ export function RequireAuth({
}): JSX.Element {
const navigate = useNavigate();
const isLogged = useIsLogged();
useEffect(() => {
if (!hasAccessToken()) {
if (!isLogged) {
navigate('/auth');
}
}, [navigate]);
}, [isLogged, navigate]);
if (!hasAccessToken())
if (!isLogged) {
return (
<EmptyContainer>
<FadeInStyle>
@@ -48,5 +50,7 @@ export function RequireAuth({
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { hasAccessToken } from '../services/AuthService';
import { useIsLogged } from '../hooks/useIsLogged';
const EmptyContainer = styled.div`
align-items: center;
@@ -34,13 +34,15 @@ export function RequireNotAuth({
}): JSX.Element {
const navigate = useNavigate();
const isLogged = useIsLogged();
useEffect(() => {
if (hasAccessToken()) {
if (isLogged) {
navigate('/');
}
}, [navigate]);
}, [isLogged, navigate]);
if (hasAccessToken())
if (isLogged) {
return (
<EmptyContainer>
<FadeInStyle>
@@ -48,5 +50,7 @@ export function RequireNotAuth({
</FadeInStyle>
</EmptyContainer>
);
}
return children;
}

View File

@@ -0,0 +1,82 @@
import { useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useChallengeMutation, useVerifyMutation } from '~/generated/graphql';
import { tokenService } from '../services/TokenService';
import { currentUserState } from '../states/currentUserState';
import { isAuthenticatingState } from '../states/isAuthenticatingState';
export function useAuth() {
const [, setCurrentUser] = useRecoilState(currentUserState);
const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState);
const [challenge] = useChallengeMutation();
const [verify] = useVerifyMutation();
const handleChallenge = useCallback(
async (email: string, password: string) => {
const challengeResult = await challenge({
variables: {
email,
password,
},
});
if (challengeResult.errors) {
throw challengeResult.errors;
}
if (!challengeResult.data?.challenge) {
throw new Error('No login token');
}
return challengeResult.data.challenge;
},
[challenge],
);
const handleVerify = useCallback(
async (loginToken: string) => {
const verifyResult = await verify({
variables: { loginToken },
});
if (verifyResult.errors) {
throw verifyResult.errors;
}
if (!verifyResult.data?.verify) {
throw new Error('No verify result');
}
tokenService.setTokenPair(verifyResult.data?.verify.tokens);
setIsAuthenticating(false);
setCurrentUser(verifyResult.data?.verify.user);
return verifyResult.data?.verify;
},
[setCurrentUser, setIsAuthenticating, verify],
);
const handleLogin = useCallback(
async (email: string, password: string) => {
const { loginToken } = await handleChallenge(email, password);
await handleVerify(loginToken.token);
},
[handleChallenge, handleVerify],
);
const handleLogout = useCallback(() => {
tokenService.removeTokenPair();
}, []);
return {
challenge: handleChallenge,
verify: handleVerify,
login: handleLogin,
logout: handleLogout,
};
}

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { cookieStorage } from '@/utils/cookie-storage';
export function useIsLogged(): boolean {
const [value, setValue] = useState<string | undefined>(
cookieStorage.getItem('accessToken'),
);
useEffect(() => {
const updateValue = (newValue: string | undefined) => setValue(newValue);
cookieStorage.addEventListener('accessToken', updateValue);
return () => {
cookieStorage.removeEventListener('accessToken', updateValue);
};
}, []);
return !!value;
}

View File

@@ -1,13 +1,81 @@
import {
ApolloClient,
ApolloLink,
HttpLink,
InMemoryCache,
UriFunction,
} from '@apollo/client';
import jwt from 'jwt-decode';
export const hasAccessToken = () => {
const accessToken = localStorage.getItem('accessToken');
import { cookieStorage } from '@/utils/cookie-storage';
import {
RenewTokenDocument,
RenewTokenMutation,
RenewTokenMutationVariables,
} from '~/generated/graphql';
import { loggerLink } from '~/providers/apollo/logger';
return accessToken ? true : false;
import { tokenService } from './TokenService';
const logger = loggerLink(() => 'Twenty-Refresh');
/**
* Renew token mutation with custom apollo client
* @param uri string | UriFunction | undefined
* @param refreshToken string
* @returns RenewTokenMutation
*/
const renewTokenMutation = async (
uri: string | UriFunction | undefined,
refreshToken: string,
) => {
const httpLink = new HttpLink({ uri });
// Create new client to call refresh token graphql mutation
const client = new ApolloClient({
link: ApolloLink.from([logger, httpLink]),
cache: new InMemoryCache({}),
});
const { data, errors } = await client.mutate<
RenewTokenMutation,
RenewTokenMutationVariables
>({
mutation: RenewTokenDocument,
variables: {
refreshToken: refreshToken,
},
fetchPolicy: 'network-only',
});
if (errors || !data) {
throw new Error('Something went wrong during token renewal');
}
return data;
};
/**
* Renew token and update cookie storage
* @param uri string | UriFunction | undefined
* @returns TokenPair
*/
export const renewToken = async (uri: string | UriFunction | undefined) => {
const tokenPair = tokenService.getTokenPair();
if (!tokenPair) {
throw new Error('Refresh token is not defined');
}
const data = await renewTokenMutation(uri, tokenPair.refreshToken);
tokenService.setTokenPair(data.renewToken.tokens);
return data.renewToken;
};
export const getUserIdFromToken: () => string | null = () => {
const accessToken = localStorage.getItem('accessToken');
const accessToken = cookieStorage.getItem('accessToken');
if (!accessToken) {
return null;
}
@@ -18,76 +86,3 @@ export const getUserIdFromToken: () => string | null = () => {
return null;
}
};
export const hasRefreshToken = () => {
const refreshToken = localStorage.getItem('refreshToken');
return refreshToken ? true : false;
};
export const getTokensFromLoginToken = async (loginToken: string) => {
if (!loginToken) {
return;
}
const response = await fetch(
process.env.REACT_APP_AUTH_URL + '/verify' || '',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ loginToken }),
},
);
if (response.ok) {
const { tokens } = await response.json();
if (!tokens) {
return;
}
localStorage.setItem('accessToken', tokens.accessToken.token);
localStorage.setItem('refreshToken', tokens.refreshToken.token);
} else {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
}
};
export const getTokensFromRefreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
localStorage.removeItem('accessToken');
return;
}
const response = await fetch(
process.env.REACT_APP_AUTH_URL + '/token' || '',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
},
);
if (response.ok) {
const { tokens } = await response.json();
if (!tokens) {
return;
}
localStorage.setItem('accessToken', tokens.accessToken.token);
localStorage.setItem('refreshToken', tokens.refreshToken.token);
} else {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
}
};
export const removeTokens = () => {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
};

View File

@@ -0,0 +1,34 @@
import { cookieStorage } from '@/utils/cookie-storage';
import { AuthTokenPair } from '~/generated/graphql';
export class TokenService {
getTokenPair() {
const accessToken = cookieStorage.getItem('accessToken');
const refreshToken = cookieStorage.getItem('refreshToken');
if (!accessToken || !refreshToken) {
return null;
}
return {
accessToken,
refreshToken,
};
}
setTokenPair(tokens: AuthTokenPair) {
cookieStorage.setItem('accessToken', tokens.accessToken.token, {
secure: true,
});
cookieStorage.setItem('refreshToken', tokens.refreshToken.token, {
secure: true,
});
}
removeTokenPair() {
cookieStorage.removeItem('accessToken');
cookieStorage.removeItem('refreshToken');
}
}
export const tokenService = new TokenService();

View File

@@ -1,109 +1,6 @@
import { waitFor } from '@testing-library/react';
import { cookieStorage } from '@/utils/cookie-storage';
import {
getTokensFromLoginToken,
getTokensFromRefreshToken,
getUserIdFromToken,
hasAccessToken,
hasRefreshToken,
} from '../AuthService';
const validTokensPayload = {
accessToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
expiresAt: '2023-06-17T09:18:02.942Z',
},
refreshToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
expiresAt: '2023-09-15T09:13:02.952Z',
},
};
const mockFetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
if (input.toString().match(/\/auth\/token$/g)) {
const refreshToken = init?.body
? JSON.parse(init.body.toString()).refreshToken
: null;
return new Promise((resolve) => {
resolve(
new Response(
JSON.stringify({
tokens:
refreshToken === 'xxx-valid-refresh' ? validTokensPayload : null,
}),
),
);
});
}
if (input.toString().match(/\/auth\/verify$/g)) {
const loginToken = init?.body
? JSON.parse(init.body.toString()).loginToken
: null;
return new Promise((resolve) => {
resolve(
new Response(
JSON.stringify({
tokens:
loginToken === 'xxx-valid-login' ? validTokensPayload : null,
}),
),
);
});
}
return new Promise(() => new Response());
};
global.fetch = mockFetch;
it('hasAccessToken is true when token is present', () => {
localStorage.setItem('accessToken', 'xxx');
expect(hasAccessToken()).toBe(true);
});
it('hasAccessToken is false when token is not', () => {
expect(hasAccessToken()).toBe(false);
});
it('hasRefreshToken is true when token is present', () => {
localStorage.setItem('refreshToken', 'xxx');
expect(hasRefreshToken()).toBe(true);
});
it('hasRefreshToken is true when token is not', () => {
expect(hasRefreshToken()).toBe(false);
});
it('refreshToken does not refresh the token if refresh token is missing', () => {
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken does not refreh the token if refresh token is invalid', () => {
localStorage.setItem('refreshToken', 'xxx-invalid-refresh');
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken does not refreh the token if refresh token is empty', () => {
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken refreshes the token if refresh token is valid', async () => {
localStorage.setItem('refreshToken', 'xxx-valid-refresh');
getTokensFromRefreshToken();
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
);
});
});
import { getUserIdFromToken } from '../AuthService';
it('getUserIdFromToken returns null when the token is not present', async () => {
const userId = getUserIdFromToken();
@@ -111,13 +8,13 @@ it('getUserIdFromToken returns null when the token is not present', async () =>
});
it('getUserIdFromToken returns null when the token is not valid', async () => {
localStorage.setItem('accessToken', 'xxx-invalid-access');
cookieStorage.setItem('accessToken', 'xxx-invalid-access');
const userId = getUserIdFromToken();
expect(userId).toBeNull();
});
it('getUserIdFromToken returns the right userId when the token is valid', async () => {
localStorage.setItem(
cookieStorage.setItem(
'accessToken',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTI0ODgsImV4cCI6MTY4Njk5Mjc4OH0.IO7U5G14IrrQriw3JjrKVxmZgd6XKL6yUIwuNe_R55E',
);
@@ -125,28 +22,6 @@ it('getUserIdFromToken returns the right userId when the token is valid', async
expect(userId).toBe('374fe3a5-df1e-4119-afe0-2a62a2ba481e');
});
it('getTokensFromLoginToken does nothing if loginToken is empty', async () => {
await getTokensFromLoginToken('');
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('getTokensFromLoginToken does nothing if loginToken is not valid', async () => {
await getTokensFromLoginToken('xxx-invalid-login');
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('getTokensFromLoginToken does nothing if loginToken is not valid', async () => {
await getTokensFromLoginToken('xxx-valid-login');
expect(localStorage.getItem('accessToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
);
expect(localStorage.getItem('refreshToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
);
});
afterEach(() => {
localStorage.clear();
cookieStorage.clear();
});

View File

@@ -0,0 +1,43 @@
import Cookies from 'js-cookie';
import { tokenService } from '../TokenService';
const tokenPair = {
accessToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
expiresAt: '2023-06-17T09:18:02.942Z',
},
refreshToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
expiresAt: '2023-09-15T09:13:02.952Z',
},
};
it('getTokenPair is fullfiled when token is present', () => {
tokenService.setTokenPair(tokenPair);
// Otherwise the test will fail because Cookies-js seems to be async but functions aren't promises
setTimeout(() => {
expect(tokenService.getTokenPair()).toBe({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
});
}, 10);
});
it('getTokenPair is null when token is not set', () => {
expect(tokenService.getTokenPair()).toBeNull();
});
it('removeTokenPair clean cookie storage', () => {
tokenService.setTokenPair(tokenPair);
tokenService.removeTokenPair();
expect(tokenService.getTokenPair()).toBeNull();
});
afterEach(() => {
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
});

View File

@@ -0,0 +1 @@
export * from './index';

View File

@@ -0,0 +1,60 @@
import { gql } from '@apollo/client';
export const CHALLENGE = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
loginToken {
expiresAt
token
}
}
}
`;
export const VERIFY = gql`
mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) {
user {
id
email
displayName
workspaceMember {
id
workspace {
id
domainName
displayName
logo
}
}
}
tokens {
accessToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
}
`;
export const RENEW_TOKEN = gql`
mutation RenewToken($refreshToken: String!) {
renewToken(refreshToken: $refreshToken) {
tokens {
accessToken {
expiresAt
token
}
refreshToken {
token
expiresAt
}
}
}
}
`;

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useMatch, useResolvedPath } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import { removeTokens } from '@/auth/services/AuthService';
import { useAuth } from '@/auth/hooks/useAuth';
import {
IconColorSwatch,
IconLogout,
@@ -15,11 +15,15 @@ import NavTitle from '@/ui/layout/navbar/NavTitle';
import SubNavbarContainer from '@/ui/layout/navbar/sub-navbar/SubNavBarContainer';
export function SettingsNavbar() {
const logout = useCallback(() => {
removeTokens();
window.location.href = '/';
}, []);
const theme = useTheme();
const { logout } = useAuth();
const handleLogout = useCallback(() => {
logout();
window.location.href = '/';
}, [logout]);
return (
<SubNavbarContainer backButtonTitle="Settings">
<NavItemsContainer>
@@ -63,7 +67,7 @@ export function SettingsNavbar() {
<NavTitle label="Other" />
<NavItem
label="Logout"
onClick={logout}
onClick={handleLogout}
icon={<IconLogout size={theme.iconSizeMedium} />}
danger={true}
/>

View File

@@ -1,7 +1,5 @@
import { gql } from '@apollo/client';
import { useGetCurrentUserQuery as generatedUseGetCurrentUserQuery } from '~/generated/graphql';
export const GET_CURRENT_USER = gql`
query GetCurrentUser($uuid: String) {
users: findManyUser(where: { id: { equals: $uuid } }) {
@@ -30,11 +28,3 @@ export const GET_USERS = gql`
}
}
`;
export function useGetCurrentUserQuery(userId: string | null) {
return generatedUseGetCurrentUserQuery({
variables: {
uuid: userId,
},
});
}

View File

@@ -0,0 +1,3 @@
export function assertNotNull<T>(item: T): item is NonNullable<T> {
return item !== null && item !== undefined;
}

View File

@@ -0,0 +1,62 @@
import Cookies, { CookieAttributes } from 'js-cookie';
type Listener = (
newValue: string | undefined,
oldValue: string | undefined,
) => void;
class CookieStorage {
private listeners: Record<string, Listener[]> = {};
private keys: Set<string> = new Set();
getItem(key: string): string | undefined {
return Cookies.get(key);
}
setItem(key: string, value: string, attributes?: CookieAttributes): void {
const oldValue = this.getItem(key);
this.keys.add(key);
Cookies.set(key, value, attributes);
this.dispatch(key, value, oldValue);
}
removeItem(key: string): void {
const oldValue = this.getItem(key);
this.keys.delete(key);
Cookies.remove(key);
this.dispatch(key, undefined, oldValue);
}
clear(): void {
this.keys.forEach((key) => this.removeItem(key));
}
private dispatch(
key: string,
newValue: string | undefined,
oldValue: string | undefined,
): void {
if (this.listeners[key]) {
this.listeners[key].forEach((callback) => callback(newValue, oldValue));
}
}
addEventListener(key: string, callback: Listener): void {
if (!this.listeners[key]) {
this.listeners[key] = [];
}
this.listeners[key].push(callback);
}
removeEventListener(key: string, callback: Listener): void {
if (this.listeners[key]) {
this.listeners[key] = this.listeners[key].filter(
(listener) => listener !== callback,
);
}
}
}
export const cookieStorage = new CookieStorage();

View File

@@ -0,0 +1,16 @@
import { Observable } from '@apollo/client';
export const promiseToObservable = <T>(promise: Promise<T>) =>
new Observable<T>((subscriber) => {
promise.then(
(value) => {
if (subscriber.closed) {
return;
}
subscriber.next(value);
subscriber.complete();
},
(err) => subscriber.error(err),
);
});

View File

@@ -10,7 +10,6 @@ import { HorizontalSeparator } from '@/auth/components/ui/HorizontalSeparator';
import { Logo } from '@/auth/components/ui/Logo';
import { Modal } from '@/auth/components/ui/Modal';
import { Title } from '@/auth/components/ui/Title';
import { hasAccessToken } from '@/auth/services/AuthService';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton';
@@ -34,14 +33,6 @@ export function Index() {
authFlowUserEmailState,
);
useEffect(() => {
setMockMode(true);
if (hasAccessToken()) {
navigate('/');
}
}, [navigate, setMockMode]);
const onGoogleLoginClick = useCallback(() => {
window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || '';
}, []);
@@ -62,6 +53,10 @@ export function Index() {
[onPasswordLoginClick],
);
useEffect(() => {
setMockMode(true);
}, [navigate, setMockMode]);
return (
<>
<Companies />

View File

@@ -9,7 +9,7 @@ import { Logo } from '@/auth/components/ui/Logo';
import { Modal } from '@/auth/components/ui/Modal';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { getTokensFromLoginToken } from '@/auth/services/AuthService';
import { useAuth } from '@/auth/hooks/useAuth';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton';
@@ -47,48 +47,28 @@ export function PasswordLogin() {
const [internalPassword, setInternalPassword] = useState(prefillPassword);
const [formError, setFormError] = useState('');
const userLogin = useCallback(async () => {
const response = await fetch(
process.env.REACT_APP_AUTH_URL + '/password' || '',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: authFlowUserEmail,
password: internalPassword,
}),
},
);
const { login } = useAuth();
if (!response.ok) {
const errorData = await response.json();
setFormError(errorData.message);
return;
const handleLogin = useCallback(async () => {
try {
await login(authFlowUserEmail, internalPassword);
setMockMode(false);
navigate('/');
} catch (err: any) {
setFormError(err.message);
}
const { loginToken } = await response.json();
if (!loginToken) {
return;
}
await getTokensFromLoginToken(loginToken.token);
setMockMode(false);
navigate('/');
}, [authFlowUserEmail, internalPassword, navigate, setMockMode]);
}, [authFlowUserEmail, internalPassword, login, navigate, setMockMode]);
useHotkeys(
'enter',
() => {
userLogin();
handleLogin();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[userLogin],
[handleLogin],
);
return (
@@ -118,7 +98,7 @@ export function PasswordLogin() {
type="password"
/>
<StyledButtonContainer>
<PrimaryButton fullWidth onClick={userLogin}>
<PrimaryButton fullWidth onClick={handleLogin}>
Continue
</PrimaryButton>
</StyledButtonContainer>

View File

@@ -1,30 +1,33 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getTokensFromLoginToken } from '@/auth/services/AuthService';
import { useAuth } from '@/auth/hooks/useAuth';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
export function Verify() {
const [searchParams] = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const loginToken = searchParams.get('loginToken');
const isLogged = useIsLogged();
const navigate = useNavigate();
const { verify } = useAuth();
useEffect(() => {
async function getTokens() {
if (!loginToken) {
return;
}
setIsLoading(true);
await getTokensFromLoginToken(loginToken);
setIsLoading(false);
await verify(loginToken);
navigate('/');
}
if (!isLoading) {
if (!isLogged) {
getTokens();
}
}, [isLoading, navigate, loginToken]);
// Verify only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <></>;
}

View File

@@ -1,29 +0,0 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { getUserIdFromToken } from '@/auth/services/AuthService';
import { currentUserState } from '@/auth/states/currentUserState';
import { isAuthenticatingState } from '@/auth/states/isAuthenticatingState';
import { useGetCurrentUserQuery } from '@/users/services';
type OwnProps = {
children: JSX.Element;
};
export function AuthProvider({ children }: OwnProps) {
const [, setCurrentUser] = useRecoilState(currentUserState);
const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState);
const userIdFromToken = getUserIdFromToken();
const { data } = useGetCurrentUserQuery(userIdFromToken);
const user = data?.users?.[0];
useEffect(() => {
if (user) {
setCurrentUser(user);
setIsAuthenticating(false);
}
}, [user, setCurrentUser, setIsAuthenticating]);
return <>{children}</>;
}

View File

@@ -0,0 +1,19 @@
import { ApolloProvider as ApolloProviderBase } from '@apollo/client';
import { useRecoilState } from 'recoil';
import { isMockModeState } from '@/auth/states/isMockModeState';
import { apolloClient } from './apollo-client';
import { mockClient } from './mock-client';
export const ApolloProvider: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const [isMockMode] = useRecoilState(isMockModeState);
return (
<ApolloProviderBase client={isMockMode ? mockClient : apolloClient}>
{children}
</ApolloProviderBase>
);
};

View File

@@ -0,0 +1,36 @@
import { InMemoryCache } from '@apollo/client';
import { tokenService } from '@/auth/services/TokenService';
import { CommentThreadTarget } from '~/generated/graphql';
import { ApolloFactory } from './apollo.factory';
const apollo = new ApolloFactory({
uri: `${process.env.REACT_APP_API_URL}`,
cache: new InMemoryCache({
typePolicies: {
CommentThread: {
fields: {
commentThreadTargets: {
merge(
existing: CommentThreadTarget[] = [],
incoming: CommentThreadTarget[],
) {
return [...incoming];
},
},
},
},
},
}),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
onUnauthenticatedError() {
tokenService.removeTokenPair();
},
});
export const apolloClient = apollo.getClient();

View File

@@ -0,0 +1,167 @@
/* eslint-disable no-loop-func */
import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
createHttpLink,
ServerError,
ServerParseError,
} from '@apollo/client';
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 { tokenService } from '@/auth/services/TokenService';
import { assertNotNull } from '../../modules/utils/assert';
import { promiseToObservable } from '../../modules/utils/promise-to-observable';
import { ApolloManager } from './interfaces/apollo-manager.interface';
import { loggerLink } from './logger';
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;
onUnauthenticatedError?: () => void;
}
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
constructor(opts: Options<TCacheShape>) {
const {
uri,
onError: onErrorCb,
onNetworkError,
onUnauthenticatedError,
...options
} = opts;
const buildApolloLink = (): ApolloLink => {
const httpLink = createHttpLink({
uri,
});
const authLink = setContext(async (_, { headers }) => {
const credentials = tokenService.getTokenPair();
return {
headers: {
...headers,
authorization: credentials?.accessToken
? `Bearer ${credentials?.accessToken}`
: '',
},
};
});
const retryLink = new RetryLink({
delay: {
initial: 100,
},
attempts: {
max: 2,
retryIf: (error) => !!error,
},
});
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
if (graphQLErrors) {
onErrorCb?.(graphQLErrors);
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)
.then(() => {
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));
}),
);
}
return forward$.flatMap(() => forward(operation));
}
default:
if (process.env.NODE_ENV === 'development') {
console.warn(
`[GraphQL error]: Message: ${
graphQLError.message
}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
}
}
}
}
if (networkError) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[Network error]: ${networkError}`);
}
onNetworkError?.(networkError);
}
},
);
return ApolloLink.from(
[
errorLink,
authLink,
// Only show logger in dev mode
process.env.NODE_ENV !== 'production' ? logger : null,
retryLink,
httpLink,
].filter(assertNotNull),
);
};
this.client = new ApolloClient({
...options,
link: buildApolloLink(),
});
}
getClient() {
return this.client;
}
}

View File

@@ -0,0 +1,5 @@
import { ApolloClient } from '@apollo/client';
export interface ApolloManager<TCacheShape> {
getClient(): ApolloClient<TCacheShape>;
}

View File

@@ -0,0 +1,45 @@
import { OperationType } from './operation-type';
const operationTypeColors = {
query: '#03A9F4',
mutation: '#61A600',
subscription: '#61A600',
error: '#F51818',
default: '#61A600',
};
const getOperationColor = (operationType: OperationType) => {
return operationTypeColors[operationType] ?? operationTypeColors.default;
};
const formatTitle = (
operationType: OperationType,
schemaName: string,
queryName: string,
time: string | number,
) => {
const headerCss = [
'color: gray; font-weight: lighter', // title
`color: ${getOperationColor(operationType)}; font-weight: bold;`, // operationType
'color: gray; font-weight: lighter;', // schemaName
'color: black; font-weight: bold;', // queryName
];
const parts = [
'%c apollo',
`%c${operationType}`,
`%c${schemaName}::%c${queryName}`,
];
if (operationType !== OperationType.Subscription) {
parts.push(`%c(in ${time} ms)`);
headerCss.push('color: gray; font-weight: lighter;'); // time
} else {
parts.push(`%c(@ ${time})`);
headerCss.push('color: gray; font-weight: lighter;'); // time
}
return [parts.join(' '), ...headerCss];
};
export default formatTitle;

View File

@@ -0,0 +1,102 @@
import { ApolloLink, gql, Operation } from '@apollo/client';
import formatTitle from './format-title';
const getGroup = (collapsed: boolean) =>
collapsed
? console.groupCollapsed.bind(console)
: console.group.bind(console);
const parseQuery = (queryString: string) => {
const queryObj = gql`
${queryString}
`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { name } = queryObj.definitions[0] as any;
return [name ? name.value : 'Generic', queryString.trim()];
};
export const loggerLink = (getSchemaName: (operation: Operation) => string) =>
new ApolloLink((operation, forward) => {
const schemaName = getSchemaName(operation);
operation.setContext({ start: Date.now() });
const { variables } = operation;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const operationType = (operation.query.definitions[0] as any).operation;
const headers = operation.getContext().headers;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [queryName, query] = parseQuery(operation.query.loc!.source.body);
if (operationType === 'subscription') {
const date = new Date().toLocaleTimeString();
const titleArgs = formatTitle(operationType, schemaName, queryName, date);
console.groupCollapsed(...titleArgs);
if (variables && Object.keys(variables).length !== 0) {
console.log('VARIABLES', variables);
}
console.log('QUERY', query);
console.groupEnd();
return forward(operation);
}
return forward(operation).map((result) => {
const time = Date.now() - operation.getContext().start;
const errors = result.errors ?? result.data?.[queryName]?.errors;
const hasError = Boolean(errors);
try {
const titleArgs = formatTitle(
operationType,
schemaName,
queryName,
time,
);
getGroup(!hasError)(...titleArgs);
if (errors) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errors.forEach((err: any) => {
console.log(
`%c${err.message}`,
'color: #F51818; font-weight: lighter',
);
});
}
console.log('HEADERS: ', headers);
if (variables && Object.keys(variables).length !== 0) {
console.log('VARIABLES', variables);
}
console.log('QUERY', query);
if (result.data) {
console.log('RESULT', result.data);
}
if (errors) {
console.log('ERRORS', errors);
}
console.groupEnd();
} catch {
// this may happen if console group is not supported
console.log(
`${operationType} ${schemaName}::${queryName} (in ${time} ms)`,
);
if (errors) {
console.error(errors);
}
}
return result;
});
});

View File

@@ -0,0 +1,6 @@
export enum OperationType {
Query = 'query',
Mutation = 'mutation',
Subscription = 'subscription',
Error = 'error',
}

View File

@@ -0,0 +1,36 @@
import {
ApolloClient,
ApolloLink,
createHttpLink,
from,
InMemoryCache,
} from '@apollo/client';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { mockedUsersData } from '~/testing/mock-data/users';
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 === 'Verify') {
return { data: { user: [mockedUsersData[0]], tokens: {} } };
}
return response;
});
});
export const mockClient = new ApolloClient({
link: from([mockLink, apiLink]),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
});

View File

@@ -4,7 +4,6 @@ import { ApolloProvider } from '@apollo/client';
import { RecoilRoot } from 'recoil';
import { DefaultLayout } from '@/ui/layout/DefaultLayout';
import { AuthProvider } from '~/providers/AuthProvider';
import { ComponentStorybookLayout } from './ComponentStorybookLayout';
import { FullHeightStorybookLayout } from './FullHeightStorybookLayout';
@@ -20,9 +19,7 @@ export function getRenderWrapperForPage(
<ApolloProvider client={mockedClient}>
<MemoryRouter initialEntries={[currentPath]}>
<FullHeightStorybookLayout>
<AuthProvider>
<DefaultLayout>{children}</DefaultLayout>
</AuthProvider>
<DefaultLayout>{children}</DefaultLayout>
</FullHeightStorybookLayout>
</MemoryRouter>
</ApolloProvider>

View File

@@ -4892,6 +4892,11 @@
jest-matcher-utils "^27.0.0"
pretty-format "^27.0.0"
"@types/js-cookie@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.3.tgz#d6bfbbdd0c187354ca555213d1962f6d0691ff4e"
integrity sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww==
"@types/js-levenshtein@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5"
@@ -11937,6 +11942,11 @@ jose@^4.11.4:
resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca"
integrity sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==
js-cookie@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
js-levenshtein@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d"

View File

@@ -5,13 +5,12 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
import { AuthService } from './services/auth.service';
import { GoogleAuthController } from './controllers/google-auth.controller';
import { GoogleStrategy } from './strategies/google.auth.strategy';
import { TokenController } from './controllers/token.controller';
import { PrismaService } from 'src/database/prisma.service';
import { UserModule } from '../user/user.module';
import { AuthController } from './controllers/auth.controller';
import { PasswordAuthController } from './controllers/password-auth.controller';
import { VerifyAuthController } from './controllers/verify-auth.controller';
import { TokenService } from './services/token.service';
import { AuthResolver } from './auth.resolver';
const jwtModule = JwtModule.registerAsync({
useFactory: async (configService: ConfigService) => {
@@ -28,18 +27,14 @@ const jwtModule = JwtModule.registerAsync({
@Module({
imports: [jwtModule, ConfigModule.forRoot({}), UserModule],
controllers: [
GoogleAuthController,
PasswordAuthController,
TokenController,
AuthController,
],
controllers: [GoogleAuthController, VerifyAuthController],
providers: [
AuthService,
TokenService,
JwtAuthStrategy,
GoogleStrategy,
PrismaService,
AuthResolver,
],
exports: [jwtModule],
})

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthResolver } from './auth.resolver';
import { TokenService } from './services/token.service';
import { AuthService } from './services/auth.service';
describe('AuthResolver', () => {
let resolver: AuthResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthResolver,
{
provide: AuthService,
useValue: {},
},
{
provide: TokenService,
useValue: {},
},
],
}).compile();
resolver = module.get<AuthResolver>(AuthResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

View File

@@ -0,0 +1,49 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service';
import { RefreshTokenInput } from './dto/refresh-token.input';
import { BadRequestException } from '@nestjs/common';
import { Verify } from './dto/verify.entity';
import { VerifyInput } from './dto/verify.input';
import { AuthService } from './services/auth.service';
import { LoginToken } from './dto/login-token.entity';
import { ChallengeInput } from './dto/challenge.input';
@Resolver()
export class AuthResolver {
constructor(
private authService: AuthService,
private tokenService: TokenService,
) {}
@Mutation(() => LoginToken)
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
const user = await this.authService.challenge(challengeInput);
const loginToken = await this.tokenService.generateLoginToken(user.email);
return { loginToken };
}
@Mutation(() => Verify)
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.tokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);
return result;
}
@Mutation(() => AuthTokens)
async renewToken(@Args() args: RefreshTokenInput): Promise<AuthTokens> {
if (!args.refreshToken) {
throw new BadRequestException('Refresh token is mendatory');
}
const tokens = await this.tokenService.generateTokensFromRefreshToken(
args.refreshToken,
);
return { tokens: tokens };
}
}

View File

@@ -1,30 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PasswordAuthController } from './password-auth.controller';
import { AuthService } from '../services/auth.service';
import { TokenService } from '../services/token.service';
describe('PasswordAuthController', () => {
let controller: PasswordAuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PasswordAuthController],
providers: [
{
provide: AuthService,
useValue: {},
},
{
provide: TokenService,
useValue: {},
},
],
}).compile();
controller = module.get<PasswordAuthController>(PasswordAuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -1,23 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ChallengeInput } from '../dto/challenge.input';
import { AuthService } from '../services/auth.service';
import { LoginTokenEntity } from '../dto/login-token.entity';
import { TokenService } from '../services/token.service';
@Controller('auth/password')
export class PasswordAuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
) {}
@Post()
async challenge(
@Body() challengeInput: ChallengeInput,
): Promise<LoginTokenEntity> {
const user = await this.authService.challenge(challengeInput);
const loginToken = await this.tokenService.generateLoginToken(user.email);
return { loginToken };
}
}

View File

@@ -1,21 +0,0 @@
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
import { RefreshTokenInput } from '../dto/refresh-token.input';
import { TokenService } from '../services/token.service';
@Controller('auth/token')
export class TokenController {
constructor(private tokenService: TokenService) {}
@Post()
async generateAccessToken(@Body() body: RefreshTokenInput) {
if (!body.refreshToken) {
throw new BadRequestException('Refresh token is mendatory');
}
const tokens = await this.tokenService.generateTokensFromRefreshToken(
body.refreshToken,
);
return { tokens: tokens };
}
}

View File

@@ -1,14 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { VerifyAuthController } from './verify-auth.controller';
import { AuthService } from '../services/auth.service';
import { TokenService } from '../services/token.service';
describe('AuthController', () => {
let controller: AuthController;
describe('VerifyAuthController', () => {
let controller: VerifyAuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
controllers: [VerifyAuthController],
providers: [
{
provide: AuthService,
@@ -21,7 +21,7 @@ describe('AuthController', () => {
],
}).compile();
controller = module.get<AuthController>(AuthController);
controller = module.get<VerifyAuthController>(VerifyAuthController);
});
it('should be defined', () => {

View File

@@ -1,18 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from '../services/auth.service';
import { VerifyInput } from '../dto/verify.input';
import { VerifyEntity } from '../dto/verify.entity';
import { Verify } from '../dto/verify.entity';
import { TokenService } from '../services/token.service';
@Controller('auth')
export class AuthController {
@Controller('auth/verify')
export class VerifyAuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
) {}
@Post('verify')
async verify(@Body() verifyInput: VerifyInput): Promise<VerifyEntity> {
@Post()
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.tokenService.verifyLoginToken(
verifyInput.loginToken,
);

View File

@@ -1,10 +1,14 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class ChallengeInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
password: string;

View File

@@ -1,5 +1,8 @@
import { TokenEntity } from './token.entity';
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from './token.entity';
export class LoginTokenEntity {
loginToken: TokenEntity;
@ObjectType()
export class LoginToken {
@Field(() => AuthToken)
loginToken: AuthToken;
}

View File

@@ -1,6 +1,9 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class RefreshTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
refreshToken: string;

View File

@@ -6,18 +6,23 @@ import {
MinLength,
} from 'class-validator';
import { PASSWORD_REGEX } from '../auth.util';
import { ArgsType, Field } from '@nestjs/graphql';
@ArgsType()
export class RegisterInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
@MinLength(8)
@Matches(PASSWORD_REGEX, { message: 'password too weak' })
password: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
displayName: string;

View File

@@ -1,4 +1,25 @@
export class TokenEntity {
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AuthToken {
@Field(() => String)
token: string;
@Field(() => Date)
expiresAt: Date;
}
@ObjectType()
export class AuthTokenPair {
@Field(() => AuthToken)
accessToken: AuthToken;
@Field(() => AuthToken)
refreshToken: AuthToken;
}
@ObjectType()
export class AuthTokens {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
}

View File

@@ -1,11 +1,9 @@
import { TokenEntity } from './token.entity';
import { User } from '@prisma/client';
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthTokens } from './token.entity';
import { User } from 'src/core/@generated/user/user.model';
export class VerifyEntity {
user: Omit<User, 'passwordHash'>;
tokens: {
accessToken: TokenEntity;
refreshToken: TokenEntity;
};
@ObjectType()
export class Verify extends AuthTokens {
@Field(() => User)
user: User;
}

View File

@@ -1,6 +1,9 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class VerifyInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
loginToken: string;

View File

@@ -9,7 +9,7 @@ import { UserService } from 'src/core/user/user.service';
import { assert } from 'src/utils/assert';
import { RegisterInput } from '../dto/register.input';
import { PASSWORD_REGEX, compareHash, hashPassword } from '../auth.util';
import { VerifyEntity } from '../dto/verify.entity';
import { Verify } from '../dto/verify.entity';
import { TokenService } from './token.service';
export type UserPayload = {
@@ -73,17 +73,17 @@ export class AuthService {
return user;
}
async verify(email: string): Promise<VerifyEntity> {
const data = await this.userService.findUnique({
async verify(email: string): Promise<Verify> {
const user = await this.userService.findUnique({
where: {
email,
},
});
assert(data, "This user doesn't exist", NotFoundException);
assert(user, "This user doesn't exist", NotFoundException);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { passwordHash: _, ...user } = data;
// passwordHash is hidden for security reasons
user.passwordHash = '';
const accessToken = await this.tokenService.generateAccessToken(user.id);
const refreshToken = await this.tokenService.generateRefreshToken(user.id);

View File

@@ -13,7 +13,7 @@ import { PrismaService } from 'src/database/prisma.service';
import { assert } from 'src/utils/assert';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { TokenEntity } from '../dto/token.entity';
import { AuthToken } from '../dto/token.entity';
import { TokenExpiredError } from 'jsonwebtoken';
@Injectable()
@@ -24,7 +24,7 @@ export class TokenService {
private readonly prismaService: PrismaService,
) {}
async generateAccessToken(userId: string): Promise<TokenEntity> {
async generateAccessToken(userId: string): Promise<AuthToken> {
const expiresIn = this.configService.get<string>('ACCESS_TOKEN_EXPIRES_IN');
assert(expiresIn, '', InternalServerErrorException);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
@@ -55,7 +55,7 @@ export class TokenService {
};
}
async generateRefreshToken(userId: string): Promise<TokenEntity> {
async generateRefreshToken(userId: string): Promise<AuthToken> {
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
const expiresIn = this.configService.get<string>(
'REFRESH_TOKEN_EXPIRES_IN',
@@ -86,7 +86,7 @@ export class TokenService {
};
}
async generateLoginToken(email: string): Promise<TokenEntity> {
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.configService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN');
assert(expiresIn, '', InternalServerErrorException);
@@ -163,8 +163,8 @@ export class TokenService {
}
async generateTokensFromRefreshToken(token: string): Promise<{
accessToken: TokenEntity;
refreshToken: TokenEntity;
accessToken: AuthToken;
refreshToken: AuthToken;
}> {
const {
user,

View File

@@ -1,14 +1,9 @@
import { Module } from '@nestjs/common';
import { WorkspaceService } from './services/workspace.service';
import { WorkspaceMemberService } from './services/workspace-member.service';
import { WorkspaceMemberResolver } from './resolvers/workspace-member.resolver';
@Module({
providers: [
WorkspaceService,
WorkspaceMemberService,
WorkspaceMemberResolver,
],
providers: [WorkspaceService, WorkspaceMemberService],
exports: [WorkspaceService, WorkspaceMemberService],
})
export class WorkspaceModule {}