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:
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
82
front/src/modules/auth/hooks/useAuth.ts
Normal file
82
front/src/modules/auth/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
front/src/modules/auth/hooks/useIsLogged.ts
Normal file
21
front/src/modules/auth/hooks/useIsLogged.ts
Normal 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;
|
||||
}
|
||||
@ -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');
|
||||
};
|
||||
|
||||
34
front/src/modules/auth/services/TokenService.ts
Normal file
34
front/src/modules/auth/services/TokenService.ts
Normal 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();
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
1
front/src/modules/auth/services/index.ts
Normal file
1
front/src/modules/auth/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './index';
|
||||
60
front/src/modules/auth/services/update.ts
Normal file
60
front/src/modules/auth/services/update.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
3
front/src/modules/utils/assert.ts
Normal file
3
front/src/modules/utils/assert.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function assertNotNull<T>(item: T): item is NonNullable<T> {
|
||||
return item !== null && item !== undefined;
|
||||
}
|
||||
62
front/src/modules/utils/cookie-storage.ts
Normal file
62
front/src/modules/utils/cookie-storage.ts
Normal 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();
|
||||
16
front/src/modules/utils/promise-to-observable.ts
Normal file
16
front/src/modules/utils/promise-to-observable.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user