├── .editorconfig ├── .env.development ├── .env.production ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .jest └── setup.ts ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── babel.config.cjs ├── index.html ├── jest.config.cjs ├── package.json ├── public └── .gitkeep ├── scripts └── typeCheckStaged.js ├── src ├── App.tsx ├── components │ ├── CanAccess │ │ ├── CanAccess.spec.tsx │ │ ├── CanAccess.tsx │ │ └── index.ts │ ├── ErrorState │ │ ├── ErrorState.spec.tsx │ │ ├── ErrorState.tsx │ │ └── index.ts │ ├── Loader │ │ ├── Loader.spec.tsx │ │ ├── Loader.tsx │ │ └── index.ts │ ├── NavBar │ │ ├── NavBar.spec.tsx │ │ ├── NavBar.tsx │ │ └── index.ts │ └── index.ts ├── contexts │ ├── AuthContext │ │ ├── AuthContext.spec.tsx │ │ ├── AuthContext.tsx │ │ └── index.ts │ └── index.ts ├── hooks │ ├── index.ts │ ├── useRoutePaths │ │ ├── index.ts │ │ ├── useRoutePaths.spec.ts │ │ └── useRoutePaths.ts │ └── useSession │ │ ├── index.ts │ │ └── useSession.ts ├── main.tsx ├── pages │ ├── Home │ │ ├── Home.spec.tsx │ │ ├── Home.tsx │ │ └── index.ts │ ├── Login │ │ ├── Login.spec.tsx │ │ ├── Login.tsx │ │ └── index.ts │ ├── Metrics │ │ ├── Metrics.spec.tsx │ │ ├── Metrics.tsx │ │ └── index.ts │ ├── Register │ │ ├── Register.spec.tsx │ │ ├── Register.tsx │ │ └── index.ts │ ├── Users │ │ ├── Users.spec.tsx │ │ ├── Users.tsx │ │ └── index.ts │ └── index.ts ├── providers │ ├── AuthProvider │ │ ├── AuthProvider.tsx │ │ └── index.ts │ └── index.ts ├── router │ ├── PrivateRoute │ │ ├── PrivateRoute.tsx │ │ └── index.ts │ ├── PublicRoute │ │ ├── PublicRoute.tsx │ │ └── index.ts │ ├── Router │ │ ├── Router.tsx │ │ └── index.ts │ ├── index.ts │ └── paths │ │ ├── index.ts │ │ └── paths.ts ├── services │ ├── api.ts │ ├── index.ts │ └── interceptors.ts ├── utils │ ├── constants.ts │ ├── index.ts │ ├── tokenCookies.ts │ └── validateUserPermissions.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | REACT_APP_API_URL=https://node-api-refresh-token.cyclic.app 3 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | REACT_APP_API_URL= 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest": true, 6 | "node": true 7 | }, 8 | "settings": { 9 | "react": { 10 | "version": "detect" 11 | } 12 | }, 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:react/recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:prettier/recommended" 18 | ], 19 | "overrides": [ 20 | ], 21 | "parser": "@typescript-eslint/parser", 22 | "parserOptions": { 23 | "ecmaVersion": "latest", 24 | "sourceType": "module" 25 | }, 26 | "plugins": [ 27 | "react", 28 | "react-hooks", 29 | "@typescript-eslint" 30 | ], 31 | "rules": { 32 | "react-hooks/rules-of-hooks": "error", 33 | "react-hooks/exhaustive-deps": "warn", 34 | "react/react-in-jsx-scope": "off" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 15 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout Repository 9 | uses: actions/checkout@v3 10 | 11 | - name: Setup Node 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 20.x 15 | 16 | - uses: actions/cache@v3 17 | id: yarn-cache 18 | with: 19 | path: | 20 | ~/cache 21 | !~/cache/exclude 22 | **/node_modules 23 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn- 26 | 27 | - name: Install dependencies 28 | run: yarn install 29 | 30 | - name: Linting 31 | run: yarn lint 32 | 33 | - name: Test 34 | run: yarn coverage-test 35 | 36 | - name: Build 37 | run: yarn build 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.jest/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | !.jest 2 | /dist 3 | /node_modules 4 | /public 5 | /generators 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "14" 5 | 6 | cache: 7 | diretories: 8 | "node_modules" 9 | 10 | before_script: 11 | - npm run test:coverage 12 | 13 | after_success: 14 | - "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 - Eder Sampaio 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

React JS Authentication Boilerplate

3 |

4 | 5 |

6 | 7 | Build Status 8 | 9 | 10 | 11 | Coverage Status 12 | 13 | 14 | 15 | Coverage Status 16 | 17 |

