Create Profile Hooks to fetch current user

This commit is contained in:
Charles Bochet 2023-02-02 20:40:44 +01:00
parent 0eef9871f0
commit fcdc9aaec4
17 changed files with 150 additions and 52 deletions

View File

@ -20,6 +20,7 @@
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jwt-decode": "^3.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.4.4", "react-router-dom": "^6.4.4",
@ -25034,6 +25035,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/kind-of": { "node_modules/kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -53365,6 +53371,11 @@
"integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==",
"dev": true "dev": true
}, },
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"kind-of": { "kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",

View File

@ -15,6 +15,7 @@
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jwt-decode": "^3.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.4.4", "react-router-dom": "^6.4.4",

View File

@ -6,28 +6,10 @@ import AuthCallback from './pages/AuthCallback';
import AppLayout from './layout/AppLayout'; import AppLayout from './layout/AppLayout';
import RequireAuth from './components/RequireAuth'; import RequireAuth from './components/RequireAuth';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { useQuery, gql } from '@apollo/client'; import { useGetProfile } from './hooks/profile/useGetProfile';
const GET_USER_PROFILE = gql`
query GetUserProfile {
users {
id
email
first_name
last_name
tenant {
id
name
}
}
}
`;
function App() { function App() {
const { data } = useQuery(GET_USER_PROFILE, { const { user } = useGetProfile();
fetchPolicy: 'network-only',
});
const user = data?.users[0];
return ( return (
<AppLayout user={user}> <AppLayout user={user}>

View File

@ -1,7 +1,6 @@
import AuthService from '../hooks/AuthenticationHooks'; import { redirectIfNotLoggedIn } from '../hooks/AuthenticationHooks';
function RequireAuth({ children }: { children: JSX.Element }): JSX.Element { function RequireAuth({ children }: { children: JSX.Element }): JSX.Element {
const { redirectIfNotLoggedIn } = AuthService;
redirectIfNotLoggedIn(); redirectIfNotLoggedIn();
return children; return children;

View File

@ -1,5 +1,7 @@
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import jwt from 'jwt-decode';
import { TokenPayload } from '../interfaces/TokenPayload.interface';
const useIsNotLoggedIn = () => { const useIsNotLoggedIn = () => {
const { isAuthenticated, isLoading } = useAuth0(); const { isAuthenticated, isLoading } = useAuth0();
@ -15,6 +17,17 @@ const redirectIfNotLoggedIn = () => {
} }
}; };
const useGetUserEmailFromToken = (): string | undefined => {
const token = localStorage.getItem('accessToken');
const payload: TokenPayload | undefined = token ? jwt(token) : undefined;
if (!payload) {
return;
}
return payload['https://hasura.io/jwt/claims']['x-hasura-user-email'];
};
const useGetAccessToken = () => { const useGetAccessToken = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [token, setToken] = useState(''); const [token, setToken] = useState('');
@ -36,4 +49,9 @@ const useGetAccessToken = () => {
return { loading, token }; return { loading, token };
}; };
export default { useIsNotLoggedIn, useGetAccessToken, redirectIfNotLoggedIn }; export {
useIsNotLoggedIn,
useGetAccessToken,
redirectIfNotLoggedIn,
useGetUserEmailFromToken,
};

View File

@ -1,5 +1,8 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import AuthenticationHooks from '../AuthenticationHooks'; import {
useIsNotLoggedIn,
useGetUserEmailFromToken,
} from '../AuthenticationHooks';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { mocked } from 'jest-mock'; import { mocked } from 'jest-mock';
@ -32,7 +35,6 @@ describe('useIsNotLoggedIn', () => {
isLoading: true, isLoading: true,
}); });
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn()); const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current; const isNotLoggedIn = result.current;
@ -53,7 +55,6 @@ describe('useIsNotLoggedIn', () => {
isLoading: false, isLoading: false,
}); });
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn()); const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current; const isNotLoggedIn = result.current;
@ -75,7 +76,6 @@ describe('useIsNotLoggedIn', () => {
}); });
window.localStorage.setItem('accessToken', 'token'); window.localStorage.setItem('accessToken', 'token');
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn()); const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current; const isNotLoggedIn = result.current;
@ -97,10 +97,33 @@ describe('useIsNotLoggedIn', () => {
}); });
window.localStorage.setItem('accessToken', 'token'); window.localStorage.setItem('accessToken', 'token');
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn()); const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current; const isNotLoggedIn = result.current;
expect(isNotLoggedIn).toBe(false); expect(isNotLoggedIn).toBe(false);
}); });
}); });
describe('useGetUserEmailFromToken', () => {
beforeEach(() => {
window.localStorage.clear();
jest.resetModules();
});
it('returns undefined if token is not there', () => {
const { result } = renderHook(() => useGetUserEmailFromToken());
const email = result.current;
expect(email).toBe(undefined);
});
it('returns email if token is there', () => {
window.localStorage.setItem(
'accessToken',
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1FQXZiR0dFNjJ4S25mTFNxNHQ0dCJ9.eyJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsidXNlciJdLCJ4LWhhc3VyYS1kZWZhdWx0LXJvbGUiOiJ1c2VyIiwieC1oYXN1cmEtdXNlci1lbWFpbCI6ImNoYXJsZXNAb3VpaGVscC50d2VudHkuY29tIiwieC1oYXN1cmEtdXNlci1pZCI6Imdvb2dsZS1vYXV0aDJ8MTE4MjM1ODk3NDQ2OTIwNTQ3NzMzIn0sImlzcyI6Imh0dHBzOi8vdHdlbnR5LWRldi5ldS5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTgyMzU4OTc0NDY5MjA1NDc3MzMiLCJhdWQiOlsiaGFzdXJhLWRldiIsImh0dHBzOi8vdHdlbnR5LWRldi5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjc1MzYyNzY0LCJleHAiOjE2NzU0NDkxNjQsImF6cCI6IlM2ZXoyUFdUdUFsRncydjdxTFBWb2hmVXRseHc4QlBhIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCJ9.DseeSqYzNlYVQfuicoK8fK1Z6b-TYNvCkRoXXYOhg1X3HDSejowUTudyrJGErkT65xMCfx8K5quof9eV8BZQixCPr670r5gAIHxHuGY_KNfHTOALe8E5VyQaoekRyDr99Qo3QxliOOlJxtmckA8FTeD6JanfVmcrqghUOIsSXXDOOzJV6eME7JErEjTQHpfxveSVbPlCmIqZ3fqDaFdKfAlUDZFhVQM8XbfubNmG4VcoMyB7H47yLdGkYvVfPO1lVg0efywQo4IfbtiqFv5CjOEqO6PG78Wfkd24bcilkf6ZuGXsA-w-0xlU089GhKF99lNI1PxvNWAaLFbqanxiEw',
);
const { result } = renderHook(() => useGetUserEmailFromToken());
expect(result.current).toBe('charles@ouihelp.twenty.com');
});
});

