Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BA: jwt user management #88

Merged
merged 3 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/authentication/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @baseapp-frontend/authentication

## 2.1.0

### Minor Changes

- Create `useJWTUser` and `useUpdateUser` for user management in the client side using JWT.
- Rename the constant `PRE_AUTH_API_KEY` to `AUTH_API_KEY`.
- Add `phoneNumber` field to the `IUser` interface.

### Patch Changes

- Updated dependencies
- @baseapp-frontend/utils@2.1.0

## 2.0.1

### Patch Changes
Expand Down
26 changes: 3 additions & 23 deletions packages/authentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,6 @@ export * from './services/mfa'
export { default as UserApi } from './services/user'
export * from './services/user'

export type {
ILoginMfaRequest,
ILoginMfaResponse,
ILoginSimpleTokenResponse,
ILoginRequest,
LoginResponse,
IForgotPasswordRequest,
IResetPasswordRequest,
IRegisterRequest,
ICookieName,
} from './types/auth'
export type {
IMfaActivationResponse,
IMfaActiveMethodResponse,
IMfaConfigurationResponse,
IMfaConfirmationResponse,
IMfaDeactivateRequest,
IMfaRequest,
MfaActivationEmailRequest,
MfaActivationPhoneRequest,
MfaMethod,
} from './types/mfa'
export type { IUser } from './types/user'
export type * from './types/auth'
export type * from './types/mfa'
export type * from './types/user'
4 changes: 2 additions & 2 deletions packages/authentication/modules/access/usePreAuth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME, TokenTypes } from '@baseapp-fr
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'

import AuthApi, { PRE_AUTH_API_KEY } from '../../../services/auth'
import AuthApi, { AUTH_API_KEY } from '../../../services/auth'
import { USER_API_KEY } from '../../../services/user'
import { isJWTResponse } from '../../../utils/login'
import { useSimpleTokenUser } from '../../user'
Expand All @@ -25,7 +25,7 @@ const usePreAuth = ({
useErrorBoundary: false,
retry: false,
...queryOptions, // needs to be placed bellow all overridable options
queryKey: PRE_AUTH_API_KEY.preAuth(token, tokenType),
queryKey: AUTH_API_KEY.preAuth(token, tokenType),
queryFn: () => ApiClass.preAuth({ token }, tokenType),
onSuccess: (response) => {
if (isJWTResponse(tokenType, response)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/authentication/modules/user/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { default as getUser } from './getUser'
export { default as useSimpleTokenUser } from './useSimpleTokenUser'
export { default as useUpdateUser } from './useUpdateUser'
export { default as useJWTUser } from './useJWTUser'
export { default as useUser } from './useUser'

export { default as withUser } from './withUser'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"id": 1,
"email": "aa@tsl.io",
"is_email_verified": false,
"new_email": "",
"is_new_email_confirmed": false,
"referral_code": "",
"avatar": {
"full_size": "/media/user-avatars/5/6/1/544ca406ae11bac5c49759a2407fdcbe.png",
"small": "/media/user-avatars/5/6/1/544ca406ae11bac5c49759a2407fdcbe.png"
},
"first_name": "aa",
"last_name": "Test",
"phone_number": "+14073535656",
"exp": 1601546400
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
ComponentWithProviders,
CookiesGetByNameFn,
MockAdapter,
renderHook,
waitFor,
} from '@baseapp-frontend/test'
import { TokenTypes, axios } from '@baseapp-frontend/utils'

import { UseQueryResult } from '@tanstack/react-query'
import Cookies from 'js-cookie'

import { IUser } from '../../../../types/user'
import { IUseJWTUser } from '../types'
import request from './fixtures/request.json'

interface IIUseJWTUser<IUser> extends Omit<UseQueryResult<IUser, unknown>, 'data'> {
user?: IUser
}

describe('useJWTUser', () => {
// @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error
const axiosMock = new MockAdapter(axios)

let useJWTUser: <TUser extends Partial<IUser>>(props?: IUseJWTUser<TUser>) => IIUseJWTUser<TUser>

const decodeJWTMock = jest.fn()

const useQueryClientMock = jest.fn()
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: useQueryClientMock,
}))

beforeAll(async () => {
process.env.NEXT_PUBLIC_TOKEN_TYPE = TokenTypes.jwt
useJWTUser = (await import('../index')).default as any
// freeze time to
jest.useFakeTimers().setSystemTime(new Date(2020, 9, 1, 7))
})

const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzA5NjcxNjgzLCJpYXQiOjE3MDk2NjA3MTIsImp0aSI6IjhmMjg3ZGNhODVjODRjOTVhOThkZThmN2NiZTllNTE5IiwidXNlcl9pZCI6MSwiaWQiOjEsImVtYWlsIjoiYWFAdHNsLmlvIiwiaXNfZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuZXdfZW1haWwiOiIiLCJpc19uZXdfZW1haWxfY29uZmlybWVkIjpmYWxzZSwicmVmZXJyYWxfY29kZSI6IiIsImF2YXRhciI6eyJmdWxsX3NpemUiOiIvbWVkaWEvdXNlci1hdmF0YXJzLzUvNi8xL3Jlc2l6ZWQvMTAyNC8xMDI0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyIsInNtYWxsIjoiL21lZGlhL3VzZXItYXZhdGFycy81LzYvMS9yZXNpemVkLzY0LzY0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyJ9LCJmaXJzdF9uYW1lIjoiYWEiLCJsYXN0X25hbWUiOiJUZXN0In0.zmTBh3Iz6iRGTiV84o7r4JMA3AU4Q4bVbN76ZUwm5Jg'

it(`should call the user's endpoint if there is no initial data`, async () => {
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token)
decodeJWTMock.mockImplementation(() => undefined)
axiosMock.onGet('/users/me').reply(200, { ...request })

const { result } = renderHook(() => useJWTUser({ options: { placeholderData: undefined } }), {
wrapper: ComponentWithProviders,
})

expect(result.current.isLoading).toBe(true)
await waitFor(() => expect(result.current.isSuccess).toBeTruthy())

expect(result.current.user?.email).toBe(request.email)
})

it('should run custom onError and resetQueries on 401', async () => {
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token)
decodeJWTMock.mockImplementation(() => undefined)
const resetQueriesMock = jest.fn()
useQueryClientMock.mockImplementation(() => ({ resetQueries: resetQueriesMock }))

let hasOnErrorRan = false

axiosMock.onGet('/users/me').reply(401, {
error: 'Invalid token.',
})
const { result } = renderHook(
() =>
useJWTUser({
options: {
initialData: undefined,
retry: false,
onError: () => {
hasOnErrorRan = true
},
},
}),
{
wrapper: ComponentWithProviders,
},
)

await waitFor(() => expect(result.current.isLoading).toBe(false))
await waitFor(() => expect(hasOnErrorRan).toBe(true))
await waitFor(() => expect(resetQueriesMock).toHaveBeenCalled())
})
})
46 changes: 46 additions & 0 deletions packages/authentication/modules/user/useJWTUser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ACCESS_COOKIE_NAME, decodeJWT } from '@baseapp-frontend/utils'