18 | 19 | ## Summary 20 | 21 | - [About](#about) 22 | - [Built using](#built-using) 23 | - [Getting started](#getting-started) 24 | - [Prerequisites](#prerequisites) 25 | - [Installing dependencies](#installing-dependencies) 26 | - [Project setup](#project-setup) 27 | - [Compiles and hot-reloads for development](#compiles-and-hot-reloads-for-development) 28 | - [Compiles and minifies for production](#compiles-and-minifies-for-production) 29 | - [Lints and fixes files](#lints-and-fixes-files) 30 | - [Run your unit tests](#run-your-unit-tests) 31 | - [Test users](#test-users) 32 | - [Administrator](#administrator) 33 | - [Client](#client) 34 | - [Route types](#route-types) 35 | - [Public route](#public-route) 36 | - [Private route](#private-route) 37 | - [Hybrid route](#hybrid-route) 38 | - [Control visibility of components](#control-visibility-of-components) 39 | - [Contributing](#contributing) 40 | - [Versioning](#versioning) 41 | - [Authors](#authors) 42 | - [License](#license) 43 | 44 | ## About 45 | 46 | This repository was created to assist in the authentication implementation process in React **JS applications with JWT and refresh token**. All components and contexts have **unit tests** and a **basic HTML structure without CSS**. The project has features to **secure routes** and **control the visibility of components** based on permissions, the entire implementation process is in this document. 47 | 48 | Feel free to clone the project or use it as a template and make any changes you deem necessary. 49 | 50 | ## Built using 51 | 52 | - [React JS](https://reactjs.org): JavaScript library 53 | - [TypeScript](https://www.typescriptlang.org): JavaScript With Syntax For Types 54 | - [Jest](https://jestjs.io): JavaScript Testing Framework 55 | - [React Testing Library](https://testing-library.com): Testing utilities 56 | 57 | ## Getting started 58 | 59 | ### Prerequisites 60 | 61 | You need to install on your machine [Node.js](https://nodejs.org) or [Yarn](https://yarnpkg.com). 62 | 63 | ### Installing dependencies 64 | 65 | ```bash 66 | npm install 67 | # or 68 | yarn install 69 | ``` 70 | 71 | ## Project setup 72 | 73 | ### Compiles and hot-reloads for development 74 | 75 | ```bash 76 | # start app open development mode 77 | yarn start 78 | # or 79 | npm run start 80 | ``` 81 | 82 | ### Compiles and minifies for production 83 | 84 | ```bash 85 | yarn build 86 | # or 87 | npm run build 88 | ``` 89 | 90 | ### Lints and fixes files 91 | ```bash 92 | # show errors 93 | yarn lint 94 | # or 95 | npm run lint 96 | 97 | # fix errors 98 | yarn lint:fix 99 | # or 100 | npm run lint:fix 101 | ``` 102 | 103 | ### Run your unit tests 104 | 105 | ```bash 106 | # run tests 107 | yarn test 108 | # or 109 | npm run test 110 | 111 | # run tests on watch mode 112 | yarn test:watch 113 | # or 114 | npm run test:watch 115 | 116 | # run tests on coverage mode 117 | yarn test:coverage 118 | # or 119 | npm run test:coverage 120 | 121 | # run tests on coverage with watch mode 122 | yarn test:coverage:watch 123 | # or 124 | npm run test:coverage:watch 125 | ``` 126 | 127 | ## Test users 128 | 129 | The app is integrated with the [node-api-refresh-token.cyclic.app](https://node-api-refresh-token.cyclic.app) API, configured in the `.env` file. There are two users with different accesses so that the tests can be performed: 130 | 131 | ### Administrator 132 | 133 | - **Email**: admin@site.com 134 | - **Password**: password@123 135 | - **Permissions**: `users.list`, `users.create`, `metrics.list` 136 | 137 | ### Client 138 | 139 | - **Email**: client@site.com 140 | - **Password**: password@123 141 | - **Permissions**: `metrics.list` 142 | 143 | ## Route types 144 | 145 | The route components are based on `` component of [react-router-dom](https://reactrouter.com/web/guides/quick-start) and receive same props. 146 | 147 | ### Public route 148 | 149 | The route can only be accessed if a user is not authenticated. If accessed after authentication, the user will be redirected `/` route. 150 | 151 | ```tsx 152 | import { Routes } from 'react-router-dom' 153 | import { PublicRoute } from 'src/router/PublicRoute' 154 | 155 | const SampleComponent = () =>
Sample component
156 | 157 | export const Router = () => ( 158 | 159 | 163 | 164 | ) 165 | ``` 166 | 167 | ### Private route 168 | 169 | The route can only be accessed if a user is authenticated. Use permission props (returned by the API) to access the control. 170 | 171 | ```tsx 172 | import { Routes } from 'react-router-dom' 173 | import { PrivateRoute } from 'src/router/PrivateRoute' 174 | 175 | const SampleComponent = () =>
Sample component
176 | 177 | export const Router = () => ( 178 | 179 | {/* 180 | allow route access if the user has the permissions 181 | `users.list` and `users.create` 182 | */} 183 | 188 | 189 | ) 190 | ``` 191 | 192 | ### Hybrid route 193 | 194 | The route can be accessed if a user is authenticated or not. Use `Route` component. 195 | 196 | ```tsx 197 | import { Route, Routes } from 'react-router-dom' 198 | 199 | const SampleComponent = () =>
Sample component
200 | 201 | export const Router = () => ( 202 | 203 | } /> 204 | 205 | ) 206 | ``` 207 | 208 | ## Control visibility of components 209 | 210 | Use the `CanAccess` component and pass `permissions` props to control the visibility of a component. 211 | 212 | ```tsx 213 | import { CanAccess } from 'src/components' 214 | 215 | export function NavBar () { 216 | return ( 217 | <> 218 | {/* 219 | the component is shown if the user has the permissions 220 | `users.list` and `metrics.list` 221 | */} 222 | 223 | {/* YOUR COMPONENT HERE */} 224 | 225 | 226 | ) 227 | } 228 | ``` 229 | 230 | ## Contributing 231 | 232 | Please read [CONTRIBUTING.md](https://gist.github.com/PurpleBooth/b24679402957c63ec426) for details on our code of conduct, and the process for submitting pull requests to us. 233 | 234 | ## Versioning 235 | 236 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/ederssouza/reactjs-auth-boilerplate/tags). 237 | 238 | ## Authors 239 | 240 | See also the list of [contributors](https://github.com/ederssouza/reactjs-auth-boilerplate/contributors) who participated in this project. 241 | 242 | ## License 243 | 244 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 245 | 246 | ---- 247 | 248 | Develop by Eder Sampaio 👋  [See my linkedin](https://www.linkedin.com/in/ederssouza). 249 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-typescript'], 4 | ['@babel/preset-env', { modules: 'auto' }], 5 | [ 6 | '@babel/preset-react', 7 | { 8 | runtime: 'automatic' 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React JS Authentication Boilerplate 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | testPathIgnorePatterns: ['/dist/', '/node_modules/', '/public/'], 4 | collectCoverageFrom: [ 5 | 'src/components/**/*.ts(x)?', 6 | 'src/contexts/**/*.ts(x)?', 7 | 'src/hooks/**/*.ts(x)?', 8 | 'src/pages/**/*.ts(x)?', 9 | 'src/providers/**/*.ts(x)?', 10 | '!src/**/index.ts', 11 | '!src/main.tsx', 12 | '!src/vite-env.d.ts' 13 | ], 14 | modulePaths: ['/src/'], 15 | setupFilesAfterEnv: ['/.jest/setup.ts'], 16 | transform: { 17 | '^.+\\.tsx?$': 'babel-jest' 18 | }, 19 | moduleNameMapper: { 20 | '@/(.*)': '/src/$1' 21 | }, 22 | coverageThreshold: { 23 | global: { 24 | statements: -10, 25 | branches: 100, 26 | functions: 100, 27 | lines: 100 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactjs-auth-boilerplate", 3 | "version": "2.0.2", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0", 10 | "lint:fix": "npm run lint -- --fix", 11 | "typecheck": "node scripts/typeCheckStaged.js", 12 | "test": "jest --maxWorkers=50%", 13 | "test:watch": "jest --watch --maxWorkers=25%", 14 | "test:ci": "jest --runInBand", 15 | "coverage-test": "jest --coverage --maxWorkers=50%", 16 | "coverage-test:watch": "jest --coverage --watch --maxWorkers=25%", 17 | "postinstall": "husky install", 18 | "prepare": "husky install" 19 | }, 20 | "lint-staged": { 21 | "src/**/*": [ 22 | "npm run typecheck", 23 | "npm run lint", 24 | "npm test" 25 | ] 26 | }, 27 | "dependencies": { 28 | "axios": "^1.9.0", 29 | "nookies": "^2.5.2", 30 | "react": "^18.3.1", 31 | "react-dom": "^18.3.1", 32 | "react-error-boundary": "^5.0.0", 33 | "react-router-dom": "^6.27.0" 34 | }, 35 | "devDependencies": { 36 | "@babel/preset-env": "^7.27.1", 37 | "@babel/preset-react": "^7.27.1", 38 | "@babel/preset-typescript": "^7.27.1", 39 | "@testing-library/jest-dom": "^5.17.0", 40 | "@testing-library/react": "^15.0.7", 41 | "@types/jest": "^29.5.14", 42 | "@types/react": "^18.3.12", 43 | "@types/react-dom": "^18.3.1", 44 | "@typescript-eslint/eslint-plugin": "^5.62.0", 45 | "@typescript-eslint/parser": "^5.62.0", 46 | "@vitejs/plugin-react": "^4.4.1", 47 | "eslint": "^8.57.1", 48 | "eslint-config-prettier": "^10.1.2", 49 | "eslint-plugin-prettier": "^5.2.6", 50 | "eslint-plugin-react": "^7.37.5", 51 | "eslint-plugin-react-hooks": "^5.2.0", 52 | "husky": "^9.1.7", 53 | "jest": "^29.7.0", 54 | "jest-environment-jsdom": "^29.7.0", 55 | "lint-staged": "^15.5.1", 56 | "prettier": "3.0.3", 57 | "typescript": "^5.8.3", 58 | "vite": "^6.3.4", 59 | "vite-plugin-environment": "^1.1.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ederssouza/reactjs-auth-boilerplate/e3433019d11836f28c38c8b68b2114482a382d80/public/.gitkeep -------------------------------------------------------------------------------- /scripts/typeCheckStaged.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | 3 | const stagedFiles = process.argv.slice(2) 4 | const tsFiles = stagedFiles.filter( 5 | (file) => file.endsWith('.ts') || file.endsWith('.tsx') 6 | ) 7 | 8 | if (tsFiles.length > 0) { 9 | execSync('tsc --project ./tsconfig.json --noEmit', { stdio: 'inherit' }) 10 | } 11 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom' 2 | import { NavBar } from './components' 3 | import { AuthProvider } from './providers' 4 | import { Router } from './router' 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default App 18 | -------------------------------------------------------------------------------- /src/components/CanAccess/CanAccess.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import CanAccess from './CanAccess' 3 | import { useSession } from '@/hooks' 4 | import { validateUserPermissions } from '@/utils' 5 | 6 | jest.mock('@/hooks/useSession', () => ({ 7 | useSession: jest.fn() 8 | })) 9 | 10 | jest.mock('@/utils/validateUserPermissions', () => ({ 11 | validateUserPermissions: jest.fn() 12 | })) 13 | 14 | describe('CanAccess component', () => { 15 | beforeEach(() => { 16 | ;(useSession as jest.Mock).mockReturnValue({ 17 | isAuthenticated: true 18 | }) 19 | }) 20 | 21 | describe('when the user does not have permission', () => { 22 | it('should not render child component', () => { 23 | ;(validateUserPermissions as jest.Mock).mockReturnValue({ 24 | hasAllPermissions: false, 25 | hasAllRoles: false 26 | }) 27 | 28 | render(Sample component) 29 | 30 | expect(screen.queryByText('Sample component')).not.toBeInTheDocument() 31 | }) 32 | }) 33 | 34 | describe('when the user has permission', () => { 35 | it('should render child component', () => { 36 | ;(validateUserPermissions as jest.Mock).mockReturnValue({ 37 | hasAllPermissions: true, 38 | hasAllRoles: true 39 | }) 40 | 41 | render(Sample component) 42 | 43 | expect(screen.getByText('Sample component')).toBeInTheDocument() 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/CanAccess/CanAccess.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { useSession } from '@/hooks' 3 | import { validateUserPermissions } from '@/utils' 4 | 5 | type Props = { 6 | children: ReactNode 7 | permissions?: string[] 8 | roles?: string[] 9 | } 10 | 11 | function CanAccess(props: Props) { 12 | const { children, permissions, roles } = props 13 | 14 | const { isAuthenticated, user } = useSession() 15 | const { hasAllPermissions, hasAllRoles } = validateUserPermissions({ 16 | user, 17 | permissions, 18 | roles 19 | }) 20 | 21 | if (!isAuthenticated || !hasAllPermissions || !hasAllRoles) { 22 | return null 23 | } 24 | 25 | return <>{children} 26 | } 27 | 28 | export default CanAccess 29 | -------------------------------------------------------------------------------- /src/components/CanAccess/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CanAccess } from './CanAccess' 2 | -------------------------------------------------------------------------------- /src/components/ErrorState/ErrorState.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import ErrorState from './ErrorState' 3 | 4 | describe('ErrorState | component | unit test', () => { 5 | it('should render with success', () => { 6 | render() 7 | 8 | expect( 9 | screen.getByText('An internal error occurred on the server') 10 | ).toBeInTheDocument() 11 | }) 12 | 13 | describe('when text is passed', () => { 14 | it('should render with success', () => { 15 | render() 16 | 17 | expect(screen.getByText('Custom error message')).toBeInTheDocument() 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/components/ErrorState/ErrorState.tsx: -------------------------------------------------------------------------------- 1 | export type Props = { 2 | text?: string 3 | } 4 | 5 | function ErrorState(props: Props) { 6 | const { text = 'An internal error occurred on the server' } = props 7 | 8 | return
{text}
9 | } 10 | 11 | export default ErrorState 12 | -------------------------------------------------------------------------------- /src/components/ErrorState/index.ts: -------------------------------------------------------------------------------- 1 | export type { Props as ErrorStateProps } from './ErrorState' 2 | export { default as ErrorState } from './ErrorState' 3 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Loader from './Loader' 3 | 4 | describe('Loader | component | unit test', () => { 5 | it('should render with success', () => { 6 | render() 7 | 8 | expect(screen.getByText('Loading...')).toBeInTheDocument() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | function Loader() { 2 | return
Loading...
3 | } 4 | 5 | export default Loader 6 | -------------------------------------------------------------------------------- /src/components/Loader/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Loader } from './Loader' 2 | -------------------------------------------------------------------------------- /src/components/NavBar/NavBar.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { ReactNode } from 'react' 3 | import { MemoryRouter } from 'react-router-dom' 4 | import { AuthContext } from '@/contexts' 5 | import { paths } from '@/router' 6 | import NavBar from './NavBar' 7 | 8 | const providerUserUnloggedMock = { 9 | signIn: jest.fn(), 10 | signOut: jest.fn(), 11 | user: undefined, 12 | isAuthenticated: false, 13 | loadingUserData: false 14 | } 15 | 16 | const providerUserLoggedMock = { 17 | signIn: jest.fn(), 18 | signOut: jest.fn(), 19 | user: { 20 | email: 'email@site.com', 21 | permissions: ['users.list', 'metrics.list'], 22 | roles: [] 23 | }, 24 | isAuthenticated: true, 25 | loadingUserData: false 26 | } 27 | 28 | type WrapperProps = { 29 | children: ReactNode 30 | } 31 | 32 | function wrapper(props: WrapperProps) { 33 | const { children } = props 34 | 35 | return {children} 36 | } 37 | 38 | describe('NavBar component', () => { 39 | it('should render with success', () => { 40 | render( 41 | 42 | 43 | , 44 | { wrapper } 45 | ) 46 | 47 | expect(screen.getByText(/Login/)).toHaveAttribute('href', paths.LOGIN_PATH) 48 | }) 49 | 50 | describe('when the user is authenticated', () => { 51 | it('should show user email', () => { 52 | render( 53 | 54 | 55 | , 56 | { wrapper } 57 | ) 58 | 59 | expect(screen.getByText(/email@site\.com/)).toBeInTheDocument() 60 | }) 61 | }) 62 | 63 | describe('when the user clicks on the logout button', () => { 64 | it('should logout user', () => { 65 | render( 66 | 67 | 68 | , 69 | { wrapper } 70 | ) 71 | 72 | const logoutButton = screen.getByRole('button', { name: /logout/i }) 73 | 74 | fireEvent.click(logoutButton) 75 | 76 | expect(providerUserLoggedMock.signOut).toBeCalledTimes(1) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/components/NavBar/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { useRoutePaths, useSession } from '@/hooks' 2 | import { Link } from 'react-router-dom' 3 | import { CanAccess } from '../CanAccess' 4 | 5 | function NavBar() { 6 | const { isAuthenticated, user, signOut } = useSession() 7 | const { LOGIN_PATH, METRICS_PATH, REGISTER_PATH, ROOT_PATH, USERS_PATH } = 8 | useRoutePaths() 9 | 10 | return ( 11 |
12 |
    13 |
  • 14 | Login 15 |
  • 16 |
  • 17 | Register 18 |
  • 19 |
  • 20 | Home 21 |
  • 22 | 23 | 24 |
  • 25 | Users 26 |
  • 27 |
    28 | 29 | 30 |
  • 31 | Metrics 32 |
  • 33 |
    34 |
35 | 36 | {isAuthenticated && ( 37 | <> 38 | {user?.email} 39 | 40 | 41 | )} 42 |
43 | ) 44 | } 45 | 46 | export default NavBar 47 | -------------------------------------------------------------------------------- /src/components/NavBar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NavBar } from './NavBar' 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CanAccess' 2 | export * from './ErrorState' 3 | export * from './Loader' 4 | export * from './NavBar' 5 | -------------------------------------------------------------------------------- /src/contexts/AuthContext/AuthContext.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen, waitFor } from '@testing-library/react' 2 | import { useSession } from '@/hooks' 3 | import { AuthProvider } from '@/providers' 4 | import { api } from '@/services' 5 | import { paths } from '@/router' 6 | 7 | const mockNavigate = jest.fn() 8 | 9 | jest.mock('@/services/api') 10 | jest.mock('react-router-dom', () => ({ 11 | useNavigate: () => mockNavigate, 12 | useLocation: () => ({ 13 | pathname: '/' 14 | }) 15 | })) 16 | 17 | function SampleComponent() { 18 | const { signIn, signOut } = useSession() 19 | 20 | return ( 21 |
22 | 32 | 33 | 34 |
35 | ) 36 | } 37 | 38 | function customRender() { 39 | render( 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | describe('AuthProvider', () => { 47 | afterEach(() => { 48 | jest.clearAllMocks() 49 | }) 50 | 51 | describe('when invoked and return valid response', () => { 52 | it('should dispatch signIn function', async () => { 53 | const responseMock = { 54 | data: { 55 | token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 56 | refreshToken: '84ee647c-ac74-4e34-bb84-1bd6c96b3977', 57 | permissions: ['users.list', 'users.create', 'metrics.list'], 58 | roles: ['administrator'] 59 | } 60 | } 61 | 62 | ;(api.post as jest.Mock).mockReturnValueOnce(responseMock) 63 | 64 | customRender() 65 | 66 | const signInButton = screen.getByRole('button', { name: /sign in/i }) 67 | 68 | fireEvent.click(signInButton) 69 | 70 | await waitFor( 71 | () => { 72 | expect(api.post).toHaveBeenCalledTimes(1) 73 | expect(api.post).toHaveReturnedWith(responseMock) 74 | }, 75 | { timeout: 1000 } 76 | ) 77 | }) 78 | }) 79 | 80 | describe('when invoked and return invalid response', () => { 81 | it('should dispatch signIn function', async () => { 82 | ;(api.post as jest.Mock).mockRejectedValueOnce({}) 83 | 84 | customRender() 85 | 86 | const signInButton = screen.getByRole('button', { name: /sign in/i }) 87 | 88 | fireEvent.click(signInButton) 89 | 90 | await waitFor( 91 | () => { 92 | expect(api.post).toHaveBeenCalledTimes(1) 93 | }, 94 | { timeout: 1000 } 95 | ) 96 | }) 97 | }) 98 | 99 | describe('when the request to `/me` endpoint returns valid data', () => { 100 | it('should return valid paylod on make `/me`', async () => { 101 | const responseMock = { 102 | data: { 103 | email: 'admin@site.com', 104 | permissions: ['users.list', 'users.create', 'metrics.list'], 105 | roles: ['administrator'] 106 | } 107 | } 108 | 109 | ;(api.get as jest.Mock).mockReturnValueOnce(responseMock) 110 | 111 | customRender() 112 | 113 | await waitFor( 114 | () => { 115 | expect(api.get).toHaveBeenCalledTimes(1) 116 | expect(api.get).toHaveReturnedWith(responseMock) 117 | }, 118 | { timeout: 1000 } 119 | ) 120 | }) 121 | }) 122 | 123 | describe('when the request to `/me` endpoint returns invalid data', () => { 124 | it('should return an error', async () => { 125 | ;(api.get as jest.Mock).mockRejectedValueOnce({}) 126 | 127 | customRender() 128 | 129 | await waitFor( 130 | () => { 131 | expect(api.get).toHaveBeenCalledTimes(1) 132 | }, 133 | { timeout: 1000 } 134 | ) 135 | }) 136 | }) 137 | 138 | describe('when the user clicks on the logout button', () => { 139 | it('should dispatch signOut function', async () => { 140 | customRender() 141 | 142 | const signOutButton = screen.getByRole('button', { name: /sign out/i }) 143 | 144 | fireEvent.click(signOutButton) 145 | 146 | await waitFor(() => { 147 | expect(mockNavigate).toHaveBeenCalledTimes(1) 148 | expect(mockNavigate).toBeCalledWith(paths.LOGIN_PATH) 149 | }) 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /src/contexts/AuthContext/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import { createContext } from 'react' 3 | 4 | export type User = { 5 | email: string 6 | permissions: string[] 7 | roles: string[] 8 | } 9 | 10 | export type SignInCredentials = { 11 | email: string 12 | password: string 13 | } 14 | 15 | export type AuthContextData = { 16 | user?: User 17 | isAuthenticated: boolean 18 | loadingUserData: boolean 19 | signIn: (credentials: SignInCredentials) => Promise 20 | signOut: () => void 21 | } 22 | 23 | const AuthContext = createContext({} as AuthContextData) 24 | 25 | export default AuthContext 26 | -------------------------------------------------------------------------------- /src/contexts/AuthContext/index.ts: -------------------------------------------------------------------------------- 1 | export type { AuthContextData, SignInCredentials, User } from './AuthContext' 2 | export { default as AuthContext } from './AuthContext' 3 | -------------------------------------------------------------------------------- /src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthContext' 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useRoutePaths' 2 | export * from './useSession' 3 | -------------------------------------------------------------------------------- /src/hooks/useRoutePaths/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useRoutePaths } from './useRoutePaths' 2 | -------------------------------------------------------------------------------- /src/hooks/useRoutePaths/useRoutePaths.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react' 2 | import { paths } from '@/router' 3 | import useRoutePaths from './useRoutePaths' 4 | 5 | describe('useRoutePaths | hook | integration test', () => { 6 | it('should return paths object', () => { 7 | const { result } = renderHook(useRoutePaths) 8 | 9 | expect(result.current).toEqual(expect.objectContaining({ ...paths })) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/hooks/useRoutePaths/useRoutePaths.ts: -------------------------------------------------------------------------------- 1 | import { paths } from '@/router' 2 | 3 | function useRoutePaths() { 4 | return paths 5 | } 6 | 7 | export default useRoutePaths 8 | -------------------------------------------------------------------------------- /src/hooks/useSession/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useSession } from './useSession' 2 | -------------------------------------------------------------------------------- /src/hooks/useSession/useSession.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { AuthContext } from '@/contexts' 3 | 4 | function useSession() { 5 | return useContext(AuthContext) 6 | } 7 | 8 | export default useSession 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client' 2 | import App from './App' 3 | 4 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 5 | 6 | ) 7 | -------------------------------------------------------------------------------- /src/pages/Home/Home.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Home from './Home' 3 | 4 | describe('Home page component', () => { 5 | it('should render with success', () => { 6 | render() 7 | 8 | expect( 9 | screen.getByRole('heading', { 10 | name: 'Home', 11 | level: 1 12 | }) 13 | ).toBeInTheDocument() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/pages/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | function Home() { 2 | return ( 3 |
4 |

Home

5 |
6 | ) 7 | } 8 | 9 | export default Home 10 | -------------------------------------------------------------------------------- /src/pages/Home/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Home } from './Home' 2 | -------------------------------------------------------------------------------- /src/pages/Login/Login.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen, waitFor } from '@testing-library/react' 2 | import { AuthContext } from '@/contexts' 3 | import Login from './Login' 4 | 5 | const providerUserUnloggedMock = { 6 | user: undefined, 7 | isAuthenticated: false, 8 | loadingUserData: false, 9 | signIn: jest.fn(), 10 | signOut: jest.fn() 11 | } 12 | 13 | describe('Login page component', () => { 14 | beforeEach(() => { 15 | render( 16 | 17 | 18 | 19 | ) 20 | }) 21 | 22 | describe('when user select user test in select box', () => { 23 | it('should have a value on input on the fields', () => { 24 | const select = screen.getByRole('combobox') as HTMLSelectElement 25 | const inputEmail = screen.getByLabelText(/email/i) as HTMLInputElement 26 | const inputPassword = screen.getByLabelText( 27 | /password/i 28 | ) as HTMLInputElement 29 | 30 | fireEvent.change(select, { 31 | target: { 32 | value: 33 | '{"name":"Client","email":"client@site.com","password":"password@123"}' 34 | } 35 | }) 36 | 37 | fireEvent.change(inputEmail, { target: { value: 'client@site.com' } }) 38 | fireEvent.change(inputPassword, { target: { value: 'password@123' } }) 39 | 40 | expect(inputEmail.value).toEqual('client@site.com') 41 | expect(inputPassword.value).toEqual('password@123') 42 | }) 43 | }) 44 | 45 | describe('when inputting email and password on the fields', () => { 46 | it('should have a value in the inputs', () => { 47 | const inputEmail = screen.getByLabelText(/email/i) as HTMLInputElement 48 | const inputPassword = screen.getByLabelText( 49 | /password/i 50 | ) as HTMLInputElement 51 | 52 | fireEvent.change(inputEmail, { target: { value: 'email@site.com' } }) 53 | fireEvent.change(inputPassword, { target: { value: 'pass@123' } }) 54 | 55 | expect(inputEmail.value).toEqual('email@site.com') 56 | expect(inputPassword.value).toEqual('pass@123') 57 | }) 58 | }) 59 | 60 | it('should disabled button when submit form', async () => { 61 | const button = screen.getByRole('button', { 62 | name: /submit/i 63 | }) as HTMLButtonElement 64 | 65 | expect(button).not.toHaveAttribute('disabled') 66 | expect(button).toHaveTextContent(/Submit/) 67 | 68 | fireEvent.click(button) 69 | 70 | await waitFor( 71 | () => { 72 | expect(button).toHaveAttribute('disabled') 73 | expect(button).toHaveTextContent(/Loading/) 74 | }, 75 | { timeout: 1000 } 76 | ) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/pages/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, useEffect, useState } from 'react' 2 | import { useSession } from '@/hooks' 3 | 4 | function initialFormValues() { 5 | return { 6 | email: '', 7 | password: '' 8 | } 9 | } 10 | 11 | function Login() { 12 | const [values, setValues] = useState(initialFormValues) 13 | const [loginRequestStatus, setLoginRequestStatus] = useState('success') 14 | const { signIn } = useSession() 15 | 16 | const users = [ 17 | { name: 'Admin', email: 'admin@site.com', password: 'password@123' }, 18 | { name: 'Client', email: 'client@site.com', password: 'password@123' } 19 | ] 20 | 21 | function handleUserChange(event: React.ChangeEvent) { 22 | const user = event.target.value 23 | setValues(JSON.parse(user)) 24 | } 25 | 26 | function handleChange(event: React.ChangeEvent) { 27 | const { name, value } = event.target 28 | 29 | setValues({ 30 | ...values, 31 | [name]: value 32 | }) 33 | } 34 | 35 | async function handleSubmit(e: FormEvent) { 36 | e.preventDefault() 37 | 38 | setLoginRequestStatus('loading') 39 | 40 | try { 41 | await signIn(values) 42 | } catch (error) { 43 | /** 44 | * an error handler can be added here 45 | */ 46 | } finally { 47 | setLoginRequestStatus('success') 48 | } 49 | } 50 | 51 | useEffect(() => { 52 | // clean the function to prevent memory leak 53 | return () => setLoginRequestStatus('success') 54 | }, []) 55 | 56 | return ( 57 |
58 |
59 | 69 | 70 |
71 | 72 | 80 |
81 | 82 |
83 | 84 | 92 |
93 | 94 | 97 |
98 |
99 | ) 100 | } 101 | 102 | export default Login 103 | -------------------------------------------------------------------------------- /src/pages/Login/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Login } from './Login' 2 | -------------------------------------------------------------------------------- /src/pages/Metrics/Metrics.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Metrics from './Metrics' 3 | 4 | describe('Metrics page component', () => { 5 | it('should render with success', () => { 6 | render() 7 | 8 | expect( 9 | screen.getByRole('heading', { 10 | name: 'Metrics', 11 | level: 1 12 | }) 13 | ).toBeInTheDocument() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/pages/Metrics/Metrics.tsx: -------------------------------------------------------------------------------- 1 | function Metrics() { 2 | return ( 3 |
4 |

Metrics

5 |
6 | ) 7 | } 8 | 9 | export default Metrics 10 | -------------------------------------------------------------------------------- /src/pages/Metrics/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Metrics } from './Metrics' 2 | -------------------------------------------------------------------------------- /src/pages/Register/Register.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Register from './Register' 3 | 4 | describe('Register page component', () => { 5 | it('should render with success', () => { 6 | render() 7 | 8 | expect( 9 | screen.getByRole('heading', { 10 | name: 'Register', 11 | level: 1 12 | }) 13 | ).toBeInTheDocument() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/pages/Register/Register.tsx: -------------------------------------------------------------------------------- 1 | function Register() { 2 | return ( 3 |
4 |

Register

5 |
6 | ) 7 | } 8 | 9 | export default Register 10 | -------------------------------------------------------------------------------- /src/pages/Register/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Register } from './Register' 2 | -------------------------------------------------------------------------------- /src/pages/Users/Users.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import { api } from '@/services/api' 3 | import Users from './Users' 4 | 5 | jest.mock('@/services/api') 6 | 7 | describe('Users page component', () => { 8 | describe('when the request returns valid data', () => { 9 | it('should render a list of users', async () => { 10 | const responseMock = { 11 | data: { 12 | users: [ 13 | { id: 1, name: 'User 1', email: 'user1@site.com' }, 14 | { id: 2, name: 'User 2', email: 'user2@site.com' } 15 | ] 16 | } 17 | } 18 | 19 | ;(api.get as jest.Mock).mockReturnValueOnce(responseMock) 20 | 21 | render() 22 | 23 | await waitFor( 24 | () => { 25 | expect(screen.getByText(/User 1/)).toBeInTheDocument() 26 | expect(screen.getByText(/User 2/)).toBeInTheDocument() 27 | }, 28 | { timeout: 1000 } 29 | ) 30 | }) 31 | }) 32 | 33 | describe('when the request does not return the payload', () => { 34 | it('should render empty list message', async () => { 35 | ;(api.get as jest.Mock).mockReturnValueOnce({ data: {} }) 36 | 37 | render() 38 | 39 | await waitFor( 40 | () => { 41 | expect(screen.getByText(/empty user list/)).toBeInTheDocument() 42 | }, 43 | { timeout: 1000 } 44 | ) 45 | }) 46 | }) 47 | 48 | describe('when the request does not return the data attribute', () => { 49 | it('should render empty list message', async () => { 50 | ;(api.get as jest.Mock).mockRejectedValueOnce({}) 51 | 52 | render() 53 | 54 | await waitFor( 55 | () => { 56 | expect(screen.getByText(/empty user list/)).toBeInTheDocument() 57 | }, 58 | { timeout: 1000 } 59 | ) 60 | }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/pages/Users/Users.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import { useEffect, useState } from 'react' 3 | import { api } from '@/services' 4 | 5 | type User = { 6 | id: number 7 | name: string 8 | } 9 | 10 | function Users() { 11 | const [users, setUsers] = useState([]) 12 | 13 | useEffect(() => { 14 | async function loadUsers() { 15 | try { 16 | const response = await api.get('/users') 17 | const users = response?.data?.users || [] 18 | setUsers(users) 19 | } catch (error) { 20 | const err = error as AxiosError 21 | return err 22 | } 23 | } 24 | 25 | loadUsers() 26 | }, []) 27 | 28 | return ( 29 |
30 |

Users

31 | 32 |
    33 | {users?.length > 0 ? ( 34 | users.map((user) => ( 35 |
  • 36 | ID: {user.id} Name: {user.name} 37 |
  • 38 | )) 39 | ) : ( 40 |
  • empty user list
  • 41 | )} 42 |
43 |
44 | ) 45 | } 46 | 47 | export default Users 48 | -------------------------------------------------------------------------------- /src/pages/Users/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Users } from './Users' 2 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Home' 2 | export * from './Login' 3 | export * from './Metrics' 4 | export * from './Register' 5 | export * from './Users' 6 | -------------------------------------------------------------------------------- /src/providers/AuthProvider/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | import { AxiosError } from 'axios' 4 | import { AuthContext, SignInCredentials, User } from '@/contexts' 5 | import { paths } from '@/router' 6 | import { api, setAuthorizationHeader } from '@/services' 7 | import { createSessionCookies, getToken, removeSessionCookies } from '@/utils' 8 | 9 | type Props = { 10 | children: ReactNode 11 | } 12 | 13 | function AuthProvider(props: Props) { 14 | const { children } = props 15 | 16 | const [user, setUser] = useState() 17 | const [loadingUserData, setLoadingUserData] = useState(true) 18 | const navigate = useNavigate() 19 | const { pathname } = useLocation() 20 | 21 | const token = getToken() 22 | const isAuthenticated = Boolean(token) 23 | 24 | async function signIn(params: SignInCredentials) { 25 | const { email, password } = params 26 | 27 | try { 28 | const response = await api.post('/sessions', { email, password }) 29 | const { token, refreshToken, permissions, roles } = response.data 30 | 31 | createSessionCookies({ token, refreshToken }) 32 | setUser({ email, permissions, roles }) 33 | setAuthorizationHeader({ request: api.defaults, token }) 34 | } catch (error) { 35 | const err = error as AxiosError 36 | return err 37 | } 38 | } 39 | 40 | function signOut() { 41 | removeSessionCookies() 42 | setUser(undefined) 43 | setLoadingUserData(false) 44 | navigate(paths.LOGIN_PATH) 45 | } 46 | 47 | useEffect(() => { 48 | if (!token) { 49 | removeSessionCookies() 50 | setUser(undefined) 51 | setLoadingUserData(false) 52 | } 53 | }, [navigate, pathname, token]) 54 | 55 | useEffect(() => { 56 | const token = getToken() 57 | 58 | async function getUserData() { 59 | setLoadingUserData(true) 60 | 61 | try { 62 | const response = await api.get('/me') 63 | 64 | if (response?.data) { 65 | const { email, permissions, roles } = response.data 66 | setUser({ email, permissions, roles }) 67 | } 68 | } catch (error) { 69 | /** 70 | * an error handler can be added here 71 | */ 72 | } finally { 73 | setLoadingUserData(false) 74 | } 75 | } 76 | 77 | if (token) { 78 | setAuthorizationHeader({ request: api.defaults, token }) 79 | getUserData() 80 | } 81 | }, []) 82 | 83 | return ( 84 | 93 | {children} 94 | 95 | ) 96 | } 97 | 98 | export default AuthProvider 99 | -------------------------------------------------------------------------------- /src/providers/AuthProvider/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthProvider } from './AuthProvider' 2 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthProvider' 2 | -------------------------------------------------------------------------------- /src/router/PrivateRoute/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, Suspense } from 'react' 2 | import { Navigate } from 'react-router-dom' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { ErrorState, Loader } from '@/components' 5 | import { useSession } from '@/hooks' 6 | import { validateUserPermissions } from '@/utils' 7 | 8 | type Props = { 9 | permissions?: string[] 10 | roles?: string[] 11 | redirectTo?: string 12 | children: ReactNode 13 | } 14 | 15 | function PrivateRoute(props: Props) { 16 | const { permissions, roles, redirectTo = '/login', children } = props 17 | 18 | const { isAuthenticated, user, loadingUserData } = useSession() 19 | const { hasAllPermissions } = validateUserPermissions({ 20 | user, 21 | permissions, 22 | roles 23 | }) 24 | 25 | if (loadingUserData) { 26 | return null 27 | } 28 | 29 | if (!isAuthenticated) { 30 | return 31 | } 32 | 33 | if (!hasAllPermissions) { 34 | return 35 | } 36 | 37 | return ( 38 | } 40 | > 41 | }>{children} 42 | 43 | ) 44 | } 45 | 46 | export default PrivateRoute 47 | -------------------------------------------------------------------------------- /src/router/PrivateRoute/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PrivateRoute } from './PrivateRoute' 2 | -------------------------------------------------------------------------------- /src/router/PublicRoute/PublicRoute.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, Suspense } from 'react' 2 | import { Navigate } from 'react-router-dom' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { ErrorState, Loader } from '@/components' 5 | import { useSession } from '@/hooks' 6 | 7 | type Props = { 8 | children: ReactNode 9 | } 10 | 11 | function PublicRoute(props: Props) { 12 | const { children } = props 13 | 14 | const { isAuthenticated } = useSession() 15 | 16 | if (isAuthenticated) { 17 | return 18 | } 19 | 20 | return ( 21 | } 23 | > 24 | }>{children} 25 | 26 | ) 27 | } 28 | 29 | export default PublicRoute 30 | -------------------------------------------------------------------------------- /src/router/PublicRoute/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PublicRoute } from './PublicRoute' 2 | -------------------------------------------------------------------------------- /src/router/Router/Router.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom' 2 | import { useRoutePaths } from '@/hooks' 3 | import { Home, Login, Metrics, Register, Users } from '@/pages' 4 | import { PrivateRoute } from '../PrivateRoute' 5 | import { PublicRoute } from '../PublicRoute' 6 | 7 | function Router() { 8 | const { 9 | LOGIN_PATH, 10 | METRICS_PATH, 11 | REGISTER_PATH, 12 | ROOT_PATH, 13 | USERS_PATH, 14 | USER_PATH 15 | } = useRoutePaths() 16 | 17 | return ( 18 | 19 | 23 | 24 | 25 | } 26 | /> 27 | 28 | 32 | 33 | 34 | } 35 | /> 36 | 37 | } /> 38 | 39 | 43 | 44 | 45 | } 46 | /> 47 | 48 | 52 | 53 | 54 | } 55 | /> 56 | 57 | 61 | 62 | 63 | } 64 | /> 65 | 66 | 404} /> 67 | 68 | ) 69 | } 70 | 71 | export default Router 72 | -------------------------------------------------------------------------------- /src/router/Router/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Router } from './Router' 2 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paths' 2 | export * from './PrivateRoute' 3 | export * from './PublicRoute' 4 | export * from './Router' 5 | -------------------------------------------------------------------------------- /src/router/paths/index.ts: -------------------------------------------------------------------------------- 1 | export { default as paths } from './paths' 2 | -------------------------------------------------------------------------------- /src/router/paths/paths.ts: -------------------------------------------------------------------------------- 1 | const ROOT_PATH = '/' 2 | const LOGIN_PATH = '/login' 3 | const REGISTER_PATH = '/register' 4 | const METRICS_PATH = '/metrics' 5 | const USERS_PATH = '/users' 6 | const USER_PATH = '/users/:id' 7 | 8 | const paths = { 9 | ROOT_PATH, 10 | LOGIN_PATH, 11 | REGISTER_PATH, 12 | METRICS_PATH, 13 | USERS_PATH, 14 | USER_PATH 15 | } as const 16 | 17 | export default paths 18 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { setupInterceptors } from './interceptors' 4 | 5 | export const api = setupInterceptors( 6 | axios.create({ 7 | baseURL: process.env.REACT_APP_API_URL 8 | }) 9 | ) 10 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export * from './interceptors' 3 | -------------------------------------------------------------------------------- /src/services/interceptors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AxiosDefaults, 3 | AxiosError, 4 | AxiosInstance, 5 | AxiosRequestConfig, 6 | AxiosResponse, 7 | InternalAxiosRequestConfig 8 | } from 'axios' 9 | import { 10 | createSessionCookies, 11 | getRefreshToken, 12 | getToken, 13 | removeSessionCookies 14 | } from '@/utils' 15 | import { paths } from '@/router' 16 | import { api } from './api' 17 | 18 | type FailedRequestQueue = { 19 | onSuccess: (token: string) => void 20 | onFailure: (error: AxiosError) => void 21 | } 22 | 23 | let isRefreshing = false 24 | let failedRequestQueue: FailedRequestQueue[] = [] 25 | 26 | type SetAuthorizationHeaderParams = { 27 | request: AxiosDefaults | AxiosRequestConfig 28 | token: string 29 | } 30 | 31 | export function setAuthorizationHeader(params: SetAuthorizationHeaderParams) { 32 | const { request, token } = params 33 | 34 | ;(request.headers as Record)[ 35 | 'Authorization' 36 | ] = `Bearer ${token}` 37 | } 38 | 39 | function handleRefreshToken(refreshToken: string) { 40 | isRefreshing = true 41 | 42 | api 43 | .post( 44 | '/refresh', 45 | { refreshToken }, 46 | { 47 | headers: { 48 | Authorization: `Bearer ${getToken()}` 49 | } 50 | } 51 | ) 52 | .then((response) => { 53 | const { token } = response.data 54 | 55 | createSessionCookies({ token, refreshToken: response.data.refreshToken }) 56 | setAuthorizationHeader({ request: api.defaults, token }) 57 | 58 | failedRequestQueue.forEach((request) => request.onSuccess(token)) 59 | failedRequestQueue = [] 60 | }) 61 | .catch((error) => { 62 | failedRequestQueue.forEach((request) => request.onFailure(error)) 63 | failedRequestQueue = [] 64 | 65 | removeSessionCookies() 66 | }) 67 | .finally(() => { 68 | isRefreshing = false 69 | }) 70 | } 71 | 72 | function onRequest(config: AxiosRequestConfig) { 73 | const token = getToken() 74 | 75 | if (token) { 76 | setAuthorizationHeader({ request: config, token }) 77 | } 78 | 79 | return config as InternalAxiosRequestConfig 80 | } 81 | 82 | function onRequestError(error: AxiosError): Promise { 83 | return Promise.reject(error) 84 | } 85 | 86 | function onResponse(response: AxiosResponse): AxiosResponse { 87 | return response 88 | } 89 | 90 | type ErrorCode = { 91 | code: string 92 | } 93 | 94 | function onResponseError( 95 | error: AxiosError 96 | ): Promise { 97 | if (error?.response?.status === 401) { 98 | if (error.response?.data?.code === 'token.expired') { 99 | const originalConfig = error.config as AxiosRequestConfig 100 | const refreshToken = getRefreshToken() 101 | 102 | if (!isRefreshing) { 103 | handleRefreshToken(refreshToken) 104 | } 105 | 106 | return new Promise((resolve, reject) => { 107 | failedRequestQueue.push({ 108 | onSuccess: (token: string) => { 109 | setAuthorizationHeader({ request: originalConfig, token }) 110 | resolve(api(originalConfig)) 111 | }, 112 | onFailure: (error: AxiosError) => { 113 | reject(error) 114 | } 115 | }) 116 | }) 117 | } else { 118 | removeSessionCookies() 119 | window.location.href = paths.LOGIN_PATH 120 | } 121 | } 122 | 123 | return Promise.reject(error) 124 | } 125 | 126 | export function setupInterceptors(axiosInstance: AxiosInstance): AxiosInstance { 127 | axiosInstance.interceptors.request.use(onRequest, onRequestError) 128 | axiosInstance.interceptors.response.use(onResponse, onResponseError) 129 | 130 | return axiosInstance 131 | } 132 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // seconds * minutes * hours * days 2 | export const COOKIE_EXPIRATION_TIME = 60 * 60 * 24 // 1 day 3 | export const TOKEN_COOKIE = 'reactauth.token' 4 | export const REFRESH_TOKEN_COOKIE = 'reactauth.refreshToken' 5 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './tokenCookies' 3 | export * from './validateUserPermissions' 4 | -------------------------------------------------------------------------------- /src/utils/tokenCookies.ts: -------------------------------------------------------------------------------- 1 | import { destroyCookie, parseCookies, setCookie } from 'nookies' 2 | import { 3 | COOKIE_EXPIRATION_TIME, 4 | REFRESH_TOKEN_COOKIE, 5 | TOKEN_COOKIE 6 | } from '@/utils' 7 | 8 | type CreateSessionCookiesParams = { 9 | token?: string 10 | refreshToken?: string 11 | } 12 | 13 | export function createSessionCookies(params: CreateSessionCookiesParams) { 14 | const { token, refreshToken } = params 15 | 16 | if (token) { 17 | setCookie(null, TOKEN_COOKIE, token, { 18 | maxAge: COOKIE_EXPIRATION_TIME, 19 | path: '/' 20 | }) 21 | } 22 | 23 | if (refreshToken) { 24 | setCookie(null, REFRESH_TOKEN_COOKIE, refreshToken, { 25 | maxAge: COOKIE_EXPIRATION_TIME, 26 | path: '/' 27 | }) 28 | } 29 | } 30 | 31 | export function removeSessionCookies() { 32 | destroyCookie(null, TOKEN_COOKIE) 33 | destroyCookie(null, REFRESH_TOKEN_COOKIE) 34 | } 35 | 36 | export function getToken() { 37 | const cookies = parseCookies() 38 | return cookies[TOKEN_COOKIE] 39 | } 40 | 41 | export function getRefreshToken() { 42 | const cookies = parseCookies() 43 | return cookies[REFRESH_TOKEN_COOKIE] 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/validateUserPermissions.ts: -------------------------------------------------------------------------------- 1 | type User = { 2 | permissions: string[] 3 | roles: string[] 4 | } 5 | 6 | type Params = { 7 | user?: User 8 | permissions?: string[] 9 | roles?: string[] 10 | } 11 | 12 | export function validateUserPermissions(params: Params) { 13 | const { user, permissions, roles } = params 14 | 15 | let hasAllPermissions = true 16 | let hasAllRoles = true 17 | 18 | if (permissions?.length) { 19 | const userPermissions = user?.permissions || [] 20 | 21 | hasAllPermissions = permissions.every((permission) => { 22 | return userPermissions.includes(permission) 23 | }) 24 | } 25 | 26 | if (roles?.length) { 27 | const userRoles = user?.roles || [] 28 | 29 | hasAllRoles = roles.every((role) => { 30 | return userRoles.includes(role) 31 | }) 32 | } 33 | 34 | return { hasAllPermissions, hasAllRoles } 35 | } 36 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import EnvironmentPlugin from 'vite-plugin-environment' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), EnvironmentPlugin('all')], 8 | server: { 9 | host: true, 10 | port: 3000 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': '/src' 15 | } 16 | } 17 | }) 18 | --------------------------------------------------------------------------------