Skip to content
This repository has been archived by the owner on Sep 29, 2024. It is now read-only.

Commit

Permalink
feat: Improve authentication flow (#275)
Browse files Browse the repository at this point in the history
* perf: Mark type imports properly
* feat: Do not have a state for each derrived value
* feat: Move all http calls to tanstack mutation/query

---------

Signed-off-by: AlexNg <[email protected]>
  • Loading branch information
caffeine-addictt authored Aug 12, 2024
2 parents fc76785 + 849d7ae commit b58b6d2
Showing 1 changed file with 116 additions and 118 deletions.
234 changes: 116 additions & 118 deletions client/src/service/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
*/

import * as z from 'zod';
import { AxiosError, isAxiosError } from 'axios';
import { createContext, useCallback, useState, useEffect } from 'react';
import { createContext, useState } from 'react';

import httpClient from '@utils/http';
import { type AxiosError, isAxiosError } from 'axios';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

import { auth } from '@lib/api-types';
import { userType } from '@lib/api-types/schemas/user';
import { GetUserSuccAPI } from '@lib/api-types/user';
import { RefreshFailAPI, RefreshSuccAPI } from '@lib/api-types/auth';
import type { GetUserSuccAPI } from '@lib/api-types/user';
import type { RefreshFailAPI, RefreshSuccAPI } from '@lib/api-types/auth';
import { getAuthCookie, setAuthCookie, unsetAuthCookie } from '@utils/jwt';
import { refreshTokenSchema } from '@lib/api-types/schemas/auth';

Expand All @@ -22,140 +24,136 @@ export type AuthContextType = {
isLoggedIn: boolean;
isAdmin: boolean;
isActivated: boolean;
refetch: () => Promise<void>;
refetch: () => Promise<unknown>;
login: (tokens: auth.LoginSuccAPI['data']) => void;
logout: () => Promise<unknown>;
};
export const AuthContext = createContext<AuthContextType | null>(null);

/** Try to get user info */
const getUserInfo = () =>
httpClient.get<GetUserSuccAPI>({ uri: '/user', withCredentials: 'access' });

export type AuthProviderState = 'pending' | 'done';
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const queryClient = useQueryClient();
const [state, setState] = useState<AuthProviderState>('pending');
const [user, setUser] = useState<AuthContextType['user']>(null);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [isActivated, setIsActivated] = useState<boolean>(false);

const validateUser = () =>
getUserInfo()
.then((res) => {
console.log('Authenticated');
setUser(res.data);
setIsActivated(res.data.activated);
setIsLoggedIn(true);
setIsAdmin(res.data.permission === 0);
return null;
})
.catch((err: AxiosError) => {
console.log(
'An error occurred while trying to authenticate user:',
err.message,
);
return err;
});
const [user, setUser] = useState<z.infer<typeof userType> | null>(null);

// Login functionality
const login = (tokens: auth.LoginSuccAPI['data']) => {
setAuthCookie(tokens.access_token, 'access');
setAuthCookie(tokens.refresh_token, 'refresh');
};

// Logout functionality
const { mutateAsync: logout } = useMutation(
{
mutationKey: ['user-logout'],
mutationFn: () =>
httpClient.post({
uri: '/auth/invalidate-tokens',
withCredentials: 'refresh',
payload: { access_token: getAuthCookie('access')! },
}),
onError: (err) => console.log('Failed to logout:', err.message),
onSettled: () => {
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['user'] });
queryClient.invalidateQueries({ queryKey: ['user-logout'] });
queryClient.invalidateQueries({ queryKey: ['user-refresh'] });

// Unset token
unsetAuthCookie('access');
unsetAuthCookie('refresh');

// Update state
setUser(null);
},
},
queryClient,
);