import { useQuery, useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'

import UserApi, { USER_API_KEY } from '../../../services/user'
import { IUser } from '../../../types/user'
import { IUseJWTUser } from './types'

/**
* Fetches the user data using the JWT token data as placeholder data.
*
* This makes user data available before fetching it from the server.
*/
const useJWTUser = <TUser extends Partial<IUser>>({
options,
cookieName = ACCESS_COOKIE_NAME,
ApiClass = UserApi,
}: IUseJWTUser<TUser> = {}) => {
const token = Cookies.get(cookieName) || ''
const placeholderData = decodeJWT<TUser>(token) || undefined
const queryClient = useQueryClient()

const { data: user, ...rest } = useQuery({
queryFn: () => ApiClass.getUser<TUser>(),
queryKey: USER_API_KEY.getUser(),
staleTime: Infinity, // makes cache never expire automatically
enabled: !!token,
useErrorBoundary: false,
placeholderData,
...options, // needs to be placed bellow all overridable options
onError: (error: any) => {
if (error?.response?.status === 401) {
// we don't need to remove cookies here since this should be done by the interceptor

// making sure to reset the cache
queryClient.resetQueries(USER_API_KEY.getUser())
}
options?.onError?.(error)
},
})

return { user, ...rest }
}

export default useJWTUser
11 changes: 11 additions & 0 deletions packages/authentication/modules/user/useJWTUser/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UseQueryOptions } from '@tanstack/react-query'

import UserApi from '../../../services/user'
import { ICookieName } from '../../../types/auth'

type ApiClass = Pick<typeof UserApi, 'getUser'>

export interface IUseJWTUser<IUser> extends ICookieName {
options?: UseQueryOptions<IUser, unknown, IUser, any>
ApiClass?: ApiClass
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"email": "janedoe@example.com",
"id": 123,
"firstName": "Jane",
"lastName": "Doe",
"isEmailVerified": true,
"isNewEmailConfirmed": false,
"newEmail": "",
"referralCode": "1234"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
ComponentWithProviders,
CookiesGetByNameFn,
MockAdapter,
renderHook,
waitFor,
} from '@baseapp-frontend/test'
import { TokenTypes, axios } from '@baseapp-frontend/utils'

import { UseMutationResult } from '@tanstack/react-query'
import Cookies from 'js-cookie'

import { IUser } from '../../../../types/user'
import { UseUpdateUserOptions } from '../types'
import request from './fixtures/request.json'

interface IIUseUpdateUser<IUser> extends Omit<UseMutationResult<IUser, unknown>, 'data'> {
user?: IUser
}

