diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a92519f..563b6f426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Frontend:** JWT token is now refreshed when it expires. If the refresh token has expired as well, user has to login again. #1120 - **Frontend:** Fix styling bug that caused the login button to be hidden on short screens. #1107 ### Changed diff --git a/README.md b/README.md index 02fd693d0..be0abd7a6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ yarn ### 3. Create Discord Bot -Create and setup the Discord bot. Be sure to take not of ENV variables during setup as these will be needed during the next step. You need to have administrative access to a Discord server in order to create a bot. Creating a server is free, we recommend setting up a personal server to use for testing purposes. +Create and setup the Discord bot. Be sure to take not of ENV variables during setup as these will be needed during the next step. You need to have administrative access to a Discord server in order to create a bot. Creating a server is free, we recommend setting up a personal server to use for testing purposes. [Create the Praise Discord bot](https://givepraise.xyz/docs/server-setup/create-discord-bot) @@ -70,6 +70,12 @@ Run mongo: yarn mongodb:start ``` +Finishing ENV setup + +``` +yarn run setup +``` + ### 6. Build and start api backend Api, discord-bot and frontend can also be started from the Visual Studio Code Launch menu. diff --git a/packages/api/src/auth/guards/auth.guard.ts b/packages/api/src/auth/guards/auth.guard.ts index 6f345cd3a..d78a68ed4 100644 --- a/packages/api/src/auth/guards/auth.guard.ts +++ b/packages/api/src/auth/guards/auth.guard.ts @@ -63,6 +63,10 @@ export class AuthGuard implements CanActivate { return false; } + isTokenExpiredError(error: any): boolean { + return error.name === 'TokenExpiredError'; + } + /** * Checks for a valid JWT. */ @@ -94,6 +98,9 @@ export class AuthGuard implements CanActivate { roles: payload.roles, } as AuthContext; } catch (e) { + if (this.isTokenExpiredError(e)) { + throw new ApiException(errorMessages.JWT_TOKEN_EXPIRED); + } return false; } return true; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d584b2982..03ecfc291 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -74,7 +74,7 @@ "analyze": "yarn run build && source-map-explorer --gzip 'build/static/js/*.js'", "load-env": "env-cmd --silent --no-override -f ../../.env env-cmd --silent --no-override", "start": "PORT=$(grep FRONTEND_PORT ../../.env | cut -d '=' -f2) TAILWIND_MODE=watch yarn run load-env craco start", - "lint": "eslint . --ext .ts --ext .tsx", + "lint": "eslint . --ext .ts --ext .tsx --fix", "test": "jest", "test:watch": "jest --watch" }, diff --git a/packages/frontend/src/model/auth/auth.ts b/packages/frontend/src/model/auth/auth.ts index f6c1af9f4..4b3914f29 100644 --- a/packages/frontend/src/model/auth/auth.ts +++ b/packages/frontend/src/model/auth/auth.ts @@ -27,6 +27,16 @@ export const AccessToken = selector({ }, }); +export const RefreshToken = selector({ + key: 'RefreshToken', + get: ({ get }) => { + const activeTokenSet = get(ActiveTokenSet); + if (!activeTokenSet) return; + + return activeTokenSet.refreshToken; + }, +}); + export const DecodedAccessToken = selector({ key: 'DecodedAccessToken', get: ({ get }) => { diff --git a/packages/frontend/src/model/auth/dto/refresh-token-input-dto.ts b/packages/frontend/src/model/auth/dto/refresh-token-input-dto.ts new file mode 100644 index 000000000..cb4c04a0c --- /dev/null +++ b/packages/frontend/src/model/auth/dto/refresh-token-input-dto.ts @@ -0,0 +1,3 @@ +import { components } from 'api-types'; + +export type RefreshTokenInputDto = components['schemas']['GenerateTokenDto']; diff --git a/packages/frontend/src/model/auth/interfaces/token-set.interface.ts b/packages/frontend/src/model/auth/interfaces/token-set.interface.ts index 3f41c04bc..9d4b942fb 100644 --- a/packages/frontend/src/model/auth/interfaces/token-set.interface.ts +++ b/packages/frontend/src/model/auth/interfaces/token-set.interface.ts @@ -1,3 +1,4 @@ export interface TokenSet { accessToken: string; + refreshToken: string; } diff --git a/packages/frontend/src/navigation/MainRoutes.tsx b/packages/frontend/src/navigation/MainRoutes.tsx index 3c65afb88..0195d792e 100644 --- a/packages/frontend/src/navigation/MainRoutes.tsx +++ b/packages/frontend/src/navigation/MainRoutes.tsx @@ -83,7 +83,7 @@ const AuthRoute = ({ ) : ( ) diff --git a/packages/frontend/src/navigation/Routes.tsx b/packages/frontend/src/navigation/Routes.tsx index ede12e678..46e9394ba 100644 --- a/packages/frontend/src/navigation/Routes.tsx +++ b/packages/frontend/src/navigation/Routes.tsx @@ -6,7 +6,7 @@ import { ActiveTokenSet, DecodedAccessToken } from '@/model/auth/auth'; import { MainLayout } from '../layouts/MainLayout'; const ActivatePage = React.lazy(() => import('@/pages/Activate/ActivatePage')); -const ErrorPage = React.lazy(() => import('@/pages/ErrorPage')); +const NotFoundPage = React.lazy(() => import('@/pages/NotFoundPage')); export const Routes = (): JSX.Element => { const [tokenSet, setTokenSet] = useRecoilState(ActiveTokenSet); @@ -26,7 +26,7 @@ export const Routes = (): JSX.Element => { - + diff --git a/packages/frontend/src/pages/NotFoundPage.tsx b/packages/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 000000000..a4fb84de1 --- /dev/null +++ b/packages/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,26 @@ +import { faHeartCrack } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Link } from 'react-router-dom'; + +const NotFoundPage = (): JSX.Element => { + return ( +
+
+ +
+ We couldn't find what you were looking for +
+
+ Go to start page +
+
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default NotFoundPage; diff --git a/packages/frontend/src/utils/auth.ts b/packages/frontend/src/utils/auth.ts index 2fe50d734..45d149ce1 100644 --- a/packages/frontend/src/utils/auth.ts +++ b/packages/frontend/src/utils/auth.ts @@ -7,21 +7,41 @@ import { ActivateInputDto } from '@/model/activate/dto/activate-input.dto'; import { TokenSet } from '@/model/auth/interfaces/token-set.interface'; import { LoginResponseDto } from '@/model/auth/dto/login-response.dto'; import { NonceResponseDto } from '@/model/auth/dto/nonce-response.dto'; +import { RefreshTokenInputDto } from '@/model/auth/dto/refresh-token-input-dto'; +import { isResponseOk } from '../model/api'; export const requestApiAuth = async ( params: LoginInputDto ): Promise => { const apiClient = makeApiClient(); const response = await apiClient.post('/auth/eth-signature/login', params); - if (!response) throw Error('Failed to request authorization'); + if (isResponseOk(response)) { + const { accessToken, refreshToken } = response.data; - const { accessToken } = response.data as unknown as LoginResponseDto; + setRecoil(ActiveTokenSet, { + accessToken, + refreshToken, + }); - setRecoil(ActiveTokenSet, { - accessToken, - }); + return getRecoil(ActiveTokenSet); + } +}; + +export const requestApiRefreshToken = async ( + params: RefreshTokenInputDto +): Promise => { + const apiClient = makeApiClient(false); + const response = await apiClient.post('/auth/eth-signature/refresh', params); + if (isResponseOk(response)) { + const { accessToken, refreshToken } = response.data; + + setRecoil(ActiveTokenSet, { + accessToken, + refreshToken, + }); - return getRecoil(ActiveTokenSet); + return getRecoil(ActiveTokenSet); + } }; export const requestNonce = async ( diff --git a/packages/frontend/src/utils/axios.ts b/packages/frontend/src/utils/axios.ts index 0f8822e2c..3c48111a6 100644 --- a/packages/frontend/src/utils/axios.ts +++ b/packages/frontend/src/utils/axios.ts @@ -1,5 +1,8 @@ import axios, { AxiosError, AxiosInstance } from 'axios'; import { toast } from 'react-hot-toast'; +import { requestApiRefreshToken } from '@/utils/auth'; +import { getRecoil, setRecoil } from 'recoil-nexus'; +import { ActiveTokenSet } from '../model/auth/auth'; const isJsonBlob = (data): data is Blob => data instanceof Blob && data.type === 'application/json'; @@ -9,15 +12,42 @@ const isJsonBlob = (data): data is Blob => * * @param err */ -export const handleErrors = ( - err: AxiosError, +export const handleErrors = async ( + err: AxiosError<{ + code: number; + message: string; + statusCode: number; + }>, handleErrorsAutomatically = true -): AxiosError => { +): Promise< + AxiosError<{ + code: number; + message: string; + statusCode: number; + }> +> => { // Handling errors automatically means the error will be displayed to the user with a toast. // If not handled automatically, the error will just be logged to the console and returned. if (!handleErrorsAutomatically) { console.error(err); - return err; + throw err; + } + + // If the error is a 401 and expired jwt token, try to refresh the token + if (err?.response?.status === 401 && err?.response?.data?.code === 1107) { + // 1107 are the error code for expired jwt token that defined in backend + const tokenSet = getRecoil(ActiveTokenSet); + if (!tokenSet) { + // Unlikely scenario: API returns 401 but no token set is available + return err; + } + try { + await requestApiRefreshToken({ refreshToken: tokenSet.refreshToken }); + return err; + } catch (error) { + console.error('Refresh JWT token failed', error); + setRecoil(ActiveTokenSet, undefined); + } } if (err?.response) { @@ -27,8 +57,8 @@ export const handleErrors = ( const json = JSON.parse(text); toast.error(json.message); }); - } else if ((err.response.data as Error).message) { - toast.error((err.response.data as Error).message); + } else if (err.response.data.message) { + toast.error(err.response.data.message); } else { toast.error('Something went wrong'); }