View File

@ -0,0 +1,25 @@
import { renderHook } from '@testing-library/react';
import { useQuery, QueryResult } from '@apollo/client';
import { useGetProfile } from '../useGetProfile';
jest.mock('@apollo/client', () => ({
useQuery: jest.fn(),
}));
describe('useGetUserEmailFromToken', () => {
beforeEach(() => {
const result: Partial<QueryResult<any>> = {
data: { users: [{ email: 'test@twenty.com' }] },
loading: false,
error: undefined,
};
(useQuery as jest.Mock).mockImplementation(() => result as QueryResult);
});
it('returns profile', () => {
const { result } = renderHook(() => useGetProfile());
const email = result.current.user?.email;
expect(email).toEqual(result.current.user?.email);
expect(useQuery).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,33 @@
import { ApolloError, useQuery } from '@apollo/client';
import { gql } from 'graphql-tag';
import { User } from '../../interfaces/user.interface';
import { useGetUserEmailFromToken } from '../AuthenticationHooks';
const GET_USER_PROFILE = gql`
query GetUserProfile($email: String!) {
users(where: { email: { _eq: $email } }, limit: 1) {
id
email
first_name
last_name
tenant {
id
name
}
}
}
`;
type ProfileResult = {
loading: boolean;
error?: ApolloError;
user?: User;
};
export const useGetProfile = (): ProfileResult => {
const email = useGetUserEmailFromToken();
const { loading, error, data } = useQuery(GET_USER_PROFILE, {
variables: { email },
});
return { loading, error, user: data?.users[0] };
};

View File

@ -0,0 +1,6 @@
export interface TokenPayload {
'https://hasura.io/jwt/claims': {
'x-hasura-user-email': string;
'x-hasura-user-id': string;
};
}

View File

@ -0,0 +1,4 @@
export interface Tenant {
id: number;
name: string;
}

View File

@ -0,0 +1,9 @@
import { Tenant } from './tenant.interface';
export interface User {
id: number;
email: string;
first_name: string;
last_name: string;
tenant?: Tenant;
}

View File

@ -1,5 +1,6 @@
import Navbar from './navbar/Navbar'; import Navbar from './navbar/Navbar';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { User } from '../interfaces/user.interface';
const StyledLayout = styled.div` const StyledLayout = styled.div`
display: flex; display: flex;
@ -9,12 +10,7 @@ const StyledLayout = styled.div`
type OwnProps = { type OwnProps = {
children: JSX.Element; children: JSX.Element;
user?: { user?: User;
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
}; };
function AppLayout({ children, user }: OwnProps) { function AppLayout({ children, user }: OwnProps) {

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useMatch, useResolvedPath } from 'react-router-dom'; import { useMatch, useResolvedPath } from 'react-router-dom';
import { User } from '../../interfaces/user.interface';
import NavItem from './NavItem'; import NavItem from './NavItem';
import ProfileContainer from './ProfileContainer'; import ProfileContainer from './ProfileContainer';
@ -18,12 +19,7 @@ const NavItemsContainer = styled.div`
`; `;
type OwnProps = { type OwnProps = {
user?: { user?: User;
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
}; };
function Navbar({ user }: OwnProps) { function Navbar({ user }: OwnProps) {

View File

@ -1,12 +1,8 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { User } from '../../interfaces/user.interface';
type OwnProps = { type OwnProps = {
user?: { user?: User;
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
}; };
const StyledContainer = styled.button` const StyledContainer = styled.button`
@ -65,7 +61,7 @@ function ProfileContainer({ user }: OwnProps) {
</StyledAvatar> </StyledAvatar>
<StyledInfoContainer> <StyledInfoContainer>
<StyledEmail>{user?.email}</StyledEmail> <StyledEmail>{user?.email}</StyledEmail>
<StyledTenant>{user?.tenant.name}</StyledTenant> <StyledTenant>{user?.tenant?.name}</StyledTenant>
</StyledInfoContainer> </StyledInfoContainer>
</StyledContainer> </StyledContainer>
); );

View File

@ -11,10 +11,11 @@ export const NavbarOnInsights = () => (
<MemoryRouter initialEntries={['/insights']}> <MemoryRouter initialEntries={['/insights']}>
<Navbar <Navbar
user={{ user={{
id: 1,
email: 'charles@twenty.com', email: 'charles@twenty.com',
first_name: 'Charles', first_name: 'Charles',
last_name: 'Bochet', last_name: 'Bochet',
tenant: { id: '1', name: 'Twenty' }, tenant: { id: 1, name: 'Twenty' },
}} }}
/> />
</MemoryRouter> </MemoryRouter>

View File

@ -1,9 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import AuthService from '../hooks/AuthenticationHooks'; import { useGetAccessToken } from '../hooks/AuthenticationHooks';
function AuthCallback() { function AuthCallback() {
const { useGetAccessToken } = AuthService;
const { token } = useGetAccessToken(); const { token } = useGetAccessToken();
useEffect(() => { useEffect(() => {

View File

@ -27,7 +27,7 @@ api-make-metadata: ##
hasura metadata export" hasura metadata export"
front-sh: ## front-sh: ##
@docker-compose exec twenty-front bash @docker-compose exec twenty-front sh
front-test: ## front-test: ##
@docker-compose exec twenty-front sh -c "npm run test" @docker-compose exec twenty-front sh -c "npm run test"