├── .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 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
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 |
Logout
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 |
24 | signIn({
25 | email: 'email@site.com',
26 | password: 'password'
27 | })
28 | }
29 | >
30 | Sign in
31 |
32 |
33 | Sign out
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 |
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 |
--------------------------------------------------------------------------------