// TODO: BA-1308: improve tests
describe('useUserUpdate', () => {
// @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error
const axiosMock = new MockAdapter(axios)

let useUserUpdate: <TUser extends Partial<IUser>>(
props?: UseUpdateUserOptions<TUser>,
) => IIUseUpdateUser<TUser>

const decodeJWTMock = jest.fn()
const refreshAccessTokenMock = jest.fn()
jest.mock('@baseapp-frontend/utils', () => ({
...jest.requireActual('@baseapp-frontend/utils'),
decodeJWT: decodeJWTMock,
refreshAccessToken: refreshAccessTokenMock,
}))

const useQueryClientMock = jest.fn()
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: useQueryClientMock,
}))

beforeAll(async () => {
process.env.NEXT_PUBLIC_TOKEN_TYPE = TokenTypes.jwt
useUserUpdate = (await import('../index')).default as any
// freeze time to
jest.useFakeTimers().setSystemTime(new Date(2020, 9, 1, 7))
})

const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzA5NjcxNjgzLCJpYXQiOjE3MDk2NjA3MTIsImp0aSI6IjhmMjg3ZGNhODVjODRjOTVhOThkZThmN2NiZTllNTE5IiwidXNlcl9pZCI6MSwiaWQiOjEsImVtYWlsIjoiYWFAdHNsLmlvIiwiaXNfZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuZXdfZW1haWwiOiIiLCJpc19uZXdfZW1haWxfY29uZmlybWVkIjpmYWxzZSwicmVmZXJyYWxfY29kZSI6IiIsImF2YXRhciI6eyJmdWxsX3NpemUiOiIvbWVkaWEvdXNlci1hdmF0YXJzLzUvNi8xL3Jlc2l6ZWQvMTAyNC8xMDI0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyIsInNtYWxsIjoiL21lZGlhL3VzZXItYXZhdGFycy81LzYvMS9yZXNpemVkLzY0LzY0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyJ9LCJmaXJzdF9uYW1lIjoiYWEiLCJsYXN0X25hbWUiOiJUZXN0In0.zmTBh3Iz6iRGTiV84o7r4JMA3AU4Q4bVbN76ZUwm5Jg'

it('should run custom onSettled on 401', async () => {
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token)
decodeJWTMock.mockImplementation(() => undefined)
const invalidateQueriesMock = jest.fn()
useQueryClientMock.mockImplementation(() => ({ invalidateQueries: invalidateQueriesMock }))

let hasOnSettledRan = false

axiosMock.onPatch('/users/1').reply(201, request)
const { result } = renderHook(
() =>
useUserUpdate({
options: {
onSettled: () => {
hasOnSettledRan = true
},
},
}),
{
wrapper: ComponentWithProviders,
},
)

await result.current.mutateAsync({ userId: 1, data: { firstName: 'BB' } })

await waitFor(() => expect(result.current.isLoading).toBe(false))
await waitFor(() => expect(hasOnSettledRan).toBe(true))
})
})
46 changes: 46 additions & 0 deletions packages/authentication/modules/user/useUpdateUser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
ACCESS_COOKIE_NAME,
REFRESH_COOKIE_NAME,
TokenTypes,
refreshAccessToken,
} from '@baseapp-frontend/utils'

import { useMutation, useQueryClient } from '@tanstack/react-query'

import UserApi, { USER_API_KEY } from '../../../services/user'
import { IUser, UserUpdateParams } from '../../../types/user'
import { UseUpdateUserOptions } from './types'

const useUpdateUser = <TUser extends Pick<IUser, 'id'>>({
options,
cookieName = ACCESS_COOKIE_NAME,
refreshCookieName = REFRESH_COOKIE_NAME,
ApiClass = UserApi,
}: UseUpdateUserOptions<TUser> = {}) => {
const queryClient = useQueryClient()

const mutation = useMutation(
(params: UserUpdateParams<TUser>) => ApiClass.updateUser<TUser>(params),
{
onSettled: async (data, error, variables, context) => {
queryClient.invalidateQueries(USER_API_KEY.getUser())
try {
const tokenType = process.env.NEXT_PUBLIC_TOKEN_TYPE as TokenTypes
if (tokenType === TokenTypes.jwt) {
await refreshAccessToken(cookieName, refreshCookieName)
}
} catch (e) {
// silently fail
// eslint-disable-next-line no-console
console.error(e)
}
options?.onSettled?.(data, error, variables, context)
},
...options,
},
)

return mutation
}

export default useUpdateUser
12 changes: 12 additions & 0 deletions packages/authentication/modules/user/useUpdateUser/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { UseMutationOptions } from '@tanstack/react-query'

import UserApi from '../../../services/user'
import { ICookieName } from '../../../types/auth'
import { IUser, UserUpdateParams } from '../../../types/user'

type ApiClass = Pick<typeof UserApi, 'updateUser'>

export interface UseUpdateUserOptions<TUser extends Partial<IUser>> extends ICookieName {
options?: UseMutationOptions<TUser, any, UserUpdateParams<TUser>, { previousUser?: TUser }>
ApiClass?: ApiClass
}
Loading
Loading