const refreshToken = () =>
httpClient
.post<RefreshSuccAPI, z.infer<typeof refreshTokenSchema>>({
uri: '/auth/refresh',
withCredentials: 'refresh',
payload: { access_token: getAuthCookie('access')! },
})
.then((res) => res.data)
.catch((err: AxiosError<RefreshFailAPI> | Error) => {
// Refreshing user token
const { mutate: refreshToken } = useMutation(
{
mutationKey: ['user-refresh'],
mutationFn: () =>
httpClient
.post<RefreshSuccAPI, z.infer<typeof refreshTokenSchema>>({
uri: '/auth/refresh',
withCredentials: 'refresh',
payload: { access_token: getAuthCookie('access')! },
})
.then((res) => res.data),
onSuccess: (data) => {
login(data);
queryClient.invalidateQueries({ queryKey: ['user'] });
return fetchUser();
},
onError: (err: AxiosError<RefreshFailAPI> | Error) => {
setState('done');
if (!isAxiosError(err)) {
console.log('Failed to refresh token:', err.message);
return;
}
return err;
});

const fetchUser = useCallback(async () => {
const validate = await validateUser();

// Handle validated
if (!validate) {
setState('done');
return;
}

// See if fail reason is expired access token
const castedErr = validate.response?.data as RefreshFailAPI | undefined;
if (!castedErr || castedErr.errors[0].message !== 'Token is expired!') {
setState('done');
return;
}

// Handle refreshing token
const refreshTokenResp = await refreshToken();

// Non-axios error
if (!refreshTokenResp) {
setState('done');
return;
}

// Handle axios error
if (isAxiosError(refreshTokenResp)) {
console.log('Failed to refresh token:', refreshTokenResp.message);
setState('done');
return;
}

// Handle success refresh
const validateAfterRefresh = await validateUser();

// Handle non-axios error or validated
if (!validateAfterRefresh) {
setState('done');
return;
}

console.log(
'Failed to authenticate user after refreshing token:',
validateAfterRefresh.message,
);
setState('done');
}, []);
console.log(
'Failed to refresh token:',
err.response?.data.errors[0].message,
);
},
},
queryClient,
);

useEffect(() => {
fetchUser();
}, [fetchUser]);
// Fetching user
const { refetch: fetchUser } = useQuery(
{
queryKey: ['user'],
queryFn: () =>
httpClient
.get<GetUserSuccAPI>({ uri: '/user', withCredentials: 'access' })
.then((res) => res.data)
.then((data) => {
console.log('Authenticated');
setUser(data);
setState('done');
return data;
})
.catch((err: AxiosError) => {
console.log(
'An error occurred while trying to authenticate user:',
err.message,
);

// See if fail reason is expired access token
const castedErr = err.response?.data as RefreshFailAPI | undefined;
if (
!castedErr ||
(castedErr.errors[0].message !== 'Token is expired!' &&
castedErr.errors[0].message !== 'Invalid token!')
) {
setState('done');
throw err;
}

// Try to refresh token
queryClient.invalidateQueries({ queryKey: ['user-refresh'] });
refreshToken();
throw err;
}),
},
queryClient,
);

return (
<AuthContext.Provider
children={children}
value={{
state: state,
user: user,
isLoggedIn: isLoggedIn,
isAdmin: isAdmin,
isActivated: isActivated,
refetch: fetchUser,
logout: async () => {
// Invalidate tokens HTTP
return await httpClient
.post({
uri: '/auth/invalidate-tokens',
withCredentials: 'refresh',
payload: { access_token: getAuthCookie('access')! },
})
.catch((res) =>
console.log('Failed to invalidate tokens:', res.message),
)
.finally(() => {
unsetAuthCookie('access');
unsetAuthCookie('refresh');
});
},
login: (tokens: auth.LoginSuccAPI['data']) => {
setAuthCookie(tokens.access_token, 'access');
setAuthCookie(tokens.refresh_token, 'refresh');
},
user: user || null,
isLoggedIn: !!user,
isAdmin: user?.permission === 0 || false,
isActivated: user?.activated || false,
refetch: () => fetchUser(),
logout: logout,
login: login,
}}
/>
);
Expand Down

0 comments on commit b58b6d2

Please sign in to comment.