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

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',
},
},
});