From fcdc9aaec40b552a203657b548e87d7b933bbc8e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 2 Feb 2023 20:40:44 +0100 Subject: [PATCH] Create Profile Hooks to fetch current user --- front/package-lock.json | 11 +++++++ front/package.json | 1 + front/src/App.tsx | 22 ++----------- front/src/components/RequireAuth.tsx | 3 +- front/src/hooks/AuthenticationHooks.ts | 20 ++++++++++- .../__tests__/AuthenticationHooks.test.tsx | 33 ++++++++++++++++--- .../profile/__tests__/useGetProfile.test.tsx | 25 ++++++++++++++ front/src/hooks/profile/useGetProfile.tsx | 33 +++++++++++++++++++ .../src/interfaces/TokenPayload.interface.ts | 6 ++++ front/src/interfaces/tenant.interface.ts | 4 +++ front/src/interfaces/user.interface.ts | 9 +++++ front/src/layout/AppLayout.tsx | 8 ++--- front/src/layout/navbar/Navbar.tsx | 8 ++--- front/src/layout/navbar/ProfileContainer.tsx | 10 ++---- .../navbar/__stories__/Navbar.stories.tsx | 3 +- front/src/pages/AuthCallback.tsx | 4 +-- infra/dev/Makefile | 2 +- 17 files changed, 150 insertions(+), 52 deletions(-) create mode 100644 front/src/hooks/profile/__tests__/useGetProfile.test.tsx create mode 100644 front/src/hooks/profile/useGetProfile.tsx create mode 100644 front/src/interfaces/TokenPayload.interface.ts create mode 100644 front/src/interfaces/tenant.interface.ts create mode 100644 front/src/interfaces/user.interface.ts diff --git a/front/package-lock.json b/front/package-lock.json index a73be02f66..ffd8347d42 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -20,6 +20,7 @@ "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", "graphql": "^16.6.0", + "jwt-decode": "^3.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.4", @@ -25034,6 +25035,11 @@ "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": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -53365,6 +53371,11 @@ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", "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": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", diff --git a/front/package.json b/front/package.json index cd38e9f53b..c6b6012006 100644 --- a/front/package.json +++ b/front/package.json @@ -15,6 +15,7 @@ "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", "graphql": "^16.6.0", + "jwt-decode": "^3.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.4", diff --git a/front/src/App.tsx b/front/src/App.tsx index c2dd5e261b..c94aa3b76a 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -6,28 +6,10 @@ import AuthCallback from './pages/AuthCallback'; import AppLayout from './layout/AppLayout'; import RequireAuth from './components/RequireAuth'; import { Routes, Route } from 'react-router-dom'; -import { useQuery, gql } from '@apollo/client'; - -const GET_USER_PROFILE = gql` - query GetUserProfile { - users { - id - email - first_name - last_name - tenant { - id - name - } - } - } -`; +import { useGetProfile } from './hooks/profile/useGetProfile'; function App() { - const { data } = useQuery(GET_USER_PROFILE, { - fetchPolicy: 'network-only', - }); - const user = data?.users[0]; + const { user } = useGetProfile(); return ( diff --git a/front/src/components/RequireAuth.tsx b/front/src/components/RequireAuth.tsx index e0d03f1ac6..8d7a56456d 100644 --- a/front/src/components/RequireAuth.tsx +++ b/front/src/components/RequireAuth.tsx @@ -1,7 +1,6 @@ -import AuthService from '../hooks/AuthenticationHooks'; +import { redirectIfNotLoggedIn } from '../hooks/AuthenticationHooks'; function RequireAuth({ children }: { children: JSX.Element }): JSX.Element { - const { redirectIfNotLoggedIn } = AuthService; redirectIfNotLoggedIn(); return children; diff --git a/front/src/hooks/AuthenticationHooks.ts b/front/src/hooks/AuthenticationHooks.ts index 861b0f1669..12972a5b0d 100644 --- a/front/src/hooks/AuthenticationHooks.ts +++ b/front/src/hooks/AuthenticationHooks.ts @@ -1,5 +1,7 @@ import { useAuth0 } from '@auth0/auth0-react'; import { useState, useEffect } from 'react'; +import jwt from 'jwt-decode'; +import { TokenPayload } from '../interfaces/TokenPayload.interface'; const useIsNotLoggedIn = () => { 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 [loading, setLoading] = useState(false); const [token, setToken] = useState(''); @@ -36,4 +49,9 @@ const useGetAccessToken = () => { return { loading, token }; }; -export default { useIsNotLoggedIn, useGetAccessToken, redirectIfNotLoggedIn }; +export { + useIsNotLoggedIn, + useGetAccessToken, + redirectIfNotLoggedIn, + useGetUserEmailFromToken, +}; diff --git a/front/src/hooks/__tests__/AuthenticationHooks.test.tsx b/front/src/hooks/__tests__/AuthenticationHooks.test.tsx index d673246100..f25b2ff531 100644 --- a/front/src/hooks/__tests__/AuthenticationHooks.test.tsx +++ b/front/src/hooks/__tests__/AuthenticationHooks.test.tsx @@ -1,5 +1,8 @@ import { renderHook } from '@testing-library/react'; -import AuthenticationHooks from '../AuthenticationHooks'; +import { + useIsNotLoggedIn, + useGetUserEmailFromToken, +} from '../AuthenticationHooks'; import { useAuth0 } from '@auth0/auth0-react'; import { mocked } from 'jest-mock'; @@ -32,7 +35,6 @@ describe('useIsNotLoggedIn', () => { isLoading: true, }); - const { useIsNotLoggedIn } = AuthenticationHooks; const { result } = renderHook(() => useIsNotLoggedIn()); const isNotLoggedIn = result.current; @@ -53,7 +55,6 @@ describe('useIsNotLoggedIn', () => { isLoading: false, }); - const { useIsNotLoggedIn } = AuthenticationHooks; const { result } = renderHook(() => useIsNotLoggedIn()); const isNotLoggedIn = result.current; @@ -75,7 +76,6 @@ describe('useIsNotLoggedIn', () => { }); window.localStorage.setItem('accessToken', 'token'); - const { useIsNotLoggedIn } = AuthenticationHooks; const { result } = renderHook(() => useIsNotLoggedIn()); const isNotLoggedIn = result.current; @@ -97,10 +97,33 @@ describe('useIsNotLoggedIn', () => { }); window.localStorage.setItem('accessToken', 'token'); - const { useIsNotLoggedIn } = AuthenticationHooks; const { result } = renderHook(() => useIsNotLoggedIn()); const isNotLoggedIn = result.current; 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'); + }); +}); diff --git a/front/src/hooks/profile/__tests__/useGetProfile.test.tsx b/front/src/hooks/profile/__tests__/useGetProfile.test.tsx new file mode 100644 index 0000000000..9c2e075a5b --- /dev/null +++ b/front/src/hooks/profile/__tests__/useGetProfile.test.tsx @@ -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> = { + 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); + }); +}); diff --git a/front/src/hooks/profile/useGetProfile.tsx b/front/src/hooks/profile/useGetProfile.tsx new file mode 100644 index 0000000000..379fc824f5 --- /dev/null +++ b/front/src/hooks/profile/useGetProfile.tsx @@ -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] }; +}; diff --git a/front/src/interfaces/TokenPayload.interface.ts b/front/src/interfaces/TokenPayload.interface.ts new file mode 100644 index 0000000000..41e96f2f64 --- /dev/null +++ b/front/src/interfaces/TokenPayload.interface.ts @@ -0,0 +1,6 @@ +export interface TokenPayload { + 'https://hasura.io/jwt/claims': { + 'x-hasura-user-email': string; + 'x-hasura-user-id': string; + }; +} diff --git a/front/src/interfaces/tenant.interface.ts b/front/src/interfaces/tenant.interface.ts new file mode 100644 index 0000000000..857c1b211b --- /dev/null +++ b/front/src/interfaces/tenant.interface.ts @@ -0,0 +1,4 @@ +export interface Tenant { + id: number; + name: string; +} diff --git a/front/src/interfaces/user.interface.ts b/front/src/interfaces/user.interface.ts new file mode 100644 index 0000000000..d56c68c851 --- /dev/null +++ b/front/src/interfaces/user.interface.ts @@ -0,0 +1,9 @@ +import { Tenant } from './tenant.interface'; + +export interface User { + id: number; + email: string; + first_name: string; + last_name: string; + tenant?: Tenant; +} diff --git a/front/src/layout/AppLayout.tsx b/front/src/layout/AppLayout.tsx index 98157bcbe5..0aabdd1a08 100644 --- a/front/src/layout/AppLayout.tsx +++ b/front/src/layout/AppLayout.tsx @@ -1,5 +1,6 @@ import Navbar from './navbar/Navbar'; import styled from '@emotion/styled'; +import { User } from '../interfaces/user.interface'; const StyledLayout = styled.div` display: flex; @@ -9,12 +10,7 @@ const StyledLayout = styled.div` type OwnProps = { children: JSX.Element; - user?: { - email: string; - first_name: string; - last_name: string; - tenant: { id: string; name: string }; - }; + user?: User; }; function AppLayout({ children, user }: OwnProps) { diff --git a/front/src/layout/navbar/Navbar.tsx b/front/src/layout/navbar/Navbar.tsx index a7c559b32a..bb7a8a84be 100644 --- a/front/src/layout/navbar/Navbar.tsx +++ b/front/src/layout/navbar/Navbar.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; import { useMatch, useResolvedPath } from 'react-router-dom'; +import { User } from '../../interfaces/user.interface'; import NavItem from './NavItem'; import ProfileContainer from './ProfileContainer'; @@ -18,12 +19,7 @@ const NavItemsContainer = styled.div` `; type OwnProps = { - user?: { - email: string; - first_name: string; - last_name: string; - tenant: { id: string; name: string }; - }; + user?: User; }; function Navbar({ user }: OwnProps) { diff --git a/front/src/layout/navbar/ProfileContainer.tsx b/front/src/layout/navbar/ProfileContainer.tsx index 3237d59017..2d8337212f 100644 --- a/front/src/layout/navbar/ProfileContainer.tsx +++ b/front/src/layout/navbar/ProfileContainer.tsx @@ -1,12 +1,8 @@ import styled from '@emotion/styled'; +import { User } from '../../interfaces/user.interface'; type OwnProps = { - user?: { - email: string; - first_name: string; - last_name: string; - tenant: { id: string; name: string }; - }; + user?: User; }; const StyledContainer = styled.button` @@ -65,7 +61,7 @@ function ProfileContainer({ user }: OwnProps) { {user?.email} - {user?.tenant.name} + {user?.tenant?.name} ); diff --git a/front/src/layout/navbar/__stories__/Navbar.stories.tsx b/front/src/layout/navbar/__stories__/Navbar.stories.tsx index 5dce0d9906..234860bec2 100644 --- a/front/src/layout/navbar/__stories__/Navbar.stories.tsx +++ b/front/src/layout/navbar/__stories__/Navbar.stories.tsx @@ -11,10 +11,11 @@ export const NavbarOnInsights = () => ( diff --git a/front/src/pages/AuthCallback.tsx b/front/src/pages/AuthCallback.tsx index 22b416149b..0d8c06533a 100644 --- a/front/src/pages/AuthCallback.tsx +++ b/front/src/pages/AuthCallback.tsx @@ -1,9 +1,7 @@ import React, { useEffect } from 'react'; -import AuthService from '../hooks/AuthenticationHooks'; +import { useGetAccessToken } from '../hooks/AuthenticationHooks'; function AuthCallback() { - const { useGetAccessToken } = AuthService; - const { token } = useGetAccessToken(); useEffect(() => { diff --git a/infra/dev/Makefile b/infra/dev/Makefile index ab0ffa8bc8..73ab0f69e2 100644 --- a/infra/dev/Makefile +++ b/infra/dev/Makefile @@ -27,7 +27,7 @@ api-make-metadata: ## hasura metadata export" front-sh: ## - @docker-compose exec twenty-front bash + @docker-compose exec twenty-front sh front-test: ## @docker-compose exec twenty-front sh -c "npm run test"