├── .babelrc.js ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── App.js ├── index.html └── index.js ├── package.json ├── rollup.config.js ├── routes └── package.json ├── src ├── Auth.tsx ├── __tests__ │ ├── Auth.test.tsx │ └── AuthDOM.test.tsx ├── actionCreators.ts ├── actionTypes.ts ├── authEffects.ts ├── bindActionCreators.ts ├── callApiRx.ts ├── hooks.ts ├── index.ts ├── reducer.ts ├── routes │ ├── AuthRoute.tsx │ ├── AuthRoutesContext.ts │ ├── AuthRoutesProvider.tsx │ ├── GuestRoute.tsx │ ├── MaybeAuthRoute.tsx │ ├── __tests__ │ │ └── Routes.test.tsx │ ├── index.ts │ ├── types.ts │ ├── useShallowMemo.ts │ └── utils.ts ├── storage.ts ├── types.ts ├── useConstant.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | ...(process.env.NODE_ENV === 'test' && { 7 | targets: { 8 | node: 'current', 9 | }, 10 | }), 11 | }, 12 | ], 13 | '@babel/preset-react', 14 | '@babel/preset-typescript', 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # misc 10 | .DS_Store 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 16 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 2.6.0 3 | 4 | ##### *July 20th, 2022* 5 | 6 | Make call rejection `401` checks working out of the box with library like [axios](https://axios-http.com). 7 | 8 | Istead of checks only: 9 | ```js 10 | error.status === 401 11 | ``` 12 | Checks also on response: 13 | ```js 14 | error.response.status === 401 15 | ``` 16 | 17 | Internally also moved the checks as an helper so in future releases can will be able to be passed as configuration option. 18 | 19 | 20 | ## 2.5.0 21 | 22 | ##### *April 04th, 2022* 23 | 24 | Support React 18 strict effects with reusable state for more info [see](https://github.com/reactwg/react-18/discussions/18) 25 | 26 | ### :zap: New features 27 | 28 | #### `useAuthCallObservable()` and `useAuthCallPromise()` 29 | 30 | These hooks make less cumberstone the integration with other fetching library. 31 | These hooks take a curried token effect fuction and return the same function with a less order. 32 | 33 | Here's the typings: 34 | ```ts 35 | type CurryAuthApiFnPromise = ( 36 | accessToken: A 37 | ) => (...args: FA) => Promise 38 | 39 | type CurryAuthApiFn = ( 40 | accessToken: A 41 | ) => (...args: FA) => Observable | Promise 42 | 43 | function useAuthCallObservable( 44 | fn: CurryAuthApiFn 45 | ): (...args: FA) => Observable 46 | 47 | function useAuthCallPromise( 48 | fn: CurryAuthApiFnPromise 49 | ): (...args: FA) => Promise 50 | ``` 51 | 52 | An example: 53 | 54 | ```ts 55 | function getProducts(token: string) { 56 | return (category: string) => fetch(`https://myapi/prodcuts/${category}`, { 57 | headers: { 58 | 'Authorization': `Bearer ${token}` 59 | } 60 | }).then(r => r.ok ? Promise.reject(r) : r.json() as Promise) 61 | } 62 | 63 | // (category: string) => Promise 64 | const wrappedGetProducts = useAuthCallPromise(getProducts) 65 | ``` 66 | 67 | The integration with fething libraries is less cumberstone and more funnny: 68 | 69 | ```js 70 | import { useQuery } from 'react-query' 71 | import { useAuthCallPromise } from 'use-eazy-auth' 72 | 73 | function useProducts() { 74 | return useQuery( 75 | ['products'], 76 | useAuthCallPromise( 77 | (token) => () => 78 | fetch('https://myapi/prodcuts', { 79 | headers: { 80 | Authorization: `Bearer ${token}`, 81 | }, 82 | }) 83 | ) 84 | ) 85 | } 86 | ``` 87 | 88 | 89 | ## 2.4.0 90 | 91 | ##### *November 02th, 2021* 92 | 93 | Call `onLogout` with last user access token. 94 | 95 | ## 2.3.0 96 | ##### *March 10th, 2021* 97 | 98 | Added optional `onAuthenticate` callback to ``, inoked when is authenticated 99 | by **use-eazy-auth**. 100 | 101 | Signature: 102 | ```ts 103 | (user: U, accessToken: A, fromLogin: boolean) => void 104 | ``` 105 | 106 | ## 2.2.0 107 | ##### *February 1th, 2021* 108 | 109 | Added optional `onLogout` callback to ``, inoked when user explicit logout 110 | (calling `logout` action) or is kicked out from `401` rejection in call api functions. 111 | 112 | ## 2.1.0 113 | ##### *January 20th, 2021* 114 | 115 | Add `initialData` prop to ``. 116 | Useful in SSR scenario when you need to init auth state and avoid running initial side effects. 117 | 118 | The `initialData` typing: 119 | 120 | ```ts 121 | interface InitialAuthData { 122 | accessToken: A | null 123 | refreshToken?: R | null 124 | expires?: number | null 125 | user: U | null 126 | } 127 | ``` 128 | 129 | ## 2.0.0 130 | ##### *January 1th, 2021* 131 | 132 | Types for `use-eazy-auth` :tada: ! 133 | Now `use-eazy-auth` is 100% typescript! 134 | 135 | ### :bangbang: Breaking changes 136 | 137 | All Routes `use-eazy-auth/routes` now has two different props to configure spinners. 138 | The `spinner` prop a `ReactNode` and a `spinnerComponent` prop a `ComponentType`. 139 | 140 | ```jsx 141 | }> 142 | 143 | 144 | 145 | 146 | 147 | ``` 148 | 149 | ### :zap: New features 150 | 151 | The `updateUser` function from `useAuthActions` can now acept a callback to 152 | execute a functional update similar to React `useState`. 153 | 154 | ```js 155 | const { updateUser } = useAuthActions() 156 | 157 | updateUser(user => ({ ...user, age: user.age + 1 })) 158 | ``` 159 | 160 | A new component `AuthRoutesProvider` is available from `use-eazy-auth/routes`, 161 | to configure common part of routes behaviours. 162 | All options can be overridden locally. 163 | 164 | Props availables: 165 | ```ts 166 | interface AuthRoutesConfig { 167 | guestRedirectTo?: string | Location 168 | authRedirectTo?: string | Location 169 | authRedirectTest?: (user: U) => string | null | undefined | Location 170 | spinner?: ReactNode 171 | spinnerComponent?: ComponentType 172 | rememberReferrer?: boolean 173 | redirectToReferrer?: boolean 174 | } 175 | ``` 176 | 177 | ```js 178 | // All with the same spinner 179 | }> 180 | {/* ... */} 181 | {/* wins and so on... */} 182 | }> 183 | 184 | 185 | 186 | ``` 187 | 188 | ## 1.4.0 189 | ##### *November 24th, 2020* 190 | 191 | Add `setTokens` action to explict set new tokens: 192 | 193 | ```js 194 | const { setTokens } = useAuthActions() 195 | setTokens({ accessToken: 'NEW_TOKEN' }) 196 | // or (if you support refresh token in your use-eazy-auth conf) 197 | setTokens({ accessToken: 'NEW_TOKEN', refreshToken: 'NEW_REFRESH' }) 198 | ``` 199 | 200 | ## 1.3.1 201 | ##### *October 27th, 2020* 202 | 203 | Support React 17 in peerDependencies and bump some build packages, nothing changed. 204 | 205 | ## 1.3.0 206 | ##### *September 18th, 2020* 207 | 208 | Fix bug that prevent retriggering login if previous login fail. 209 | 210 | ## 1.2.0 211 | ##### *August 28th, 2020* 212 | 213 | ### :heavy_exclamation_mark: DON'T INSTALL THIS VERSION 214 | *This version is bugged fixed in 1.3.0, sorry.* 215 | 216 | Fix bugged `redirectTest` in `` Component and document it. 217 | 218 | ## 1.1.0 219 | ##### *August 27th, 2020* 220 | 221 | ### :heavy_exclamation_mark: DON'T INSTALL THIS VERSION 222 | *This version is bugged fixed in 1.3.0, sorry.* 223 | 224 | Rewrite the internal logic of effects in RxJS now `loginCall`, `meCall` and `refreshTokenCall` 225 | can return Rx Observable. 226 | 227 | So you can use `rjxs/ajax` without `.toPromise()`. 228 | 229 | All routes components can be rendered with all methods supported by `react-router`. 230 | 231 | Now you can do: 232 | 233 | ```js 234 | 235 | 236 | 237 | ``` 238 | 239 | ## 1.0.0 240 | ##### *June 8th, 2020* 241 | 242 | The api still the same of `1.0.0-rc2` published on *next* tag. 243 | 244 | The only breaking change is the remove of `callApi()` *function* from 245 | `useAuthActions()` hook, cause is less reliable of `callAuthApiPromise()` and 246 | `callAuthApiObservable()`. 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) INMAGIK srl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-eazy-auth 2 | [![Build Status](https://travis-ci.com/inmagik/use-eazy-auth.svg?branch=master)](https://travis-ci.com/inmagik/use-eazy-auth) 3 | [![npm version](https://badge.fury.io/js/use-eazy-auth.svg)](https://www.npmjs.com/package/use-eazy-auth) 4 | 5 | React components and hooks to deal with token based authentication 6 | 7 | This project takes the main concepts and algorithms (but also the name) from the [eazy-auth](https://github.com/inmagik/eazy-auth) library, and aims at providing equivalent functionality in contexts where the usage of `eazy-auth` with its strong dependency on `redux` and `redux-saga` is just too constraining. 8 | 9 | ## Installation 10 | ``` 11 | yarn add use-eazy-auth 12 | npm install --save use-eazy-auth 13 | ``` 14 | 15 | ## Api 16 | 17 | ### `` Component 18 | The top level component where you are able to configure authentication behaviours. 19 | 20 | Token based authentication is based on the usage of a token as a proof of identity. As such, the library has to deal with acquiring a token, storing it for later use, validating it, refreshing it when it expires, and deleting it when no refresh is possible or the token is revoked. 21 | 22 | Moreover, the token is strictly tied to a user (as it is the proof of his identity), and so it is usually a good idea to keep the user object around while the token is valid. 23 | 24 | This concepts are common to the majority of token based authentication system, even if implementation of them can be really different. Given this, `use-eazy-auth` gives you full customization freedom to integrate with your specific implementation and to tailor its own behaviours by passing props to the `` component. 25 | 26 | The `` component creates React contexts that are used by any hook, so it is mandatory to make it a common ancestor for all components that need to deal with authentication, and advisable to put it as near as possible to the root of the React application tree. 27 | 28 | The following properties are required: 29 | * **loginCall**: the login call implements the process of acquiring a valid token, usually by means of some credentials (in the majority of cases, this is just a *username*, *password* pair, but it is not required). The signature of this function must be 30 | 31 | ```js 32 | (credentials: any) => 33 | Promise<{ accessToken: string, refreshToken?: string }, any> | 34 | Observable<{ accessToken: string, refreshToken?: string }, any> 35 | ``` 36 | 37 | Has you can see, the function is expected to return a promise which rejects in case of unsuccessful authentication (the error shape is up to you) or resolves in case of successful authentication. The required `accessToken` property in the resolution argument must hold the token which will be used to authenticate the user when interacting with the server. The optional `refreshToken` property, if present, must hold a token which is never used for API calls, but it is used to get a new token when that returned by the login expires without having the user go through the login procedure again. In case the access token expires and no refresh is possible, the user will experience a forced logout 38 | 39 | * **meCall**: the me call implements the process of validating a previously stored token while gathering information about the owner user. This is used both to read user information from server and make them available throughout the application and to validate a token that has been recalled after some time from storage (see later). The signature of this function must be 40 | 41 | ```js 42 | (accessToken: string) => 43 | Promise | Observable 44 | ``` 45 | 46 | Has you can see, this function is expected to retrieve user information given an access token. In case the process succeeds, it is expected to return the object that describes the user (the shape of this is again completely up to you). In case the process cannot succeed, the promise is expected to be rejected with a status code. In this last situation, the `accessToken` cannot be considered valid anymore. 47 | 48 | If a `refreshToken` was provided, `refreshTokenCall` is set on the `` object and the error status code is 401, the library will attempt to refresh the token and eventually repeat the me call with the refreshed token. If for any reason the token cannot be refreshed the user will be logged out. 49 | 50 | * **refreshTokenCall**: some authentication schemes allow the usage of some kind of refresh token to obtain a fresh access token when the currently used one expires. This property allows to pass a function that implements the refresh procedure. As such, its signature is 51 | 52 | ```js 53 | (refreshToken: string) => 54 | Promise<{ accessToken: string, refreshToken?: string }, any> | 55 | Observable<{ accessToken: string, refreshToken?: string }, any> 56 | ``` 57 | 58 | Considerations about the login call hold just the same for this api, the only difference is that the `credentials` parameter is replaced by the `refreshToken` 59 | 60 | * **storageBackend**: the storage of `accessToken` and `refreshToken` allows the website to remember the user identity and to skip the authentication procedure in a subsequent visit. You are free to choose any synchronous or asynchronous storage backend like `localStorage`, `sessionStorage` (or `AsyncStorage` when using ReactNative). A storage object must meet the following signature 61 | 62 | ```js 63 | type Storage = { 64 | getItem: (key: string) => string | Promise, 65 | setItem: (key: string, value: string) => void | Promise, 66 | removeItem: (key: string) => void | Promise 67 | } 68 | ``` 69 | 70 | This property defaults to `window.localStorage` if available, or to `no storage` otherwise. In case you want to completely disable token storage, set this property to `false` 71 | 72 | * **storageNamespace**: in case you did not opt-out token storage, you can customize the key under which the tokens are stored by setting this property (it must be a string). If you don't set this property, it defaults to the string `auth` 73 | 74 | * **onLogout**: An optional callback inoked when user explicit logout 75 | (calling `logout` action) or is kicked out from `401` rejection in call api functions. 76 | 77 | Here is a usage example 78 | 79 | > Please note that the login call and the me call are **not** real life examples: always validate your users against your authentication backend! 80 | 81 | ```js 82 | import React from 'react' 83 | import Auth from 'use-eazy-auth' 84 | 85 | const loginCall = ({ username, password }) => new Promise((resolve, reject) => 86 | (username === 'alice' && password === 'my-super-secret-password') 87 | ? resolve({ accessToken: 'alice-is-allowed-to-access' }) 88 | : reject('Unauthorized!') 89 | ) 90 | 91 | const meCall = token => new Promise((resolve, reject) => 92 | (token === 'alice-is-allowed-to-access') 93 | ? resolve({ username: 'alice', status: 'Administrator' }) 94 | : reject('Unauthorized!') 95 | ) 96 | 97 | const App = () => ( 98 | 104 | { 105 | /* react-router or in any case the restricted section of 106 | * your application should be put here 107 | */ 108 | } 109 | 110 | 111 | ) 112 | 113 | ``` 114 | 115 | You can also use the `render` prop. 116 | 117 | ```js 118 | function App() { 119 | return ( 120 | /* render my children */} 126 | /> 127 | ) 128 | } 129 | ``` 130 | 131 | ### `useAuthState()` hook 132 | 133 | This hooks returns the current auth state. The auth state is the operational state of the library, which can tell you if some operation is in progress, like initialization or login. The state object is a plain object with the following properties 134 | 135 | * **bootstrappedAuth** (bool): this flag tells whether the library has loaded or loading is still in progress. Loading means that the library is fetching stored tokens and validating them with a me call. 136 | * **authenticated** (bool): this flag tells whether the user is authenticated (i.e. the library has a valid access token ready for use) or not 137 | * **loginLoading** (bool): this flag tells whether a login operation is in progress 138 | * **loginError** (any): this property holds the result of the last rejected promise (it is not cleared after a successful login call, you need to clear it explictly by calling `clearLoginError` - see example) 139 | 140 | Usage example 141 | 142 | ```jsx 143 | import React, { useState } from 'react' 144 | import { useAuthState, useAuthActions } from 'use-eazy-auth' 145 | 146 | const Screens = () => { 147 | const { authenticated, bootstrappedAuth } = useAuthState() 148 | if (!bootstrappedAuth) { 149 | return
Please wait, we are logging you in...
150 | } 151 | return authenticated ? : 152 | } 153 | 154 | 155 | const Login = () => { 156 | const { loginLoading, loginError } = useAuthState() 157 | const { login, clearLoginError } = useAuthActions() 158 | 159 | const [username, setUsername] = useState('') 160 | const [password, setPassword] = useState('') 161 | 162 | return ( 163 |
{ 164 | e.preventDefault() 165 | if (username !== '' && password !== '') { 166 | login({ username, password }) 167 | } 168 | }}> 169 |
170 | { 175 | clearLoginError() 176 | setUsername(e.target.value) 177 | }} 178 | /> 179 |
180 |
181 | { 186 | clearLoginError() 187 | setPassword(e.target.value) 188 | }} 189 | /> 190 |
191 | 192 | {loginError &&
Bad combination of username and password
} 193 |
194 | ) 195 | } 196 | ``` 197 | 198 | ### `useAuthActions()` hook 199 | This hook allows to invoke some auth related behaviours. It returns a plain JavaScript object whose properties are functions. 200 | 201 | * **callAuthApiPromise** 202 | This function performs an authenticated API call. The first parameter is a factory function (a function which returns a fucntion) that is expected to create the real api call function (i.e. the function that implements the real api call, you can use XHR, Axios, SuperAgent or whatever you like inside this). The factory function is invoked with the access token, and is expected to return again a function - the api call function. Any additional parameter supplied to the **callAuthApiPromise** will be used as a parameter to invoke the api call function. The api call must return a promise. If all is fine, that promise is expected to resolve. In case it rejects, the rejection value must be an object with a status property carrying the status code of the request. A 401 code will trigger the refresh token operation (if available) and repeat the api call invocation with the new token. If even this second call is rejected, the user will be logged out. 203 | 204 | * **callAuthApiObservable** 205 | This behaves like **callAuthApiPromise** except that the api call function is expected to return an `Observable` from `RxJS`. Promise rejection is replaced by error raising. 206 | 207 | * **login** 208 | This function triggers a login operation. It is expected to be called with a single argument (the credentials object) which is used to invoke the `loginCall` provided to the `` component as a property 209 | 210 | * **logout** 211 | This function triggers a logout operation. This means clearing the stored tokens and set the library `authenticated` state to `false`. No api call is performed here. 212 | 213 | * **clearLoginError** 214 | This function clear the current login error. 215 | 216 | * **updateUser** 217 | This function update the current auth user with given *User* object. 218 | 219 | * **patchUser** 220 | This function shallow merge the given *User* object with current *User* object. 221 | 222 | * **setTokens** 223 | ```js 224 | ({ accessToken: string, refreshToken?: string }) => void 225 | ``` 226 | This function explicit set new tokens, this function write new tokens in storage as well. 227 | 228 | All these functions are stable across renders, so it is safe to add them as dependencies of some `useEffect` or `useMemo`, they will never trigger any unnecessary re-renders. 229 | 230 | Here is some example 231 | 232 | ```js 233 | import React, { useState, useEffect } from 'react' 234 | import { useAuthActions } from 'use-eazy-auth' 235 | 236 | const authenticatedGetTodos = (token) => (category) => new Promise((resolve, reject) => { 237 | return (token === 23) 238 | ? resolve([ 239 | 'Learn React', 240 | 'Prepare the dinner', 241 | ]) 242 | : reject({ status: 401, error: 'Go out' }) 243 | }) 244 | 245 | const Home = () => { 246 | const [todos, setTodos] = useState([]) 247 | const { logout, callAuthApiPromise } = useAuthActions() 248 | 249 | useEffect(() => { 250 | callAuthApiPromise(authenticatedGetTodos, 'all') 251 | .then(todos => setTodos(todos)) 252 | }, [callAuthApiPromise]) 253 | 254 | return ( 255 |
256 |

Todos

257 |
    258 | {todos.map((todo, i) => ( 259 |
  • {todo}
  • 260 | ))} 261 |
262 |
263 | 264 |
265 |
266 | ) 267 | } 268 | ``` 269 | 270 | ### `useAuthUser()` hook 271 | This hook returns the current user object (in the shape you chose to return from the `meCall` supplied to the `` component) and the current token as props of a plain JavaScript object. If user is not logged in, both properties result in `null` values. 272 | 273 | ```js 274 | 275 | import { useAuthUser } from 'use-eazy-auth' 276 | 277 | const Home = () => { 278 | const { user, token } = useAuthUser() 279 | 280 | return ( 281 |
282 | Logged in user {user.username}
283 | identified by token {token} 284 |
285 | ) 286 | } 287 | ``` 288 | 289 | ## Provide initial data 290 | 291 | In certain scenarios (Server Side Rendering), you need to provide initial data to your `` and avoid 292 | all the side effects appening during first renders (check tokens, perform `meCall` ecc). 293 | 294 | You can do that using the `initialData` prop: 295 | 296 | ```jsx 297 | const App = () => ( 298 | 305 | {/* ... */} 306 | 307 | ) 308 | ``` 309 | 310 | When both `user` and `token` are not null the initial state is **authenticated** otherwise no. 311 | 312 | The `initialData` typing is: 313 | 314 | ```ts 315 | interface InitialAuthData
{ 316 | accessToken: A | null 317 | refreshToken?: R | null 318 | expires?: number | null 319 | user: U | null 320 | } 321 | ``` 322 | 323 | ## React Router Integration 324 | This library ships with components useful to integrate routing (by react-router) and authentication. You are not forced to do this: you can use any routing library you wish and write the integration yourself, maybe taking our react-router integration as an example 325 | 326 | The integration is done by providing three specialized `Route` components: `GuestRoute`, `AuthRoute` and `MaybeAuthRoute`. A `GuestRoute` can be accessed only by non authenticated users, and will redirect authenticated users. An `AuthRoute` can be accessed just by authenticated users, and will redirect any non authenticated visitor. A `MaybeAuthRoute` will accept authenticated just as non authenticated users. If in some route you don't care about authentication, a vanilla `Route` can still be used. 327 | 328 | You can import those components from `use-eazy-auth/routes` 329 | 330 | ### `` component 331 | When the auth is booting render an optional spinner, when the user is authenticated render a `` 332 | otherwise act as a normal ``. 333 | 334 | The `` component accepts the following props 335 | 336 | * **redirectTo**: the path to redirect authenticated users to 337 | * **redirectToReferrer**: if set to `true`, users that are redirected to this page from an `` because they are not authenticated will be redirected back after login instead of being redirected to the path set by `redirectTo`. Note that it is mandatory to set the `redirectTo` property as unauthenticated users may land directly on a `GuestRoute` and so they may not have a referrer 338 | * **spinnerComponent**: an optional spinner component to render instead of content until the auth initialization is not complete 339 | * **spinner**: an optional spinner react element to render instead of content until the auth initialization is not complete 340 | * any other property accepted by `` 341 | 342 | 343 | ```ts 344 | type GuestRouteProps = { 345 | redirectTo?: string | Location 346 | redirectToReferrer?: boolean 347 | spinner?: ReactNode 348 | spinnerComponent?: ComponentType 349 | } & RouteProps 350 | ``` 351 | 352 | ### `` component 353 | When the auth is booting render an optional spinner, when the user is authenticated act as `` 354 | otherwise act as a normal ``. 355 | 356 | Can also redirect your user by a given `redirectTest`. 357 | 358 | The `` component accepts the following props 359 | 360 | * **redirectTo**: the path to redirect a non authenticated user to 361 | * **rememberReferrer**: whether to enable the referrer in order to redirect the user back after login 362 | * **redirectTest**: a function to test if current authenticated user can access your route, take user as only parameter and if falsy is returned the user can acccess the route, otherwise the return value is expected to be a valid path used to redirect the user. 363 | * **spinnerComponent**: an optional spinner component to render instead of content until the auth initialization is not complete 364 | * **spinner**: an optional spinner react element to render instead of content until the auth initialization is not complete 365 | * any other property accepted by `` 366 | 367 | ```ts 368 | type AuthRouteProps = { 369 | redirectTest?: null | ((user: U) => string | null | undefined | Location) 370 | redirectTo?: string | Location 371 | spinner?: ReactNode 372 | spinnerComponent?: ComponentType 373 | rememberReferrer?: boolean 374 | } & RouteProps 375 | ``` 376 | 377 | ### `` component 378 | When the auth is booting render an optional spinner otherwise act as ``. 379 | 380 | The `` component accepts the following props 381 | 382 | * **spinnerComponent**: an optional spinner component to render instead of content until the auth initialization is not complete 383 | * **spinner**: an optional spinner react element to render instead of content until the auth initialization is not complete 384 | * any other property accepted by `` 385 | 386 | ```ts 387 | export type MaybeAuthRouteProps = { 388 | spinner?: ReactNode 389 | spinnerComponent?: ComponentType 390 | } & RouteProps 391 | ``` 392 | 393 | ## Fetching libraries integrations 394 | 395 | ### [SWR](https://github.com/vercel/swr) 396 | 397 | ```jsx 398 | import useSWR, { SWRConfig } from 'swr' 399 | import { useAuthActions } from 'use-eazy-auth' 400 | import { meCall, refreshTokenCall, loginCall } from './authCalls' 401 | 402 | function Dashboard() { 403 | const { data: todos } = useSWR('/api/todos') 404 | // ... 405 | } 406 | 407 | function ConfigureAuthFetch({ children }) { 408 | const { callAuthApiPromise } = useAuthActions() 409 | return ( 410 | 413 | callAuthApiPromise( 414 | token => (url, options) => 415 | fetch(url, { 416 | ...options, 417 | headers: { 418 | ...options?.headers, 419 | Authorization: `Bearer ${token}`, 420 | }, 421 | }) 422 | // NOTE: use-eazy-auth needs a Rejection with shape: 423 | // { status: number } 424 | .then(res => (res.ok ? res.json() : Promise.reject(res))), 425 | ...args 426 | ), 427 | }} 428 | > 429 | {children} 430 | 431 | ) 432 | } 433 | 434 | function App() { 435 | return ( 436 | 437 | 438 | 439 | 440 | 441 | ) 442 | } 443 | ``` 444 | 445 | ### [react-query](https://github.com/tannerlinsley/react-query) 446 | 447 | ```jsx 448 | import { useQuery } from 'react-query' 449 | import { useAuthActions } from 'use-eazy-auth' 450 | 451 | export default function Dashboard() { 452 | const { callAuthApiPromise } = useAuthActions() 453 | const { data: todos } = useQuery(['todos'], () => 454 | callAuthApiPromise((token) => () => 455 | fetch(`/api/todos`, { 456 | headers: { 457 | Authorization: `Bearer ${token}`, 458 | }, 459 | }).then((res) => (res.ok ? res.json() : Promise.reject(res))) 460 | ) 461 | ) 462 | // ... 463 | } 464 | ``` 465 | 466 | ### [react-rocketjump](https://github.com/inmagik/react-rocketjump) 467 | 468 | ```jsx 469 | import { ConfigureRj, rj, useRunRj } from 'react-rocketjump' 470 | import { useAuthActions } from 'use-eazy-auth' 471 | 472 | const Todos = rj({ 473 | effectCaller: rj.configured(), 474 | effect: (token) => () => 475 | fetch(`/api/todos/`, { 476 | headers: { 477 | Authorization: `Bearer ${token}`, 478 | }, 479 | }).then((res) => (res.ok ? res.json() : Promise.reject(res))), 480 | }) 481 | 482 | export default function Dashboard() { 483 | const [{ data: todos }] = useRunRj(Todos) 484 | // ... 485 | } 486 | 487 | function ConfigureAuthFetch({ children }) { 488 | const { callAuthApiObservable } = useAuthActions() 489 | // NOTE: react-rocketjump supports RxJs Observables 490 | return ( 491 | 492 | {children} 493 | 494 | ) 495 | } 496 | 497 | function App() { 498 | return ( 499 | 500 | 501 | 502 | 503 | 504 | ) 505 | } 506 | ``` 507 | 508 | ## Run example 509 | This repository contains a runnable basic example of the main functionalities of the library 510 | 511 | ```sh 512 | git clone https://github.com/inmagik/use-eazy-auth.git 513 | cd use-eazy-auth 514 | yarn install 515 | yarn dev 516 | ``` 517 | -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { BrowserRouter as Router, Link } from 'react-router-dom' 3 | import Auth, { useAuthState, useAuthActions, useAuthUser } from 'use-eazy-auth' 4 | import { GuestRoute, AuthRoute, MaybeAuthRoute } from 'use-eazy-auth/routes' 5 | 6 | const loginCall = ({ username, password }) => 7 | new Promise((resolve, reject) => 8 | username === 'giova' && password === 'xiboro23' 9 | ? resolve({ accessToken: 23, refreshToken: 777 }) 10 | : reject({ status: 401, error: 'Go out' }) 11 | ) 12 | 13 | const meCall = (token) => 14 | new Promise((resolve, reject) => 15 | token === 23 16 | ? resolve({ username: 'giova', status: 'Awesome', age: 17 }) 17 | : reject({ status: 401, error: 'Go out' }) 18 | ) 19 | 20 | const refreshTokenCall = (token) => 21 | new Promise((resolve, reject) => { 22 | console.log('Refresh!', token) 23 | const newToken = 2323 24 | return token === 777 25 | ? resolve({ accessToken: newToken, refreshToken: 777 }) 26 | : reject({ status: 401, error: 'Go out' }) 27 | }) 28 | 29 | const authenticatedGetTodos = (token) => (category) => 30 | new Promise((resolve, reject) => { 31 | console.log('API Token', token) 32 | console.log('Todos OF', category) 33 | return token === 23 34 | ? resolve(['Learn React', 'Prepare the dinner']) 35 | : reject({ status: 401, error: 'Go out' }) 36 | }) 37 | 38 | const Login = () => { 39 | const { loginLoading, loginError } = useAuthState() 40 | const { login, clearLoginError } = useAuthActions() 41 | 42 | // login credentials state 43 | const [username, setUsername] = useState('') 44 | const [password, setPassword] = useState('') 45 | 46 | // clear login error on unmount 47 | useEffect(() => () => clearLoginError(), [clearLoginError]) 48 | 49 | // clear login error when username or password changes 50 | useEffect(() => { 51 | clearLoginError() 52 | }, [username, password, clearLoginError]) 53 | 54 | return ( 55 |
{ 57 | e.preventDefault() 58 | if (username !== '' && password !== '') { 59 | login({ username, password }) 60 | } 61 | }} 62 | > 63 | 64 | username: giova 65 |
66 | password: xiboro23 67 |
68 |
69 | setUsername(e.target.value)} 74 | /> 75 |
76 |
77 | setPassword(e.target.value)} 82 | /> 83 |
84 | 87 | {loginError &&
Bad combination of username and password
} 88 |
89 | ) 90 | } 91 | 92 | const Home = () => { 93 | const [todos, setTodos] = useState([]) 94 | const { user } = useAuthUser() 95 | const { logout, callAuthApiPromise } = useAuthActions() 96 | 97 | useEffect(() => { 98 | callAuthApiPromise(authenticatedGetTodos, 'all').then((todos) => 99 | setTodos(todos) 100 | ) 101 | }, [callAuthApiPromise]) 102 | 103 | return ( 104 |
105 |

106 | Welcome Back! {user.username} u are {user.status}! 107 |

108 |

Todos

109 |
    110 | {todos.map((todo, i) => ( 111 |
  • {todo}
  • 112 | ))} 113 |
114 |
115 | 116 |
117 |
118 | ) 119 | } 120 | 121 | function About() { 122 | const { user } = useAuthUser() 123 | const { logout } = useAuthActions() 124 | return ( 125 |
126 |

Eazy Auth Was Good

127 | {user &&

Bella {user.username}

} 128 | 129 | 130 |
131 | ) 132 | } 133 | 134 | // const Screens = () => { 135 | // const { authenticated, bootstrappedAuth } = useAuthState() 136 | // if (!bootstrappedAuth) { 137 | // return
Just logged in wait....
138 | // } 139 | // return authenticated ? : 140 | // } 141 | 142 | const Menu = () => { 143 | const { user } = useAuthUser() 144 | const { patchUser } = useAuthActions() 145 | 146 | return ( 147 |
148 | Home 149 | {' | '} 150 | About 151 | {' | '} 152 | 18+ 153 | {' | '} 154 | Login 155 | {' | '} 156 | {user && ( 157 | 158 | {'~'} 159 | {user.username} Age: {user.age}{' '} 160 | {' '} 169 | 178 | 179 | )} 180 |
181 | ) 182 | } 183 | 184 | const Adult = () => { 185 | console.log('Render Adult') 186 | return ( 187 |
188 |

Only Adult Here ;)

189 |
190 | ) 191 | } 192 | 193 | const isAdult = (user) => (user.age >= 18 ? false : '/about') 194 | 195 | const App = () => ( 196 | console.info('onLogout')} 201 | > 202 | 203 |
204 | 205 | 206 | 207 | (user.age >= 18 ? false : '/about')} 210 | > 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | ) 222 | 223 | export default App 224 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | use-eazy-auth 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom' 2 | import React from 'react' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-eazy-auth", 3 | "version": "2.6.0", 4 | "sideEffects": false, 5 | "description": "React hooks for handle auth stuff", 6 | "main": "./lib/index.cjs.js", 7 | "module": "./lib/index.es.js", 8 | "types": "lib/index.d.ts", 9 | "repository": "https://github.com/inmagik/use-eazy-auth", 10 | "author": "Giovanni Fumagalli ", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/inmagik/use-eazy-auth/issues" 14 | }, 15 | "files": [ 16 | "lib", 17 | "routes" 18 | ], 19 | "keywords": [ 20 | "react", 21 | "hooks", 22 | "auth" 23 | ], 24 | "scripts": { 25 | "format": "prettier --no-semi --single-quote --trailing-comma es5 --write \"{src,__{tests,mocks}__}/**/*.js\"", 26 | "test": "jest", 27 | "prebuild": "rimraf lib", 28 | "build": "rollup -c && tsc --project tsconfig.build.json", 29 | "build:watch": "rollup -c -w", 30 | "dev": "webpack-dev-server" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "peerDependencies": { 36 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0", 37 | "react-router-dom": "^5.0.0" 38 | }, 39 | "jest": { 40 | "testEnvironment": "jsdom" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.18.9", 44 | "@babel/plugin-transform-runtime": "^7.18.9", 45 | "@babel/preset-env": "^7.18.9", 46 | "@babel/preset-react": "^7.18.6", 47 | "@babel/preset-typescript": "^7.18.6", 48 | "@rollup/plugin-babel": "^5.3.1", 49 | "@rollup/plugin-node-resolve": "^13.3.0", 50 | "@testing-library/jest-dom": "^5.11.5", 51 | "@testing-library/react": "^11.1.0", 52 | "@testing-library/react-hooks": "^3.4.2", 53 | "@types/react": "^17.0.0", 54 | "@types/react-router-dom": "^5.1.6", 55 | "babel-jest": "^28.1.3", 56 | "babel-loader": "^8.2.5", 57 | "css-loader": "^6.7.1", 58 | "eslint": "^8.20.0", 59 | "eslint-config-react-app": "^7.0.1", 60 | "jest": "^28.1.3", 61 | "jest-environment-jsdom": "^28.1.3", 62 | "prettier": "^2.1.2", 63 | "react": "^17.0.1", 64 | "react-dom": "^17.0.1", 65 | "react-router-dom": "^5.2.0", 66 | "react-test-renderer": "^17.0.1", 67 | "rimraf": "^3.0.2", 68 | "rollup": "^2.77.0", 69 | "style-loader": "^3.3.1", 70 | "typescript": "^4.7.4", 71 | "webpack": "^5.73.0", 72 | "webpack-cli": "^4.10.0", 73 | "webpack-dev-server": "^4.9.3" 74 | }, 75 | "dependencies": { 76 | "@babel/runtime": "^7.18.9", 77 | "rxjs": "^6.6.3" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import pkg from './package.json' 4 | 5 | const extensions = ['.ts', '.tsx', '.js', '.jsx'] 6 | 7 | const vendors = [] 8 | // Make all external dependencies to be exclude from rollup 9 | .concat( 10 | Object.keys(pkg.dependencies || {}), 11 | Object.keys(pkg.peerDependencies || {}) 12 | ) 13 | 14 | const makeExternalPredicate = (externalArr) => { 15 | if (externalArr.length === 0) { 16 | return () => false 17 | } 18 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`) 19 | return (id) => pattern.test(id) 20 | } 21 | 22 | export default ['esm', 'cjs'].map((format) => ({ 23 | input: { 24 | index: 'src/index.ts', 25 | routes: 'src/routes/index.ts', 26 | }, 27 | output: [ 28 | { 29 | dir: 'lib', 30 | entryFileNames: '[name].[format].js', 31 | chunkFileNames: '[name].[format].js', 32 | exports: 'named', 33 | format, 34 | }, 35 | ], 36 | external: makeExternalPredicate(vendors), 37 | plugins: [ 38 | resolve({ extensions }), 39 | babel({ 40 | babelHelpers: 'runtime', 41 | exclude: 'node_modules/**', 42 | extensions, 43 | plugins: [ 44 | [ 45 | '@babel/plugin-transform-runtime', 46 | { 47 | useESModules: format === 'esm', 48 | // NOTE: Sometimes js world is a pain 49 | // see: https://github.com/babel/babel/issues/10261 50 | version: pkg['dependencies']['@babel/runtime'], 51 | }, 52 | ], 53 | ], 54 | }), 55 | ], 56 | })) 57 | -------------------------------------------------------------------------------- /routes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-eazy-auth/routes", 3 | "private": true, 4 | "main": "../lib/routes.cjs.js", 5 | "module": "../lib/routes.es.js", 6 | "types": "../lib/routes/index.d.ts" 7 | } 8 | -------------------------------------------------------------------------------- /src/Auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Reducer, 3 | useCallback, 4 | createContext, 5 | useMemo, 6 | useEffect, 7 | useReducer, 8 | useRef, 9 | ReactNode, 10 | } from 'react' 11 | import { Observable, Subject } from 'rxjs' 12 | import { makeStorage, StorageBackend } from './storage' 13 | import useConstant from './useConstant' 14 | import { bootAuth, LoginEffect, makePerformLogin } from './authEffects' 15 | import makeCallApiRx, { CallApiEffect } from './callApiRx' 16 | // Reducer stuff 17 | import authReducer, { AuthStateShape, initAuthState } from './reducer' 18 | import bindActionCreators from './bindActionCreators' 19 | import * as actionCreators from './actionCreators' 20 | import { 21 | AuthActions, 22 | FunctionalUpdaterUser, 23 | LOGOUT, 24 | SET_TOKENS, 25 | BOOTSTRAP_AUTH_END, 26 | LOGIN_SUCCESS, 27 | } from './actionTypes' 28 | import { 29 | AuthTokens, 30 | InitialAuthData, 31 | CurryAuthApiFn, 32 | CurryAuthApiFnPromise, 33 | LoginCall, 34 | MeCall, 35 | RefreshTokenCall, 36 | } from './types' 37 | 38 | export interface AuthState { 39 | bootstrappedAuth: boolean 40 | authenticated: boolean 41 | loginLoading: boolean 42 | loginError: any 43 | } 44 | 45 | export interface AuthUser { 46 | token: A | null 47 | user: U | null 48 | } 49 | 50 | export interface AuthActionCreators
{ 51 | callAuthApiObservable( 52 | apiFn: CurryAuthApiFn, 53 | ...args: any[] 54 | ): Observable 55 | 56 | callAuthApiPromise( 57 | apiFn: CurryAuthApiFnPromise, 58 | ...args: any[] 59 | ): Promise 60 | 61 | updateUser(user: U | FunctionalUpdaterUser | null): void 62 | 63 | patchUser(partialUser: Partial): void 64 | 65 | clearLoginError(): void 66 | 67 | setTokens(authTokens: AuthTokens): void 68 | 69 | login(loginCredentials: C): void 70 | 71 | logout(): void 72 | } 73 | 74 | // Declare Eazy Auth contexts 75 | export const AuthStateContext = createContext(null as any) 76 | export const AuthUserContext = createContext(null as any) 77 | export const AuthActionsContext = createContext(null as any) 78 | 79 | interface AuthProps { 80 | children?: ReactNode 81 | render?: ( 82 | actions: AuthActionCreators, 83 | authState: AuthState, 84 | authUser: AuthUser 85 | ) => ReactNode 86 | loginCall: LoginCall 87 | meCall: MeCall 88 | refreshTokenCall?: RefreshTokenCall 89 | storageBackend?: StorageBackend | false 90 | storageNamespace?: string 91 | initialData?: InitialAuthData 92 | onLogout?: (accessToken: A) => void 93 | onAuthenticate?: (user: U, accessToken: A, fromLogin: boolean) => void 94 | } 95 | 96 | export default function Auth({ 97 | children, 98 | render, 99 | loginCall, 100 | meCall, 101 | refreshTokenCall, 102 | storageBackend, 103 | storageNamespace = 'auth', 104 | initialData, 105 | onLogout, 106 | onAuthenticate, 107 | }: AuthProps) { 108 | // Init React Reducer 109 | const [state, originalDispatch] = useReducer< 110 | Reducer, AuthActions>, 111 | InitialAuthData | undefined 112 | >(authReducer, initialData, initAuthState) 113 | 114 | // Handle last onAuthenticate callback 115 | const autenticateCbRef = useRef(onAuthenticate) 116 | useEffect(() => { 117 | autenticateCbRef.current = onAuthenticate 118 | }, [onAuthenticate]) 119 | 120 | // Handle last onLogout callback 121 | const logoutCbRef = useRef(onLogout) 122 | useEffect(() => { 123 | logoutCbRef.current = onLogout 124 | }, [onLogout]) 125 | 126 | // Make storage from config 127 | // NOTE: Switch againg from useMemo storage is a constant fuck off 128 | const storage = useConstant(() => 129 | makeStorage(storageBackend, storageNamespace) 130 | ) 131 | 132 | const { 133 | bootstrappedAuth, 134 | accessToken, 135 | refreshToken, 136 | expires, 137 | loginLoading, 138 | loginError, 139 | } = state 140 | 141 | // Simply keep a token reference lol 142 | const tokenRef = useRef | null>( 143 | accessToken ? { accessToken, refreshToken, expires } : null 144 | ) 145 | 146 | // Is authenticated when has an access token eazy 147 | // This line can't look stupid but is very very important 148 | const authenticated = !!accessToken 149 | 150 | // Handle the ref of booting status of eazy auth 151 | const bootRef = useRef(bootstrappedAuth) 152 | 153 | const [actionObservable, dispatch] = useConstant(() => { 154 | const actionSubject = new Subject() 155 | const dispatch = (action: AuthActions) => { 156 | // Handle user callbacks 157 | if (action.type === BOOTSTRAP_AUTH_END && action.payload.authenticated) { 158 | const autenticateCb = autenticateCbRef.current 159 | if (autenticateCb) { 160 | autenticateCb(action.payload.user, action.payload.accessToken, false) 161 | } 162 | } 163 | if (action.type === LOGIN_SUCCESS) { 164 | const autenticateCb = autenticateCbRef.current 165 | if (autenticateCb) { 166 | autenticateCb(action.payload.user, action.payload.accessToken, true) 167 | } 168 | } 169 | if (action.type === LOGOUT) { 170 | // Call user callback 171 | const logoutCb = logoutCbRef.current 172 | if (logoutCb) { 173 | logoutCb(tokenRef.current!.accessToken) 174 | } 175 | // Clear token ref 176 | tokenRef.current = null 177 | } 178 | 179 | // Update React state reducer 180 | originalDispatch(action) 181 | // Next Observable 182 | actionSubject.next(action) 183 | 184 | // Remove tokens from storage 185 | // (after applying the new state cause can be slow) 186 | if (action.type === LOGOUT) { 187 | storage.removeTokens() 188 | } 189 | } 190 | return [actionSubject.asObservable(), dispatch] 191 | }) 192 | 193 | // Boot Eazy Auth 194 | useEffect(() => { 195 | return bootAuth( 196 | meCall, 197 | refreshTokenCall, 198 | storage, 199 | dispatch, 200 | tokenRef, 201 | bootRef 202 | ) 203 | }, [dispatch, meCall, refreshTokenCall, storage]) 204 | 205 | // ~~ Make Actions ~~~ 206 | 207 | // Actions creator should't change between renders 208 | const boundActionCreators = useConstant(() => 209 | bindActionCreators(actionCreators, dispatch) 210 | ) 211 | 212 | // Keep a lazy init ref to login effect 213 | const loginEffectRef = useRef | null>(null) 214 | 215 | // NOTE: This respect the old useConstant implementation 216 | // All makePerformLogin deps are treat as constants 217 | const loginEffectGetter = useRef(() => { 218 | if (loginEffectRef.current === null) { 219 | loginEffectRef.current = makePerformLogin( 220 | loginCall, 221 | meCall, 222 | storage, 223 | dispatch, 224 | tokenRef 225 | ) 226 | } 227 | return loginEffectRef.current 228 | }) 229 | 230 | const login = useCallback( 231 | (loginCredentials: C) => { 232 | if ( 233 | // Is eazy auth boostrapped? 234 | bootstrappedAuth && 235 | // Is ma men alredy logged? 236 | !authenticated 237 | ) { 238 | const { performLogin } = loginEffectGetter.current() 239 | performLogin(loginCredentials) 240 | } 241 | }, 242 | [authenticated, bootstrappedAuth] 243 | ) 244 | 245 | // Unsubscribe from login effect 246 | useEffect(() => { 247 | return () => { 248 | if (loginEffectRef.current !== null) { 249 | // Here it's safe to say: "Goodbye Space Cowboy" lol 250 | loginEffectRef.current.unsubscribe() 251 | loginEffectRef.current = null 252 | } 253 | } 254 | }, []) 255 | 256 | const performLogout = useCallback(() => { 257 | // Trigger log out 258 | dispatch({ type: LOGOUT }) 259 | }, [dispatch]) 260 | 261 | const logout = useCallback(() => { 262 | if (tokenRef.current) { 263 | performLogout() 264 | } 265 | }, [performLogout]) 266 | 267 | const setTokens = useCallback( 268 | (tokensBag: AuthTokens) => { 269 | tokenRef.current = tokensBag 270 | dispatch({ 271 | type: SET_TOKENS, 272 | payload: tokensBag, 273 | }) 274 | storage.setTokens(tokensBag) 275 | }, 276 | [dispatch, storage] 277 | ) 278 | 279 | // Keep a lazy ref to call api effect 280 | const callApiEffectRef = useRef | null>(null) 281 | 282 | // Lazy get call api effect 283 | const callApiEffecGetter = useRef(() => { 284 | if (callApiEffectRef.current === null) { 285 | callApiEffectRef.current = makeCallApiRx( 286 | refreshTokenCall, 287 | dispatch, 288 | storage, 289 | tokenRef, 290 | bootRef, 291 | actionObservable 292 | ) 293 | } 294 | return callApiEffectRef.current 295 | }) 296 | 297 | // Proxy call methods throught lazy getter 298 | const callAuthApiPromise = useCallback(function callAuthApiPromise( 299 | apiFn: CurryAuthApiFnPromise, 300 | ...args: any[] 301 | ) { 302 | return callApiEffecGetter.current().callAuthApiPromise(apiFn, ...args) 303 | }, 304 | []) 305 | const callAuthApiObservable = useCallback(function callAuthApiPromise( 306 | apiFn: CurryAuthApiFn, 307 | ...args: any[] 308 | ) { 309 | return callApiEffecGetter.current().callAuthApiObservable(apiFn, ...args) 310 | }, 311 | []) 312 | 313 | // Unsubscribe safe from call api 314 | useEffect(() => { 315 | return () => { 316 | if (callApiEffectRef.current !== null) { 317 | // Here it's safe to say: "Goodbye Space Cowboy" lol 318 | callApiEffectRef.current.unsubscribe() 319 | callApiEffectRef.current = null 320 | } 321 | } 322 | }, []) 323 | 324 | // Memoized actions 325 | const actions = useMemo( 326 | () => ({ 327 | ...boundActionCreators, 328 | callAuthApiPromise, 329 | callAuthApiObservable, 330 | login, 331 | logout, 332 | setTokens, 333 | }), 334 | [ 335 | login, 336 | logout, 337 | boundActionCreators, 338 | callAuthApiPromise, 339 | callAuthApiObservable, 340 | setTokens, 341 | ] 342 | ) 343 | 344 | // Derived state for auth 345 | // Why this even if the token change user still authenticated 346 | const authState = useMemo( 347 | () => ({ 348 | bootstrappedAuth, 349 | authenticated, 350 | loginLoading, 351 | loginError, 352 | }), 353 | [authenticated, bootstrappedAuth, loginLoading, loginError] 354 | ) 355 | 356 | const authUser: AuthUser = useMemo( 357 | () => ({ 358 | user: state.user, 359 | token: accessToken, 360 | }), 361 | [state.user, accessToken] 362 | ) 363 | 364 | return ( 365 | 366 | 367 | 368 | {typeof render === 'function' 369 | ? render(actions, authState, authUser) 370 | : children} 371 | 372 | 373 | 374 | ) 375 | } 376 | -------------------------------------------------------------------------------- /src/__tests__/Auth.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { renderHook, act } from '@testing-library/react-hooks' 3 | import { ReactNode } from 'react' 4 | import Auth, { useAuthActions, useAuthUser, useAuthState } from '../index' 5 | import { InitialAuthData } from '../types' 6 | 7 | interface TestCallBack { 8 | (value: V): void 9 | } 10 | 11 | interface DummyUser { 12 | username: string 13 | } 14 | 15 | describe('Auth', () => { 16 | it('should be aware of calls failure', async () => { 17 | const loginCall = jest.fn().mockRejectedValueOnce('GioVa') 18 | const meCall = jest.fn().mockRejectedValue('GioVa') 19 | 20 | // Fake an empty local storage 21 | const resolvesGetItem: TestCallBack[] = [] 22 | const localStorageMock = { 23 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 24 | setItem: jest.fn(), 25 | removeItem: jest.fn(), 26 | } 27 | Object.defineProperty(global, '_localStorage', { 28 | value: localStorageMock, 29 | writable: true, 30 | }) 31 | 32 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 33 | 34 | {children} 35 | 36 | ) 37 | 38 | const { result } = renderHook(() => useAuthActions(), { 39 | wrapper: AuthWrapper, 40 | }) 41 | 42 | await act(async () => { 43 | resolvesGetItem[0](null) 44 | }) 45 | 46 | await act(async () => { 47 | result.current.login({}) 48 | }) 49 | expect(loginCall).toHaveBeenCalledTimes(1) 50 | expect(meCall).toHaveBeenCalledTimes(0) 51 | 52 | loginCall.mockRejectedValueOnce('Fuck') 53 | await act(async () => { 54 | result.current.login({}) 55 | }) 56 | expect(loginCall).toHaveBeenCalledTimes(2) 57 | expect(meCall).toHaveBeenCalledTimes(0) 58 | 59 | loginCall.mockResolvedValueOnce('Yeaah') 60 | await act(async () => { 61 | result.current.login({}) 62 | }) 63 | expect(loginCall).toHaveBeenCalledTimes(3) 64 | expect(meCall).toHaveBeenCalledTimes(1) 65 | 66 | loginCall.mockResolvedValueOnce('Yeaah') 67 | await act(async () => { 68 | result.current.login({}) 69 | }) 70 | expect(loginCall).toHaveBeenCalledTimes(4) 71 | expect(meCall).toHaveBeenCalledTimes(2) 72 | }) 73 | 74 | it('Should give an action to imperative setTokens', async () => { 75 | // Fake da calls 76 | const loginCall = jest.fn() 77 | // Hack for manual resolve the me promise 78 | let resolveMe: TestCallBack 79 | const meCall = jest.fn( 80 | () => 81 | new Promise((resolve) => { 82 | resolveMe = resolve 83 | }) 84 | ) 85 | 86 | // Fake a good storage 87 | const resolvesGetItem: TestCallBack[] = [] 88 | const localStorageMock = { 89 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 90 | setItem: jest.fn(), 91 | removeItem: jest.fn(), 92 | } 93 | Object.defineProperty(global, '_localStorage', { 94 | value: localStorageMock, 95 | writable: true, 96 | }) 97 | 98 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 99 | 100 | {children} 101 | 102 | ) 103 | 104 | function useAllAuth() { 105 | return { user: useAuthUser(), actions: useAuthActions() } 106 | } 107 | 108 | const { result } = renderHook(() => useAllAuth(), { 109 | wrapper: AuthWrapper, 110 | }) 111 | 112 | await act(async () => { 113 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 114 | }) 115 | 116 | await act(async () => { 117 | resolveMe({ username: 'Giova' }) 118 | }) 119 | 120 | expect(result.current.user).toEqual({ 121 | user: { username: 'Giova' }, 122 | token: 23, 123 | }) 124 | 125 | await act(async () => { 126 | result.current.actions.setTokens({ accessToken: 99 }) 127 | }) 128 | 129 | // Token in ma state 130 | expect(result.current.user).toEqual({ 131 | user: { username: 'Giova' }, 132 | token: 99, 133 | }) 134 | 135 | // TOken in storage 136 | expect(localStorageMock.setItem).toHaveBeenCalledWith( 137 | 'auth', 138 | JSON.stringify({ 139 | accessToken: 99, 140 | }) 141 | ) 142 | 143 | const fakeApi = jest.fn((t) => () => Promise.resolve(88)) 144 | 145 | await act(async () => { 146 | await result.current.actions.callAuthApiPromise(fakeApi) 147 | }) 148 | 149 | // Token for ma caller 150 | expect(fakeApi).toHaveBeenCalledWith(99) 151 | }) 152 | 153 | it('Should give an action to updateUser', async () => { 154 | // Fake da calls 155 | const loginCall = jest.fn() 156 | // Hack for manual resolve the me promise 157 | let resolveMe: TestCallBack 158 | const meCall = jest.fn( 159 | () => 160 | new Promise((resolve) => { 161 | resolveMe = resolve 162 | }) 163 | ) 164 | 165 | // Fake a good storage 166 | const resolvesGetItem: TestCallBack[] = [] 167 | const localStorageMock = { 168 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 169 | setItem: jest.fn(), 170 | removeItem: jest.fn(), 171 | } 172 | Object.defineProperty(global, '_localStorage', { 173 | value: localStorageMock, 174 | writable: true, 175 | }) 176 | 177 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 178 | 179 | {children} 180 | 181 | ) 182 | 183 | function useAllAuth() { 184 | return { 185 | user: useAuthUser(), 186 | actions: useAuthActions(), 187 | } 188 | } 189 | 190 | const { result } = renderHook(() => useAllAuth(), { 191 | wrapper: AuthWrapper, 192 | }) 193 | 194 | await act(async () => { 195 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 196 | }) 197 | 198 | await act(async () => { 199 | resolveMe({ username: 'Giova' }) 200 | }) 201 | 202 | expect(result.current.user).toEqual({ 203 | user: { username: 'Giova' }, 204 | token: 23, 205 | }) 206 | 207 | await act(async () => { 208 | result.current.actions.updateUser({ username: 'Boundman!' }) 209 | }) 210 | 211 | expect(result.current.user).toEqual({ 212 | user: { username: 'Boundman!' }, 213 | token: 23, 214 | }) 215 | }) 216 | 217 | it('Should give an action to updateUser ... as a functional updater', async () => { 218 | // Fake da calls 219 | const loginCall = jest.fn() 220 | // Hack for manual resolve the me promise 221 | let resolveMe: TestCallBack 222 | const meCall = jest.fn( 223 | () => 224 | new Promise((resolve) => { 225 | resolveMe = resolve 226 | }) 227 | ) 228 | 229 | // Fake a good storage 230 | const resolvesGetItem: TestCallBack[] = [] 231 | const localStorageMock = { 232 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 233 | setItem: jest.fn(), 234 | removeItem: jest.fn(), 235 | } 236 | Object.defineProperty(global, '_localStorage', { 237 | value: localStorageMock, 238 | writable: true, 239 | }) 240 | 241 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 242 | 243 | {children} 244 | 245 | ) 246 | 247 | function useAllAuth() { 248 | return { 249 | user: useAuthUser(), 250 | actions: useAuthActions(), 251 | } 252 | } 253 | 254 | const { result } = renderHook(() => useAllAuth(), { 255 | wrapper: AuthWrapper, 256 | }) 257 | 258 | await act(async () => { 259 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 260 | }) 261 | 262 | await act(async () => { 263 | resolveMe({ username: 'Giova' }) 264 | }) 265 | 266 | expect(result.current.user).toEqual({ 267 | user: { username: 'Giova' }, 268 | token: 23, 269 | }) 270 | 271 | await act(async () => { 272 | result.current.actions.updateUser((user) => ({ 273 | username: user?.username + ' <.<', 274 | })) 275 | }) 276 | 277 | expect(result.current.user).toEqual({ 278 | user: { username: 'Giova <.<' }, 279 | token: 23, 280 | }) 281 | }) 282 | 283 | it('Should give an action to patchUser', async () => { 284 | interface AgedUser { 285 | username: string 286 | age: number 287 | } 288 | 289 | // Fake da calls 290 | const loginCall = jest.fn() 291 | // Hack for manual resolve the me promise 292 | let resolveMe: TestCallBack 293 | const meCall = jest.fn( 294 | () => 295 | new Promise((resolve) => { 296 | resolveMe = resolve 297 | }) 298 | ) 299 | 300 | // Fake a good storage 301 | const resolvesGetItem: TestCallBack[] = [] 302 | const localStorageMock = { 303 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 304 | setItem: jest.fn(), 305 | removeItem: jest.fn(), 306 | } 307 | Object.defineProperty(global, '_localStorage', { 308 | value: localStorageMock, 309 | writable: true, 310 | }) 311 | 312 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 313 | 314 | {children} 315 | 316 | ) 317 | 318 | function useAllAuth() { 319 | return { 320 | user: useAuthUser(), 321 | actions: useAuthActions(), 322 | } 323 | } 324 | 325 | const { result } = renderHook(() => useAllAuth(), { 326 | wrapper: AuthWrapper, 327 | }) 328 | 329 | await act(async () => { 330 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 331 | }) 332 | 333 | await act(async () => { 334 | resolveMe({ username: 'Giova', age: 23 }) 335 | }) 336 | 337 | expect(result.current.user).toEqual({ 338 | user: { username: 'Giova', age: 23 }, 339 | token: 23, 340 | }) 341 | 342 | await act(async () => { 343 | result.current.actions.patchUser({ age: 99 }) 344 | }) 345 | 346 | expect(result.current.user).toEqual({ 347 | user: { username: 'Giova', age: 99 }, 348 | token: 23, 349 | }) 350 | }) 351 | 352 | it('Should not call refreshCall when no refreshToken', async () => { 353 | interface AgedUser { 354 | username: string 355 | age: number 356 | } 357 | 358 | // Fake da calls 359 | const loginCall = jest.fn() 360 | // Hack for manual resolve the me promise 361 | let resolveMe: TestCallBack 362 | const meCall = jest.fn( 363 | () => 364 | new Promise((resolve) => { 365 | resolveMe = resolve 366 | }) 367 | ) 368 | 369 | const refreshToken = jest.fn() 370 | 371 | // Fake a good storage 372 | const resolvesGetItem: TestCallBack[] = [] 373 | const localStorageMock = { 374 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 375 | setItem: jest.fn(), 376 | removeItem: jest.fn(), 377 | } 378 | Object.defineProperty(global, '_localStorage', { 379 | value: localStorageMock, 380 | writable: true, 381 | }) 382 | 383 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 384 | 389 | {children} 390 | 391 | ) 392 | 393 | function useAllAuth() { 394 | return { 395 | user: useAuthUser(), 396 | state: useAuthState(), 397 | actions: useAuthActions(), 398 | } 399 | } 400 | 401 | const { result } = renderHook(() => useAllAuth(), { 402 | wrapper: AuthWrapper, 403 | }) 404 | 405 | await act(async () => { 406 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 407 | }) 408 | 409 | await act(async () => { 410 | resolveMe({ username: 'Giova', age: 23 }) 411 | }) 412 | 413 | expect(result.current.user).toEqual({ 414 | user: { username: 'Giova', age: 23 }, 415 | token: 23, 416 | }) 417 | 418 | expect(result.current.state).toEqual({ 419 | bootstrappedAuth: true, 420 | authenticated: true, 421 | loginLoading: false, 422 | loginError: null, 423 | }) 424 | 425 | const apiFn = jest.fn((token: any) => () => 426 | Promise.reject({ 427 | status: 401, 428 | }) 429 | ) 430 | 431 | await act(async () => { 432 | result.current.actions.callAuthApiPromise(apiFn) 433 | }) 434 | await act(async () => { 435 | await apiFn.mock.results[0].value 436 | }) 437 | expect(apiFn).toHaveBeenLastCalledWith(23) 438 | expect(refreshToken).not.toHaveBeenCalled() 439 | expect(result.current.user).toEqual({ 440 | user: null, 441 | token: null, 442 | }) 443 | expect(result.current.state).toEqual({ 444 | bootstrappedAuth: true, 445 | authenticated: false, 446 | loginLoading: false, 447 | loginError: null, 448 | }) 449 | }) 450 | 451 | it('should init unauthenticated state using initialData prop', async () => { 452 | const loginCall = jest.fn() 453 | const meCall = jest.fn() 454 | 455 | const localStorageMock = { 456 | getItem: jest.fn(), 457 | setItem: jest.fn(), 458 | removeItem: jest.fn(), 459 | } 460 | Object.defineProperty(global, '_localStorage', { 461 | value: localStorageMock, 462 | writable: true, 463 | }) 464 | 465 | const AuthWrapper = ({ 466 | children, 467 | initialData, 468 | }: { 469 | children?: ReactNode 470 | initialData: InitialAuthData 471 | }) => ( 472 | 473 | {children} 474 | 475 | ) 476 | 477 | function useAllAuth() { 478 | return { 479 | actions: useAuthActions(), 480 | user: useAuthUser(), 481 | state: useAuthState(), 482 | } 483 | } 484 | 485 | const { result } = renderHook(() => useAllAuth(), { 486 | wrapper: AuthWrapper, 487 | initialProps: { 488 | initialData: { 489 | user: null, 490 | accessToken: null, 491 | }, 492 | }, 493 | }) 494 | 495 | expect(window.localStorage.getItem).not.toHaveBeenCalled() 496 | expect(window.localStorage.setItem).not.toHaveBeenCalled() 497 | expect(meCall).not.toHaveBeenCalled() 498 | 499 | expect(result.current.user).toEqual({ 500 | token: null, 501 | user: null, 502 | }) 503 | expect(result.current.state).toEqual({ 504 | bootstrappedAuth: true, 505 | authenticated: false, 506 | loginLoading: false, 507 | loginError: null, 508 | }) 509 | 510 | const fakeApi = jest.fn((t) => () => Promise.resolve(88)) 511 | 512 | await act(async () => { 513 | await result.current.actions.callAuthApiPromise(fakeApi) 514 | }) 515 | 516 | // null token 4 ma caller 517 | expect(fakeApi).toHaveBeenCalledWith(null) 518 | }) 519 | 520 | it('should init authenticated state using initialData prop', async () => { 521 | const loginCall = jest.fn() 522 | const meCall = jest.fn() 523 | 524 | const localStorageMock = { 525 | getItem: jest.fn(), 526 | setItem: jest.fn(), 527 | removeItem: jest.fn(), 528 | } 529 | Object.defineProperty(global, '_localStorage', { 530 | value: localStorageMock, 531 | writable: true, 532 | }) 533 | 534 | const AuthWrapper = ({ 535 | children, 536 | initialData, 537 | }: { 538 | children?: ReactNode 539 | initialData: InitialAuthData 540 | }) => ( 541 | 542 | {children} 543 | 544 | ) 545 | 546 | function useAllAuth() { 547 | return { 548 | actions: useAuthActions(), 549 | user: useAuthUser(), 550 | state: useAuthState(), 551 | } 552 | } 553 | 554 | const { result } = renderHook(() => useAllAuth(), { 555 | wrapper: AuthWrapper, 556 | initialProps: { 557 | initialData: { 558 | user: { 559 | id: 23, 560 | username: '@giova', 561 | name: 'Giova', 562 | }, 563 | accessToken: 'z3cr3t', 564 | }, 565 | }, 566 | }) 567 | 568 | expect(window.localStorage.getItem).not.toHaveBeenCalled() 569 | expect(window.localStorage.setItem).not.toHaveBeenCalled() 570 | expect(meCall).not.toHaveBeenCalled() 571 | 572 | expect(result.current.user).toEqual({ 573 | token: 'z3cr3t', 574 | user: { 575 | id: 23, 576 | username: '@giova', 577 | name: 'Giova', 578 | }, 579 | }) 580 | expect(result.current.state).toEqual({ 581 | bootstrappedAuth: true, 582 | authenticated: true, 583 | loginLoading: false, 584 | loginError: null, 585 | }) 586 | 587 | const fakeApi = jest.fn((t) => () => Promise.resolve(88)) 588 | 589 | await act(async () => { 590 | await result.current.actions.callAuthApiPromise(fakeApi) 591 | }) 592 | 593 | // null token 4 ma caller 594 | expect(fakeApi).toHaveBeenCalledWith('z3cr3t') 595 | }) 596 | 597 | it('should call onLogout with last access token when user explicit logged out', async () => { 598 | const loginCall = jest.fn().mockResolvedValue({ 599 | accessToken: 23, 600 | }) 601 | 602 | const meCall = jest.fn().mockResolvedValue({ 603 | id: 23, 604 | name: 'Gio Va', 605 | }) 606 | 607 | // Fake a good storage 608 | const resolvesGetItem: TestCallBack[] = [] 609 | const localStorageMock = { 610 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 611 | setItem: jest.fn(), 612 | removeItem: jest.fn(), 613 | } 614 | Object.defineProperty(global, '_localStorage', { 615 | value: localStorageMock, 616 | writable: true, 617 | }) 618 | 619 | const onLogout = jest.fn() 620 | 621 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 622 | 623 | {children} 624 | 625 | ) 626 | 627 | function useAllAuth() { 628 | return { 629 | actions: useAuthActions(), 630 | user: useAuthUser(), 631 | state: useAuthState(), 632 | } 633 | } 634 | 635 | const { result } = renderHook(() => useAllAuth(), { 636 | wrapper: AuthWrapper, 637 | }) 638 | 639 | await act(async () => { 640 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 641 | }) 642 | 643 | expect(result.current.state).toEqual({ 644 | bootstrappedAuth: true, 645 | authenticated: true, 646 | loginLoading: false, 647 | loginError: null, 648 | }) 649 | 650 | await act(async () => { 651 | result.current.actions.logout() 652 | }) 653 | 654 | expect(result.current.state).toEqual({ 655 | bootstrappedAuth: true, 656 | authenticated: false, 657 | loginLoading: false, 658 | loginError: null, 659 | }) 660 | 661 | expect(onLogout).toHaveBeenCalled() 662 | expect(onLogout).toHaveBeenCalledWith(23) 663 | }) 664 | 665 | it('should call onLogout with last access token when user is kicked by 401', async () => { 666 | const loginCall = jest.fn().mockResolvedValue({ 667 | accessToken: 23, 668 | }) 669 | 670 | const meCall = jest.fn().mockResolvedValue({ 671 | id: 23, 672 | name: 'Gio Va', 673 | }) 674 | 675 | // Fake a good storage 676 | const resolvesGetItem: TestCallBack[] = [] 677 | const localStorageMock = { 678 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 679 | setItem: jest.fn(), 680 | removeItem: jest.fn(), 681 | } 682 | Object.defineProperty(global, '_localStorage', { 683 | value: localStorageMock, 684 | writable: true, 685 | }) 686 | 687 | const onLogout = jest.fn() 688 | 689 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 690 | 691 | {children} 692 | 693 | ) 694 | 695 | function useAllAuth() { 696 | return { 697 | actions: useAuthActions(), 698 | user: useAuthUser(), 699 | state: useAuthState(), 700 | } 701 | } 702 | 703 | const { result } = renderHook(() => useAllAuth(), { 704 | wrapper: AuthWrapper, 705 | }) 706 | 707 | await act(async () => { 708 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 709 | }) 710 | 711 | expect(result.current.state).toEqual({ 712 | bootstrappedAuth: true, 713 | authenticated: true, 714 | loginLoading: false, 715 | loginError: null, 716 | }) 717 | 718 | const kickMe = jest.fn((t) => () => 719 | Promise.reject({ 720 | status: 401, 721 | }) 722 | ) 723 | 724 | await act(async () => { 725 | await result.current.actions.callAuthApiPromise(kickMe).catch(() => {}) 726 | }) 727 | 728 | expect(result.current.state).toEqual({ 729 | bootstrappedAuth: true, 730 | authenticated: false, 731 | loginLoading: false, 732 | loginError: null, 733 | }) 734 | 735 | expect(onLogout).toHaveBeenCalled() 736 | expect(onLogout).toHaveBeenCalledWith(23) 737 | }) 738 | 739 | it('should call onAuthenticate on boot authentication', async () => { 740 | const loginCall = jest.fn().mockResolvedValue({ 741 | accessToken: 23, 742 | }) 743 | 744 | const meCall = jest.fn().mockResolvedValue({ 745 | id: 23, 746 | name: 'Gio Va', 747 | }) 748 | 749 | const onAuthenticate = jest.fn() 750 | 751 | // Fake a good storage 752 | const resolvesGetItem: TestCallBack[] = [] 753 | const localStorageMock = { 754 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 755 | setItem: jest.fn(), 756 | removeItem: jest.fn(), 757 | } 758 | Object.defineProperty(global, '_localStorage', { 759 | value: localStorageMock, 760 | writable: true, 761 | }) 762 | 763 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 764 | 769 | {children} 770 | 771 | ) 772 | 773 | function useAllAuth() { 774 | return { 775 | actions: useAuthActions(), 776 | user: useAuthUser(), 777 | state: useAuthState(), 778 | } 779 | } 780 | 781 | renderHook(() => useAllAuth(), { 782 | wrapper: AuthWrapper, 783 | }) 784 | 785 | await act(async () => { 786 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 787 | }) 788 | 789 | expect(onAuthenticate).toHaveBeenCalledWith( 790 | { 791 | id: 23, 792 | name: 'Gio Va', 793 | }, 794 | 23, 795 | false 796 | ) 797 | }) 798 | 799 | it('should call onAuthenticate on login authentication', async () => { 800 | const loginCall = jest.fn().mockResolvedValue({ 801 | accessToken: 99, 802 | }) 803 | 804 | const meCall = jest.fn().mockResolvedValue({ 805 | id: 1, 806 | name: 'Gio Va U.u', 807 | }) 808 | 809 | const onAuthenticate = jest.fn() 810 | 811 | // Fake a good storage 812 | const resolvesGetItem: TestCallBack[] = [] 813 | const localStorageMock = { 814 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 815 | setItem: jest.fn(), 816 | removeItem: jest.fn(), 817 | } 818 | Object.defineProperty(global, '_localStorage', { 819 | value: localStorageMock, 820 | writable: true, 821 | }) 822 | 823 | const AuthWrapper = ({ children }: { children: ReactNode }) => ( 824 | 829 | {children} 830 | 831 | ) 832 | 833 | function useAllAuth() { 834 | return { 835 | actions: useAuthActions(), 836 | user: useAuthUser(), 837 | state: useAuthState(), 838 | } 839 | } 840 | 841 | const { result } = renderHook(() => useAllAuth(), { 842 | wrapper: AuthWrapper, 843 | }) 844 | 845 | await act(async () => { 846 | resolvesGetItem[0](null) 847 | }) 848 | 849 | expect(onAuthenticate).not.toHaveBeenCalled() 850 | 851 | await act(async () => { 852 | result.current.actions.login({ 853 | dude: 'Gio Va', 854 | }) 855 | }) 856 | 857 | // expect(meCall).toHaveBeenCalled() 858 | 859 | // await act(async () => { 860 | // await meCall.mock.results[0].value 861 | // }) 862 | 863 | expect(onAuthenticate).toHaveBeenCalledWith( 864 | { 865 | id: 1, 866 | name: 'Gio Va U.u', 867 | }, 868 | 99, 869 | true 870 | ) 871 | }) 872 | }) 873 | -------------------------------------------------------------------------------- /src/__tests__/AuthDOM.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { render, fireEvent, act } from '@testing-library/react' 3 | import Auth, { useAuthState, useAuthUser, useAuthActions } from '../index' 4 | import { AuthTokens } from '../types' 5 | 6 | interface TestCallBack { 7 | (value: V): void 8 | } 9 | 10 | interface DummyUser { 11 | username: string 12 | } 13 | 14 | interface DummyLoginCredentials { 15 | username: string 16 | password: string 17 | } 18 | 19 | // Util component to check current auth user 20 | const MaHome = () => { 21 | const { user } = useAuthUser() 22 | const { logout } = useAuthActions() 23 | return ( 24 |
25 | Ma User
{(user as DummyUser).username}
26 | 29 |
30 | ) 31 | } 32 | 33 | // Util component to check da auth state 34 | const WhatInMaAuth = () => { 35 | const { bootstrappedAuth, authenticated } = useAuthState() 36 | return ( 37 |
38 |
39 | {authenticated ? 'Authenticated' : 'Anon'} 40 |
41 |
42 | {!bootstrappedAuth ? 'Booting...' : 'Booted!'} 43 |
44 |
45 | ) 46 | } 47 | 48 | // Util login component 49 | const Login = () => { 50 | const { login } = useAuthActions< 51 | string, 52 | never, 53 | DummyUser, 54 | DummyLoginCredentials 55 | >() 56 | const { loginLoading, loginError } = useAuthState() 57 | return ( 58 |
{ 60 | e.preventDefault() 61 | login({ username: 'giova', password: 'xiboro23' }) 62 | }} 63 | > 64 | 67 | {loginError &&
{loginError}
} 68 |
69 | ) 70 | } 71 | 72 | describe('Auth DOM', () => { 73 | it('should check local storage and not auth user when nothing in local storage', async () => { 74 | // Fake da calls 75 | const loginCall = jest.fn() 76 | const meCall = jest.fn() 77 | 78 | // Fake an empty local storage 79 | const resolvesGetItem: TestCallBack[] = [] 80 | const localStorageMock = { 81 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 82 | setItem: jest.fn(), 83 | removeItem: jest.fn(), 84 | } 85 | Object.defineProperty(global, '_localStorage', { 86 | value: localStorageMock, 87 | writable: true, 88 | }) 89 | 90 | const App = () => ( 91 | 92 | 93 | 94 | ) 95 | 96 | const { getByTestId } = render() 97 | 98 | // After the inital render auth is in booting 99 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 100 | // ... And user not authenticated 101 | expect(getByTestId('authenticated').textContent).toBe('Anon') 102 | 103 | await act(async () => { 104 | resolvesGetItem[0](null) 105 | }) 106 | // Check local stroage to have been called \w the default key auth 107 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 108 | // Ok And now auth should be bootstrapped! 109 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 110 | // But use still anon 111 | expect(getByTestId('authenticated').textContent).toBe('Anon') 112 | }) 113 | 114 | it('should check local storage and authenticate user when a valid token is provided', async () => { 115 | // Fake da calls 116 | const loginCall = jest.fn() 117 | // Hack for manual resolve the me promise 118 | let resolveMe: TestCallBack 119 | const meCall = jest.fn( 120 | () => 121 | new Promise((resolve) => { 122 | resolveMe = resolve 123 | }) 124 | ) 125 | 126 | // Fake a good storage 127 | const resolvesGetItem: TestCallBack[] = [] 128 | const localStorageMock = { 129 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 130 | setItem: jest.fn(), 131 | removeItem: jest.fn(), 132 | } 133 | Object.defineProperty(global, '_localStorage', { 134 | value: localStorageMock, 135 | writable: true, 136 | }) 137 | 138 | const Eazy = () => { 139 | const { authenticated } = useAuthState() 140 | return ( 141 |
142 | 143 | {authenticated && } 144 |
145 | ) 146 | } 147 | 148 | const App = () => ( 149 | 150 | 151 | 152 | ) 153 | 154 | const { getByTestId } = render() 155 | 156 | // After the inital render auth is in booting 157 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 158 | // ... And user not authenticated 159 | expect(getByTestId('authenticated').textContent).toBe('Anon') 160 | 161 | await act(async () => { 162 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 163 | }) 164 | 165 | // Check local stroage to have been called \w the default key auth 166 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 167 | // Ok something is in storage auth still bootstrapping 168 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 169 | // And user still anon ... 170 | expect(getByTestId('authenticated').textContent).toBe('Anon') 171 | 172 | // No run the side effect of me... 173 | await act(async () => { 174 | resolveMe({ username: 'Gio Va' }) 175 | }) 176 | // Check me called 177 | expect(meCall).toHaveBeenLastCalledWith(23) 178 | // At this time ma men should be authenticated and auth booted! 179 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 180 | expect(getByTestId('authenticated').textContent).toBe('Authenticated') 181 | // eheh and now the user should be ma men gio va 182 | expect(getByTestId('username').textContent).toBe('Gio Va') 183 | }) 184 | 185 | it('should check local storage and not auth user when token is bad', async () => { 186 | // Fake da calls 187 | const loginCall = jest.fn() 188 | // Hack for manual resolve the me promise 189 | let rejectMe: TestCallBack 190 | const meCall = jest.fn( 191 | () => 192 | new Promise((resolve, reject) => { 193 | rejectMe = reject 194 | }) 195 | ) 196 | 197 | // Fake a good storage 198 | const resolvesGetItem: TestCallBack[] = [] 199 | const localStorageMock = { 200 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 201 | setItem: jest.fn(), 202 | removeItem: jest.fn(), 203 | } 204 | Object.defineProperty(global, '_localStorage', { 205 | value: localStorageMock, 206 | writable: true, 207 | }) 208 | 209 | const Eazy = () => { 210 | const { authenticated } = useAuthState() 211 | return ( 212 |
213 | 214 | {authenticated && } 215 |
216 | ) 217 | } 218 | 219 | const App = () => ( 220 | 221 | 222 | 223 | ) 224 | 225 | const { getByTestId } = render() 226 | 227 | // After the inital render auth is in booting 228 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 229 | // ... And user not authenticated 230 | expect(getByTestId('authenticated').textContent).toBe('Anon') 231 | 232 | await act(async () => { 233 | resolvesGetItem[0](JSON.stringify({ accessToken: 777 })) 234 | }) 235 | 236 | // Check local stroage to have been called \w the default key auth 237 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 238 | // Ok something is in storage auth still bootstrapping 239 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 240 | // And user still anon ... 241 | expect(getByTestId('authenticated').textContent).toBe('Anon') 242 | 243 | // No run the side effect of me... 244 | await act(async () => { 245 | // Reject ... bad token ... 246 | rejectMe('Bleah') 247 | }) 248 | // Check me called 249 | expect(meCall).toHaveBeenLastCalledWith(777) 250 | // Auth boooted but still anono 251 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 252 | expect(getByTestId('authenticated').textContent).toBe('Anon') 253 | }) 254 | 255 | it('should login and authenticate user', async () => { 256 | // Fake da calls 257 | // Manual trigger da login 258 | let resolveLogin: TestCallBack> 259 | const loginCall = jest.fn( 260 | () => 261 | new Promise>((resolve) => { 262 | resolveLogin = resolve 263 | }) 264 | ) 265 | let resolveMe: TestCallBack 266 | const meCall = jest.fn( 267 | () => 268 | new Promise((resolve) => { 269 | resolveMe = resolve 270 | }) 271 | ) 272 | 273 | // Fake an empty local storage 274 | const resolvesGetItem: TestCallBack[] = [] 275 | const localStorageMock = { 276 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 277 | setItem: jest.fn(), 278 | removeItem: jest.fn(), 279 | } 280 | Object.defineProperty(global, '_localStorage', { 281 | value: localStorageMock, 282 | writable: true, 283 | }) 284 | 285 | const Eazy = () => { 286 | const { authenticated } = useAuthState() 287 | 288 | return ( 289 |
290 | 291 | {!authenticated ? : } 292 |
293 | ) 294 | } 295 | 296 | const App = () => ( 297 | 298 | 299 | 300 | ) 301 | 302 | const { getByTestId } = render() 303 | 304 | // After the inital render auth is in booting 305 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 306 | // ... And user not authenticated 307 | expect(getByTestId('authenticated').textContent).toBe('Anon') 308 | 309 | await act(async () => { 310 | resolvesGetItem[0](null) 311 | }) 312 | 313 | // Check local stroage to have been called \w the default key auth 314 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 315 | // Ok And now auth should be bootstrapped! 316 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 317 | // But use still anon 318 | expect(getByTestId('authenticated').textContent).toBe('Anon') 319 | // Time 2 Login! 320 | fireEvent.click(getByTestId('login-btn')) 321 | // Login performing button should be disabled heheheh 322 | expect((getByTestId('login-btn') as HTMLButtonElement).disabled).toBe(true) 323 | // Run login side effect!!! 324 | await act(async () => { 325 | resolveLogin({ accessToken: 23 }) 326 | }) 327 | // Me not called login still loading 328 | expect((getByTestId('login-btn') as HTMLButtonElement).disabled).toBe(true) 329 | // Run me effect! 330 | await act(async () => { 331 | resolveMe({ username: 'Gio Va' }) 332 | }) 333 | // Login call should be called 334 | expect(loginCall).toHaveBeenLastCalledWith({ 335 | username: 'giova', 336 | password: 'xiboro23', 337 | }) 338 | expect(window.localStorage.setItem).toHaveBeenLastCalledWith( 339 | 'auth', 340 | JSON.stringify({ 341 | expires: null, 342 | accessToken: 23, 343 | }) 344 | ) 345 | // At this time ma men should be authenticated and auth booted! 346 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 347 | expect(getByTestId('authenticated').textContent).toBe('Authenticated') 348 | // eheh and now the user should be ma men gio va 349 | expect(getByTestId('username').textContent).toBe('Gio Va') 350 | }) 351 | 352 | it('should not login when bad credentials', async () => { 353 | // Fake da calls 354 | // Manual trigger da login 355 | let rejectLogin: TestCallBack 356 | const loginCall = jest.fn( 357 | () => 358 | new Promise((resolve, reject) => { 359 | rejectLogin = reject 360 | }) 361 | ) 362 | const meCall = jest.fn() 363 | 364 | // Fake an empty local storage 365 | const resolvesGetItem: TestCallBack[] = [] 366 | const localStorageMock = { 367 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 368 | setItem: jest.fn(), 369 | removeItem: jest.fn(), 370 | } 371 | Object.defineProperty(global, '_localStorage', { 372 | value: localStorageMock, 373 | writable: true, 374 | }) 375 | 376 | const Eazy = () => { 377 | const { authenticated } = useAuthState() 378 | 379 | return ( 380 |
381 | 382 | {!authenticated ? : } 383 |
384 | ) 385 | } 386 | 387 | const App = () => ( 388 | 389 | 390 | 391 | ) 392 | 393 | const { getByTestId } = render() 394 | 395 | // After the inital render auth is in booting 396 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 397 | // ... And user not authenticated 398 | expect(getByTestId('authenticated').textContent).toBe('Anon') 399 | 400 | await act(async () => { 401 | resolvesGetItem[0](null) 402 | }) 403 | 404 | // Check local stroage to have been called \w the default key auth 405 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 406 | // Ok And now auth should be bootstrapped! 407 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 408 | // But use still anon 409 | expect(getByTestId('authenticated').textContent).toBe('Anon') 410 | // Time 2 Login! 411 | fireEvent.click(getByTestId('login-btn')) 412 | // Login performing button should be disabled heheheh 413 | expect((getByTestId('login-btn') as HTMLButtonElement).disabled).toBe(true) 414 | // Run login side effect!!! 415 | await act(async () => { 416 | rejectLogin('Fuckk Offf') 417 | }) 418 | // Finish login loading 419 | expect((getByTestId('login-btn') as HTMLButtonElement).disabled).toBe(false) 420 | // Login call should be called 421 | expect(loginCall).toHaveBeenLastCalledWith({ 422 | username: 'giova', 423 | password: 'xiboro23', 424 | }) 425 | // At this time ma men should be authenticated and auth booted! 426 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 427 | expect(getByTestId('authenticated').textContent).toBe('Anon') 428 | // Check login error 429 | expect(getByTestId('login-error').textContent).toBe('Fuckk Offf') 430 | }) 431 | 432 | it('should logout an authenticated user', async () => { 433 | // Fake da calls 434 | const loginCall = jest.fn() 435 | // Hack for manual resolve the me promise 436 | let resolveMe: TestCallBack 437 | const meCall = jest.fn( 438 | () => 439 | new Promise((resolve) => { 440 | resolveMe = resolve 441 | }) 442 | ) 443 | 444 | // Fake a good storage 445 | const resolvesGetItem: TestCallBack[] = [] 446 | const localStorageMock = { 447 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 448 | setItem: jest.fn(), 449 | removeItem: jest.fn(), 450 | } 451 | Object.defineProperty(global, '_localStorage', { 452 | value: localStorageMock, 453 | writable: true, 454 | }) 455 | 456 | const Eazy = () => { 457 | const { authenticated } = useAuthState() 458 | return ( 459 |
460 | 461 | {authenticated && } 462 |
463 | ) 464 | } 465 | 466 | const App = () => ( 467 | 468 | 469 | 470 | ) 471 | 472 | const { getByTestId } = render() 473 | 474 | // After the inital render auth is in booting 475 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 476 | // ... And user not authenticated 477 | expect(getByTestId('authenticated').textContent).toBe('Anon') 478 | 479 | await act(async () => { 480 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 481 | }) 482 | 483 | // Check local stroage to have been called \w the default key auth 484 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 485 | // Ok something is in storage auth still bootstrapping 486 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 487 | // And user still anon ... 488 | expect(getByTestId('authenticated').textContent).toBe('Anon') 489 | 490 | // Now run the side effect of me... 491 | await act(async () => { 492 | resolveMe({ username: 'Gio Va' }) 493 | }) 494 | // Check me called 495 | expect(meCall).toHaveBeenLastCalledWith(23) 496 | // At this time ma men should be authenticated and auth booted! 497 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 498 | expect(getByTestId('authenticated').textContent).toBe('Authenticated') 499 | // eheh and now the user should be ma men gio va 500 | expect(getByTestId('username').textContent).toBe('Gio Va') 501 | // Goodye boy 502 | fireEvent.click(getByTestId('logout-btn')) 503 | // Should don'r remember ma men 504 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 505 | expect(getByTestId('authenticated').textContent).toBe('Anon') 506 | // Should remove tokens 507 | expect(window.localStorage.removeItem).toHaveBeenLastCalledWith('auth') 508 | }) 509 | 510 | it('should provide a function to call api', async () => { 511 | // Fake da calls 512 | const loginCall = jest.fn() 513 | // Hack for manual resolve the me promise 514 | let resolveMe: TestCallBack 515 | const meCall = jest.fn( 516 | () => 517 | new Promise((resolve) => { 518 | resolveMe = resolve 519 | }) 520 | ) 521 | const getUserStatus = jest.fn(() => () => 522 | new Promise((resolve) => { 523 | resolve('Awesome') 524 | }) 525 | ) 526 | 527 | // Fake a good storage 528 | const resolvesGetItem: TestCallBack[] = [] 529 | const localStorageMock = { 530 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 531 | setItem: jest.fn(), 532 | removeItem: jest.fn(), 533 | } 534 | Object.defineProperty(global, '_localStorage', { 535 | value: localStorageMock, 536 | writable: true, 537 | }) 538 | 539 | const MaHomeCalled = () => { 540 | const { callAuthApiPromise } = useAuthActions() 541 | const [status, setStatus] = useState('') 542 | useEffect(() => { 543 | callAuthApiPromise(getUserStatus).then(setStatus) 544 | }, [callAuthApiPromise]) 545 | 546 | return ( 547 |
548 | Status:
{status}
549 |
550 | ) 551 | } 552 | 553 | const Eazy = () => { 554 | const { authenticated } = useAuthState() 555 | return ( 556 |
557 | 558 | {authenticated && } 559 |
560 | ) 561 | } 562 | 563 | const App = () => ( 564 | 565 | 566 | 567 | ) 568 | 569 | const { getByTestId } = render() 570 | 571 | // Auth booted 572 | await act(async () => { 573 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 574 | }) 575 | 576 | // Now run the side effect of me... 577 | await act(async () => { 578 | resolveMe({ username: 'Gio Va' }) 579 | }) 580 | 581 | // The api fn provided should be called \w token 582 | expect(getUserStatus).toHaveBeenLastCalledWith(23) 583 | // Match response 584 | expect(getByTestId('status').textContent).toBe('Awesome') 585 | }) 586 | 587 | it('should provide a function to call api and logout when raise a 401 status code', async () => { 588 | // Fake da calls 589 | const loginCall = jest.fn() 590 | // Hack for manual resolve the me promise 591 | let resolveMe: TestCallBack 592 | const meCall = jest.fn( 593 | () => 594 | new Promise((resolve) => { 595 | resolveMe = resolve 596 | }) 597 | ) 598 | const getUserStatus = jest.fn(() => () => 599 | new Promise((resolve, reject) => { 600 | reject({ status: 401 }) 601 | }) 602 | ) 603 | 604 | const getUserStatus2 = jest.fn(() => () => 605 | new Promise((resolve) => resolve('XD')) 606 | ) 607 | 608 | // Fake a good storage 609 | const resolvesGetItem: TestCallBack[] = [] 610 | const localStorageMock = { 611 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 612 | setItem: jest.fn(), 613 | removeItem: jest.fn(), 614 | } 615 | Object.defineProperty(global, '_localStorage', { 616 | value: localStorageMock, 617 | writable: true, 618 | }) 619 | 620 | const MaHomeCalled = () => { 621 | const { callAuthApiPromise } = useAuthActions() 622 | const [status, setStatus] = useState('') 623 | useEffect(() => { 624 | callAuthApiPromise(getUserStatus).then(setStatus, () => {}) 625 | }, [callAuthApiPromise]) 626 | 627 | return ( 628 |
629 | Status:
{status}
630 |
631 | ) 632 | } 633 | 634 | const BananaSplit = () => { 635 | const { callAuthApiPromise } = useAuthActions() 636 | return ( 637 |
638 | 646 |
647 | ) 648 | } 649 | 650 | const Eazy = () => { 651 | const { authenticated } = useAuthState() 652 | return ( 653 |
654 | 655 | 656 | {authenticated && } 657 |
658 | ) 659 | } 660 | 661 | const App = () => ( 662 | 663 | 664 | 665 | ) 666 | 667 | const { getByTestId } = render() 668 | 669 | // Auth booted 670 | await act(async () => { 671 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 672 | }) 673 | 674 | // Now run the side effect of me... 675 | await act(async () => { 676 | resolveMe({ username: 'Gio Va' }) 677 | }) 678 | 679 | // The api fn provided should be called \w token 680 | expect(getUserStatus).toHaveBeenLastCalledWith(23) 681 | 682 | // Check perform logout 683 | expect(getByTestId('authenticated').textContent).toBe('Anon') 684 | expect(window.localStorage.removeItem).toHaveBeenLastCalledWith('auth') 685 | 686 | fireEvent.click(getByTestId('btn-call')) 687 | // Check callApi call with empty token... 688 | process.nextTick(() => { 689 | expect(getUserStatus2).toHaveBeenLastCalledWith(null) 690 | }) 691 | }) 692 | 693 | it('should try to refresh token on boot when me give 401', async () => { 694 | // Fake da calls 695 | const loginCall = jest.fn() 696 | let rejectMe: TestCallBack 697 | let resolveMe: TestCallBack 698 | const meCall = jest.fn( 699 | () => 700 | new Promise((resolve, reject) => { 701 | resolveMe = resolve 702 | rejectMe = reject 703 | }) 704 | ) 705 | let resolveRefresh: TestCallBack> 706 | const refreshTokenCall = jest.fn( 707 | () => 708 | new Promise>((resolve, reject) => { 709 | resolveRefresh = resolve 710 | }) 711 | ) 712 | 713 | // Fake a stroage \w a bad access token and good refresh 714 | const resolvesGetItem: TestCallBack[] = [] 715 | const localStorageMock = { 716 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 717 | setItem: jest.fn(), 718 | removeItem: jest.fn(), 719 | } 720 | Object.defineProperty(global, '_localStorage', { 721 | value: localStorageMock, 722 | writable: true, 723 | }) 724 | 725 | const Eazy = () => { 726 | const { authenticated } = useAuthState() 727 | return ( 728 |
729 | 730 | {authenticated && } 731 |
732 | ) 733 | } 734 | 735 | const App = () => ( 736 | 741 | 742 | 743 | ) 744 | 745 | const { getByTestId } = render() 746 | 747 | // After the inital render auth is in booting 748 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 749 | // ... And user not authenticated 750 | expect(getByTestId('authenticated').textContent).toBe('Anon') 751 | 752 | await act(async () => { 753 | resolvesGetItem[0]( 754 | JSON.stringify({ 755 | accessToken: 23, 756 | refreshToken: 777, 757 | }) 758 | ) 759 | }) 760 | 761 | // Check local stroage to have been called \w the default key auth 762 | expect(window.localStorage.getItem).toHaveBeenLastCalledWith('auth') 763 | // Ok something is in storage auth still bootstrapping 764 | expect(getByTestId('auth-booted').textContent).toBe('Booting...') 765 | // And user still anon ... 766 | expect(getByTestId('authenticated').textContent).toBe('Anon') 767 | 768 | // Check me called 769 | expect(meCall).toHaveBeenLastCalledWith(23) 770 | 771 | // Reject 401 from me 772 | await act(async () => { 773 | rejectMe({ status: 401 }) 774 | }) 775 | 776 | // Now should we start the refresh 777 | expect(refreshTokenCall).toHaveBeenLastCalledWith(777) 778 | await act(async () => { 779 | resolveRefresh({ accessToken: 2323, refreshToken: 69 }) 780 | }) 781 | 782 | await refreshTokenCall.mock.results[0].value 783 | 784 | expect(meCall).toHaveBeenNthCalledWith(2, 2323) 785 | // Ok time to update ma men React state 786 | await act(async () => { 787 | resolveMe({ username: 'Gio Va' }) 788 | }) 789 | expect(window.localStorage.setItem).toHaveBeenLastCalledWith( 790 | 'auth', 791 | JSON.stringify({ 792 | accessToken: 2323, 793 | refreshToken: 69, 794 | }) 795 | ) 796 | // At this time ma men should be authenticated and auth booted! 797 | expect(getByTestId('auth-booted').textContent).toBe('Booted!') 798 | expect(getByTestId('authenticated').textContent).toBe('Authenticated') 799 | // eheh and now the user should be ma men gio va 800 | expect(getByTestId('username').textContent).toBe('Gio Va') 801 | // TODO: Check the valid access token in state 802 | // and fucking refresh in da ref! 803 | }) 804 | 805 | it('should provide a function to call api ... with refresh YEAH!', async () => { 806 | // Fake da calls 807 | const loginCall = jest.fn() 808 | // Hack for manual resolve the me promise 809 | let resolveMe: TestCallBack 810 | const meCall = jest.fn( 811 | () => 812 | new Promise((resolve) => { 813 | resolveMe = resolve 814 | }) 815 | ) 816 | let resolveApi: TestCallBack 817 | let rejectApi: TestCallBack 818 | const getUserStatus = jest.fn(() => () => 819 | new Promise((resolve, reject) => { 820 | resolveApi = resolve 821 | rejectApi = reject 822 | }) 823 | ) 824 | let resolveRefresh: TestCallBack> 825 | const refreshTokenCall = jest.fn( 826 | () => 827 | new Promise>((resolve, reject) => { 828 | resolveRefresh = resolve 829 | }) 830 | ) 831 | 832 | // Fake a good storage 833 | const resolvesGetItem: TestCallBack[] = [] 834 | const localStorageMock = { 835 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 836 | setItem: jest.fn(), 837 | removeItem: jest.fn(), 838 | } 839 | Object.defineProperty(global, '_localStorage', { 840 | value: localStorageMock, 841 | writable: true, 842 | }) 843 | 844 | const MaHomeCalled = () => { 845 | const { callAuthApiPromise } = useAuthActions() 846 | const [status, setStatus] = useState('') 847 | useEffect(() => { 848 | callAuthApiPromise(getUserStatus).then(setStatus) 849 | }, [callAuthApiPromise]) 850 | 851 | return ( 852 |
853 | Status:
{status}
854 |
855 | ) 856 | } 857 | 858 | const Eazy = () => { 859 | const { authenticated } = useAuthState() 860 | return ( 861 |
862 | 863 | {authenticated && } 864 |
865 | ) 866 | } 867 | 868 | const App = () => ( 869 | 874 | 875 | 876 | ) 877 | 878 | const { getByTestId } = render() 879 | 880 | await act(async () => { 881 | resolvesGetItem[0]( 882 | JSON.stringify({ 883 | accessToken: 23, 884 | refreshToken: 777, 885 | }) 886 | ) 887 | }) 888 | // Auth booted 889 | 890 | // Now run the side effect of me... 891 | await act(async () => { 892 | resolveMe({ username: 'Gio Va' }) 893 | }) 894 | 895 | // The api fn provided should be called \w token 896 | expect(getUserStatus).toHaveBeenLastCalledWith(23) 897 | 898 | await act(async () => { 899 | rejectApi({ status: 401 }) 900 | }) 901 | 902 | // Wait reject ... 903 | try { 904 | await getUserStatus.mock.results[0].value 905 | } catch {} 906 | 907 | // Now should we start the refresh 908 | expect(refreshTokenCall).toHaveBeenLastCalledWith(777) 909 | // Resolve fake refresh call 910 | await act(async () => { 911 | resolveRefresh({ accessToken: 2323, refreshToken: 69 }) 912 | }) 913 | // Wait for refresh to be processed ... 914 | await refreshTokenCall.mock.results[0].value 915 | // Check re-call API \w new token 916 | expect(getUserStatus).toHaveBeenNthCalledWith(2, 2323) 917 | // Resolve fake API 918 | await act(async () => { 919 | resolveApi('Awesome') 920 | }) 921 | // Check token saved in storage 922 | expect(window.localStorage.setItem).toHaveBeenLastCalledWith( 923 | 'auth', 924 | JSON.stringify({ 925 | accessToken: 2323, 926 | refreshToken: 69, 927 | }) 928 | ) 929 | expect(getByTestId('status').textContent).toBe('Awesome') 930 | }) 931 | 932 | it('should allow to disable storage of tokens', async () => { 933 | // Fake da calls 934 | const loginCall = jest.fn() 935 | const meCall = jest.fn() 936 | 937 | const storeGet = jest.fn() 938 | const storeSet = jest.fn() 939 | const storeClear = jest.fn() 940 | 941 | // Fake an empty local storage 942 | const localStorageMock = { 943 | getItem: storeGet, 944 | setItem: storeSet, 945 | removeItem: storeClear, 946 | } 947 | Object.defineProperty(global, '_localStorage', { 948 | value: localStorageMock, 949 | writable: true, 950 | }) 951 | 952 | const AuthObserver = () => { 953 | const { bootstrappedAuth } = useAuthState() 954 | if (!bootstrappedAuth) { 955 | return null 956 | } else { 957 | return

Auth initialized!

958 | } 959 | } 960 | 961 | const App = () => ( 962 | 963 | 964 | 965 | ) 966 | 967 | const { findByTestId } = render() 968 | 969 | await findByTestId('auth-booted') 970 | 971 | expect(storeGet).not.toBeCalled() 972 | expect(storeSet).not.toBeCalled() 973 | expect(storeClear).not.toBeCalled() 974 | }) 975 | 976 | it('should not set state on umounted component', async () => { 977 | // Fake da calls 978 | const loginCall = jest.fn() 979 | // Hack for manual resolve the me promise 980 | let resolveMe: TestCallBack 981 | const meCall = jest.fn( 982 | () => 983 | new Promise((resolve) => { 984 | resolveMe = resolve 985 | }) 986 | ) 987 | 988 | // Fake a good storage 989 | const resolvesGetItem: TestCallBack[] = [] 990 | const localStorageMock = { 991 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 992 | setItem: jest.fn(), 993 | removeItem: jest.fn(), 994 | } 995 | Object.defineProperty(global, '_localStorage', { 996 | value: localStorageMock, 997 | writable: true, 998 | }) 999 | 1000 | const Eazy = () => { 1001 | return ( 1002 |
1003 |

HELLO!

1004 |
1005 | ) 1006 | } 1007 | 1008 | const App = () => ( 1009 | 1010 | 1011 | 1012 | ) 1013 | 1014 | const { unmount } = render() 1015 | 1016 | await act(async () => { 1017 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 1018 | }) 1019 | 1020 | await act(async () => { 1021 | unmount() 1022 | }) 1023 | 1024 | const spyConsoleError = jest.spyOn(console, 'error') 1025 | await act(async () => { 1026 | resolveMe({ username: 'Gio Va' }) 1027 | }) 1028 | 1029 | expect(spyConsoleError).not.toHaveBeenCalled() 1030 | }) 1031 | 1032 | it('should not set state on umounted component while login', async () => { 1033 | // Fake da calls 1034 | // Manual trigger da login 1035 | let resolveLogin: TestCallBack> 1036 | const loginCall = jest.fn( 1037 | () => 1038 | new Promise>((resolve) => { 1039 | resolveLogin = resolve 1040 | }) 1041 | ) 1042 | const meCall = jest.fn(() => Promise.resolve({ name: 'X' })) 1043 | 1044 | // Fake an empty local storage 1045 | const resolvesGetItem: TestCallBack[] = [] 1046 | const localStorageMock = { 1047 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 1048 | setItem: jest.fn(), 1049 | removeItem: jest.fn(), 1050 | } 1051 | Object.defineProperty(global, '_localStorage', { 1052 | value: localStorageMock, 1053 | writable: true, 1054 | }) 1055 | 1056 | const Eazy = () => { 1057 | const { authenticated } = useAuthState() 1058 | 1059 | return ( 1060 |
1061 | 1062 | {!authenticated ? : } 1063 |
1064 | ) 1065 | } 1066 | 1067 | const App = () => ( 1068 | 1069 | 1070 | 1071 | ) 1072 | 1073 | const { getByTestId, unmount } = render() 1074 | 1075 | await act(async () => { 1076 | resolvesGetItem[0](null) 1077 | }) 1078 | 1079 | // Time 2 Login! 1080 | fireEvent.click(getByTestId('login-btn')) 1081 | 1082 | await act(async () => { 1083 | unmount() 1084 | }) 1085 | 1086 | const spyConsoleError = jest.spyOn(console, 'error') 1087 | await act(async () => { 1088 | resolveLogin({ accessToken: 23 }) 1089 | }) 1090 | expect(spyConsoleError).not.toHaveBeenCalled() 1091 | }) 1092 | }) 1093 | -------------------------------------------------------------------------------- /src/actionCreators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UPDATE_USER, 3 | PATCH_USER, 4 | CLEAR_LOGIN_ERROR, 5 | FunctionalUpdaterUser, 6 | } from './actionTypes' 7 | 8 | export const clearLoginError = () => ({ 9 | type: CLEAR_LOGIN_ERROR, 10 | }) 11 | 12 | export const updateUser = ( 13 | userOrUpdater: U | FunctionalUpdaterUser | null 14 | ) => ({ 15 | type: UPDATE_USER, 16 | payload: userOrUpdater, 17 | }) 18 | 19 | export const patchUser = (partialUser: Partial) => ({ 20 | type: PATCH_USER, 21 | payload: partialUser, 22 | }) 23 | -------------------------------------------------------------------------------- /src/actionTypes.ts: -------------------------------------------------------------------------------- 1 | import { AuthTokens } from './types' 2 | 3 | export const BOOTSTRAP_AUTH_START = 'BOOTSTRAP_AUTH_START' 4 | export const BOOTSTRAP_AUTH_END = 'BOOTSTRAP_AUTH_END' 5 | 6 | export const LOGIN_LOADING = 'LOGIN_LOADING' 7 | export const LOGIN_FAILURE = 'LOGIN_FAILURE' 8 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' 9 | 10 | export const CLEAR_LOGIN_ERROR = 'CLEAR_LOGIN_ERROR' 11 | 12 | export const LOGOUT = 'LOGOUT' 13 | 14 | export const TOKEN_REFRESHING = 'TOKEN_REFRESHING' 15 | export const TOKEN_REFRESHED = 'TOKEN_REFRESHED' 16 | 17 | export const UPDATE_USER = 'UPDATE_USER' 18 | 19 | export const PATCH_USER = 'PATCH_USER' 20 | 21 | export const SET_TOKENS = 'SET_TOKENS' 22 | 23 | export type EndBootPayload = 24 | | { 25 | authenticated: false 26 | } 27 | | (AuthTokens & { 28 | user: any 29 | authenticated: true 30 | }) 31 | 32 | export interface TokenRefreshedAction { 33 | type: typeof TOKEN_REFRESHED 34 | payload: AuthTokens 35 | } 36 | 37 | export interface LogoutAction { 38 | type: typeof LOGOUT 39 | } 40 | 41 | export interface TokenRefreshingAction { 42 | type: typeof TOKEN_REFRESHING 43 | } 44 | 45 | export type FunctionalUpdaterUser = (user: U | null) => U | null 46 | 47 | // NOTE: Action collection 4 reducer 48 | export type AuthActions
= 49 | | { 50 | type: typeof LOGIN_LOADING 51 | } 52 | | { 53 | type: typeof LOGIN_FAILURE 54 | error: any 55 | } 56 | | { 57 | type: typeof LOGIN_SUCCESS 58 | payload: AuthTokens & { 59 | user: U 60 | } 61 | } 62 | | { 63 | type: typeof CLEAR_LOGIN_ERROR 64 | } 65 | | { 66 | type: typeof BOOTSTRAP_AUTH_START 67 | } 68 | | { 69 | type: typeof BOOTSTRAP_AUTH_END 70 | payload: EndBootPayload 71 | } 72 | | { 73 | type: typeof SET_TOKENS 74 | payload: AuthTokens 75 | } 76 | | TokenRefreshedAction 77 | | { 78 | type: typeof UPDATE_USER 79 | payload: FunctionalUpdaterUser | U | null 80 | } 81 | | { 82 | type: typeof PATCH_USER 83 | payload: Partial 84 | } 85 | | LogoutAction 86 | -------------------------------------------------------------------------------- /src/authEffects.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject } from 'react' 2 | import { from, of, throwError, Subject, Observable } from 'rxjs' 3 | import { mergeMap, map, catchError, exhaustMap, tap } from 'rxjs/operators' 4 | import { 5 | BOOTSTRAP_AUTH_START, 6 | BOOTSTRAP_AUTH_END, 7 | LOGIN_LOADING, 8 | LOGIN_SUCCESS, 9 | LOGIN_FAILURE, 10 | AuthActions, 11 | EndBootPayload, 12 | } from './actionTypes' 13 | import { AuthStorage } from './storage' 14 | import { AuthTokens, LoginCall, MeCall, RefreshTokenCall } from './types' 15 | import { isUnauthorizedError } from './utils' 16 | 17 | export type ApiFn = (token: T) => Promise | Observable 18 | 19 | function makeCallWithRefresh( 20 | accessToken: A, 21 | refreshToken?: R | null, 22 | refreshTokenCall?: RefreshTokenCall 23 | ) { 24 | return ( 25 | apiFn: ApiFn 26 | ): Observable<{ 27 | response: any 28 | refreshedTokens: null | AuthTokens 29 | }> => { 30 | return from(apiFn(accessToken)).pipe( 31 | map((response) => ({ response, refreshedTokens: null })), 32 | catchError((error) => { 33 | if ( 34 | // Try refresh when: 35 | // Got an auth error 36 | isUnauthorizedError(error) && 37 | // We have a refresh token and an api call that we can perform 38 | // 2 refresh it! 39 | refreshToken && 40 | typeof refreshTokenCall === 'function' 41 | ) { 42 | return from(refreshTokenCall(refreshToken)).pipe( 43 | mergeMap((refreshedTokens) => { 44 | // Yeah refresh appends! 45 | // Ok now retry the apiFn \w refreshed shit! 46 | return from(apiFn(refreshedTokens.accessToken)).pipe( 47 | map((response) => { 48 | // Curry the refreshed shit \w response 49 | return { response, refreshedTokens } 50 | }), 51 | // The error of new api fn don't really means 52 | // instead reject the original 401 to enforce logout process 53 | catchError(() => throwError(error)) 54 | ) 55 | }), 56 | // At this point the refresh error does not is so usefuel 57 | // instead reject the original 401 to enforce logout process 58 | catchError(() => throwError(error)) 59 | ) 60 | } 61 | // Normal rejection 62 | return throwError(error) 63 | }) 64 | ) 65 | } 66 | } 67 | 68 | export function getBootAuthObservable( 69 | meCall: MeCall, 70 | refreshTokenCall: RefreshTokenCall | undefined, 71 | storage: AuthStorage 72 | ) { 73 | return from(storage.getTokens()).pipe( 74 | mergeMap((tokensInStorage) => { 75 | // Prepare the ~ M A G I K ~ Api call with refresh 76 | const callWithRefresh = makeCallWithRefresh( 77 | tokensInStorage.accessToken, 78 | tokensInStorage.refreshToken, 79 | refreshTokenCall 80 | ) 81 | 82 | return callWithRefresh(meCall).pipe( 83 | catchError((err) => { 84 | // Clear bad tokens from storage 85 | storage.removeTokens() 86 | return throwError(err) 87 | }), 88 | map((responseWithRefresh) => ({ 89 | tokensInStorage, 90 | responseWithRefresh, 91 | })) 92 | ) 93 | }) 94 | ) 95 | } 96 | 97 | // Boot eazy-auth 98 | // Read tokens from provided storage 99 | // if any try to use theese to authenticate the user \w the given meCall 100 | // LS -> meCall(token) -> user 101 | // dispatch to top state and keep token in sync using a React useRef 102 | export function bootAuth( 103 | meCall: MeCall, 104 | refreshTokenCall: RefreshTokenCall | undefined, 105 | storage: AuthStorage, 106 | dispatch: Dispatch, 107 | tokenRef: MutableRefObject | null>, 108 | bootRef: MutableRefObject 109 | ) { 110 | // My Auth Alredy Booted 111 | if (bootRef.current) { 112 | return () => {} 113 | } 114 | 115 | // Shortcut to finish boot process default not authenticated 116 | function endBoot(payload: EndBootPayload = { authenticated: false }) { 117 | bootRef.current = true 118 | dispatch({ 119 | type: BOOTSTRAP_AUTH_END, 120 | payload, 121 | }) 122 | } 123 | 124 | dispatch({ type: BOOTSTRAP_AUTH_START }) 125 | 126 | const subscription = getBootAuthObservable( 127 | meCall, 128 | refreshTokenCall, 129 | storage 130 | ).subscribe(({ tokensInStorage, responseWithRefresh }) => { 131 | const { response: user, refreshedTokens } = responseWithRefresh 132 | // If token refreshed take the token refreshed as valid otherwise use the good 133 | // old tokens from local storage 134 | const validTokens = refreshedTokens ? refreshedTokens : tokensInStorage 135 | // GANG saved the valid tokens to the current ref! 136 | tokenRef.current = validTokens 137 | // Tell to ma reducer 138 | endBoot({ 139 | authenticated: true, 140 | user, 141 | ...validTokens, 142 | }) 143 | // Plus only if refreshed save freshed in local storage! 144 | if (refreshedTokens) { 145 | storage.setTokens(refreshedTokens) 146 | } 147 | }, endBoot) 148 | 149 | return () => subscription.unsubscribe() 150 | } 151 | 152 | interface SuccessLoginEffectAction { 153 | type: typeof LOGIN_SUCCESS 154 | payload: { 155 | loginResponse: AuthTokens 156 | user: any 157 | } 158 | } 159 | 160 | interface FailureLoginEffectAction { 161 | type: typeof LOGIN_FAILURE 162 | error: any 163 | } 164 | 165 | export interface LoginEffect { 166 | performLogin: (loginCredentials: C) => void 167 | unsubscribe(): void 168 | } 169 | 170 | export function makePerformLogin( 171 | loginCall: LoginCall, 172 | meCall: MeCall, 173 | storage: AuthStorage, 174 | dispatch: Dispatch>, 175 | tokenRef: MutableRefObject | null> 176 | ): LoginEffect { 177 | const loginTrigger = new Subject() 178 | 179 | const subscription = loginTrigger 180 | .asObservable() 181 | .pipe( 182 | tap(() => dispatch({ type: LOGIN_LOADING })), 183 | exhaustMap((loginCredentials) => { 184 | return from(loginCall(loginCredentials)).pipe( 185 | mergeMap((loginResponse) => { 186 | const { accessToken } = loginResponse 187 | return from(meCall(accessToken, loginResponse)).pipe( 188 | map( 189 | (user) => 190 | ({ 191 | type: LOGIN_SUCCESS, 192 | payload: { loginResponse, user }, 193 | } as SuccessLoginEffectAction) 194 | ) 195 | ) 196 | }), 197 | catchError((error) => 198 | of({ 199 | type: LOGIN_FAILURE, 200 | error, 201 | } as FailureLoginEffectAction) 202 | ) 203 | ) 204 | }) 205 | ) 206 | .subscribe((action) => { 207 | if (action.type === LOGIN_SUCCESS) { 208 | // Login Flow Success 209 | const { loginResponse, user } = action.payload 210 | const { accessToken, refreshToken, expires = null } = loginResponse 211 | // Save the token ref GANG! 212 | tokenRef.current = { accessToken, refreshToken, expires } 213 | dispatch({ 214 | type: LOGIN_SUCCESS, 215 | payload: { 216 | user, 217 | expires, 218 | accessToken, 219 | refreshToken, 220 | }, 221 | }) 222 | // Ok this can be an async action sure but 223 | // is better wait them and so do waiting the use before 224 | // notify them that login was success i don't kown.... 225 | storage.setTokens({ 226 | expires, 227 | accessToken, 228 | refreshToken, 229 | }) 230 | } else if (action.type === LOGIN_FAILURE) { 231 | // Login Flow Failure 232 | dispatch(action) 233 | } 234 | }) 235 | 236 | const performLogin = (loginCredentials: C) => { 237 | loginTrigger.next(loginCredentials) 238 | } 239 | 240 | const unsubscribe = () => { 241 | subscription.unsubscribe() 242 | } 243 | 244 | return { performLogin, unsubscribe } 245 | } 246 | -------------------------------------------------------------------------------- /src/bindActionCreators.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts 2 | 3 | import { Dispatch } from 'react' 4 | 5 | type ActionCreator = (...args: any[]) => any 6 | 7 | function bindActionCreator( 8 | actionCreator: ActionCreator, 9 | dispatch: Dispatch 10 | ) { 11 | return function (this: any, ...args: any[]) { 12 | return dispatch(actionCreator.apply(this, args)) 13 | } 14 | } 15 | 16 | export type ActionCreators = { 17 | [k: string]: ActionCreator 18 | } 19 | 20 | export default function bindActionCreators( 21 | actionCreators: A, 22 | dispatch: Dispatch 23 | ): A { 24 | const boundActionCreators = {} as Record 25 | for (const key in actionCreators) { 26 | const actionCreator = actionCreators[key] 27 | if (typeof actionCreator === 'function') { 28 | boundActionCreators[key] = bindActionCreator(actionCreator, dispatch) 29 | } 30 | } 31 | return boundActionCreators 32 | } 33 | -------------------------------------------------------------------------------- /src/callApiRx.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject } from 'react' 2 | import { 3 | Subject, 4 | concat, 5 | of, 6 | defer, 7 | from, 8 | throwError, 9 | Observable, 10 | EMPTY, 11 | ConnectableObservable, 12 | } from 'rxjs' 13 | import { 14 | filter, 15 | exhaustMap, 16 | takeUntil, 17 | publish, 18 | map, 19 | catchError, 20 | take, 21 | mergeMap, 22 | } from 'rxjs/operators' 23 | import { 24 | LOGOUT, 25 | TOKEN_REFRESHED, 26 | TOKEN_REFRESHING, 27 | BOOTSTRAP_AUTH_END, 28 | AuthActions, 29 | TokenRefreshedAction, 30 | LogoutAction, 31 | TokenRefreshingAction, 32 | } from './actionTypes' 33 | import { AuthStorage } from './storage' 34 | import { 35 | AuthTokens, 36 | RefreshTokenCall, 37 | CurryAuthApiFn, 38 | CurryAuthApiFnPromise, 39 | } from './types' 40 | import { isUnauthorizedError } from './utils' 41 | 42 | // Emulate a 401 Unauthorized from server .... 43 | const UNAUTHORIZED_ERROR_SHAPE = { 44 | status: 401, 45 | fromRefresh: true, 46 | } 47 | 48 | const tokenRefreshed = (refreshedTokens: AuthTokens) => ({ 49 | type: TOKEN_REFRESHED, 50 | payload: refreshedTokens, 51 | }) 52 | 53 | const tokenRefreshing = () => ({ 54 | type: TOKEN_REFRESHING, 55 | }) 56 | 57 | export interface CallApiEffect { 58 | callAuthApiPromise( 59 | apiFn: CurryAuthApiFnPromise, 60 | ...args: any[] 61 | ): Promise 62 | 63 | callAuthApiObservable( 64 | apiFn: CurryAuthApiFn, 65 | ...args: any[] 66 | ): Observable 67 | 68 | unsubscribe(): void 69 | } 70 | 71 | // Wecolme 2 ~ H E L L ~ 72 | // callApi implemented using rxjs too keep only 1 refreshing task at time 73 | export default function makeCallApiRx( 74 | refreshTokenCall: RefreshTokenCall | undefined, 75 | dispatch: Dispatch, 76 | storage: AuthStorage, 77 | tokenRef: MutableRefObject | null>, 78 | bootRef: MutableRefObject, 79 | actionObservable: Observable 80 | ): CallApiEffect { 81 | const logout = () => dispatch({ type: LOGOUT }) 82 | 83 | let refreshingSemaphore = false 84 | 85 | // An Observable that emit when logout was dispatched 86 | const logoutObservable = actionObservable.pipe( 87 | filter((action) => action.type === LOGOUT) 88 | ) 89 | 90 | // Subject for emit refresh tasks 91 | const refreshEmitter = new Subject() 92 | 93 | // An Observable that perform the refresh token call 94 | // until logout was dispatched and emit actions 95 | const refreshRoutine = refreshEmitter.asObservable().pipe( 96 | exhaustMap((refreshToken) => { 97 | return concat( 98 | of(tokenRefreshing()), 99 | from( 100 | refreshTokenCall && refreshToken 101 | ? refreshTokenCall(refreshToken) 102 | : throwError(null) 103 | ).pipe( 104 | map((refreshResponse) => 105 | tokenRefreshed({ 106 | accessToken: refreshResponse.accessToken, 107 | refreshToken: refreshResponse.refreshToken, 108 | expires: refreshResponse.expires, 109 | }) 110 | ), 111 | catchError(() => of({ type: LOGOUT })), 112 | takeUntil(logoutObservable) 113 | ) 114 | ) 115 | }), 116 | publish() 117 | ) as ConnectableObservable< 118 | LogoutAction | TokenRefreshedAction | TokenRefreshingAction 119 | > 120 | 121 | // Make an Observable that complete with access token 122 | // when TOKEN_REFRESHED action is dispatched 123 | // or throw a simil 401 error when logout is dispatched 124 | // this can be used as 'virtual' refreshToken() api 125 | function waitForStoreRefreshObservable() { 126 | return actionObservable.pipe( 127 | filter( 128 | (action) => action.type === TOKEN_REFRESHED || action.type === LOGOUT 129 | ), 130 | take(1), 131 | mergeMap((action) => { 132 | if (action.type === LOGOUT) { 133 | return throwError(UNAUTHORIZED_ERROR_SHAPE) 134 | } 135 | return of((action as TokenRefreshedAction).payload.accessToken) 136 | }) 137 | ) 138 | } 139 | 140 | // Make an Observable that complete with token or throw a 401 like error 141 | // Handle theee situations: 142 | // - Wait eazy auth to booted before try to getting an access token 143 | // - Wait a peening refresh task (if any) before getting an access token 144 | function getAccessToken() { 145 | const authBooted = bootRef.current 146 | 147 | // Wait eazy-auth boot ... 148 | let waitBootObservable 149 | if (!authBooted) { 150 | waitBootObservable = actionObservable.pipe( 151 | filter((action) => action.type === BOOTSTRAP_AUTH_END), 152 | take(1), 153 | mergeMap(() => EMPTY) 154 | ) 155 | } else { 156 | waitBootObservable = EMPTY 157 | } 158 | 159 | return concat( 160 | waitBootObservable, 161 | defer(() => { 162 | // Get the actual token 163 | const { accessToken = null } = tokenRef.current || {} 164 | 165 | // Not authenticated, complete empty 166 | if (accessToken === null) { 167 | return of(null) 168 | } 169 | 170 | const refreshing = refreshingSemaphore 171 | 172 | // Refresh in place wait from store 173 | if (refreshing) { 174 | return waitForStoreRefreshObservable() 175 | } 176 | 177 | // Valid acces token in store! 178 | return of(accessToken) 179 | }) 180 | ) 181 | } 182 | 183 | // Make an observable that refresh token 184 | // only with no pending refresh is in place 185 | // complete \w refresh token or throw a 401 like error 186 | function refreshOnUnauth(accessToken2Refresh: A) { 187 | const { accessToken = null, refreshToken = null } = tokenRef.current || {} 188 | 189 | if (accessToken === null) { 190 | // An error occurred but in the meanwhile 191 | // logout or bad refresh was happends... 192 | return throwError(UNAUTHORIZED_ERROR_SHAPE) 193 | } 194 | 195 | const refreshing = refreshingSemaphore 196 | if (refreshing) { 197 | return waitForStoreRefreshObservable() 198 | } 199 | 200 | if (accessToken !== accessToken2Refresh) { 201 | // Another cool guy has refresh ma token 202 | // return new tokens ... 203 | return of(accessToken) 204 | } 205 | 206 | // Ok this point token match the current 207 | // no refresh ar in place so .... 208 | // start refresh! 209 | refreshEmitter.next(refreshToken) 210 | return waitForStoreRefreshObservable() 211 | } 212 | 213 | // Logout user when an unauthorized error happens or refresh failed 214 | function unauthLogout(badAccessToken: A, error: any) { 215 | const { accessToken = null } = tokenRef.current || {} 216 | 217 | if ( 218 | accessToken !== null && 219 | !refreshingSemaphore && 220 | accessToken === badAccessToken 221 | ) { 222 | if (isUnauthorizedError(error)) { 223 | logout() 224 | } /*else if (typeof error === 'object' && error.status === 403) { 225 | logout({ fromPermission: true }) 226 | }*/ 227 | } 228 | } 229 | 230 | function onObsevableError( 231 | error: any, 232 | apiFn: CurryAuthApiFn, 233 | firstAccessToken: A, 234 | args: any[] 235 | ): Observable { 236 | if (firstAccessToken !== null) { 237 | if (typeof refreshTokenCall !== 'function') { 238 | // Refresh can't be called 239 | // notify logout when needed give back error 240 | unauthLogout(firstAccessToken, error) 241 | return throwError(error) 242 | } 243 | if (isUnauthorizedError(error)) { 244 | // Try refresh 245 | return refreshOnUnauth(firstAccessToken).pipe( 246 | mergeMap((accessToken) => { 247 | return from(apiFn(accessToken)(...args)).pipe( 248 | catchError((error) => { 249 | unauthLogout(accessToken, error) 250 | return throwError(error) 251 | }) 252 | ) 253 | }) 254 | ) 255 | } 256 | } 257 | return throwError(error) 258 | } 259 | 260 | function callAuthApiObservable( 261 | apiFn: CurryAuthApiFn, 262 | ...args: any[] 263 | ): Observable { 264 | return getAccessToken().pipe( 265 | mergeMap((firstAccessToken) => 266 | from(apiFn(firstAccessToken)(...args)).pipe( 267 | catchError((error) => 268 | onObsevableError(error, apiFn, firstAccessToken, args) 269 | ) 270 | ) 271 | ) 272 | ) 273 | } 274 | 275 | function callAuthApiPromise( 276 | apiFn: CurryAuthApiFnPromise, 277 | ...args: any[] 278 | ): Promise { 279 | return getAccessToken() 280 | .toPromise() 281 | .then((firstAccessToken) => { 282 | return apiFn(firstAccessToken)(...args).catch((error) => { 283 | if (firstAccessToken !== null) { 284 | if (typeof refreshTokenCall !== 'function') { 285 | // Refresh can't be called 286 | unauthLogout(firstAccessToken, error) 287 | return Promise.reject(error) 288 | } 289 | if (isUnauthorizedError(error)) { 290 | // Try refresh 291 | return refreshOnUnauth(firstAccessToken) 292 | .toPromise() 293 | .then((accessToken) => { 294 | return apiFn(accessToken)(...args).catch((error) => { 295 | unauthLogout(accessToken, error) 296 | return Promise.reject(error) 297 | }) 298 | }) 299 | } 300 | } 301 | // Unauthorized 302 | return Promise.reject(error) 303 | }) 304 | }) 305 | } 306 | 307 | // GioVa 1312 illegal boy 308 | // NOTE: Yes i know that there is a more elegant or maybe performant 309 | // way to do this ... but this works lol 310 | const waitFirstBootObservable = bootRef.current 311 | ? of(true) // No need to wait 312 | : actionObservable.pipe( 313 | filter((action) => action.type === BOOTSTRAP_AUTH_END), 314 | take(1), 315 | map(() => true) 316 | ) 317 | 318 | const firstBootSub = waitFirstBootObservable.subscribe(() => { 319 | refreshRoutine.connect() 320 | }) 321 | 322 | const logoutSub = actionObservable 323 | .pipe(filter((action) => action.type === LOGOUT)) 324 | .subscribe(() => { 325 | refreshingSemaphore = false 326 | }) 327 | 328 | const refreshSub = refreshRoutine.subscribe((action) => { 329 | if (action.type === TOKEN_REFRESHING) { 330 | refreshingSemaphore = true 331 | } else if (action.type === TOKEN_REFRESHED) { 332 | const { payload } = action 333 | refreshingSemaphore = false 334 | tokenRef.current = payload 335 | dispatch(action) 336 | storage.setTokens(payload) 337 | } else if (action.type === LOGOUT) { 338 | refreshingSemaphore = false 339 | logout() 340 | } 341 | }) 342 | 343 | function unsubscribe() { 344 | firstBootSub.unsubscribe() 345 | logoutSub.unsubscribe() 346 | refreshSub.unsubscribe() 347 | } 348 | 349 | return { callAuthApiObservable, callAuthApiPromise, unsubscribe } 350 | } 351 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useContext, 3 | useState, 4 | useCallback, 5 | useMemo, 6 | ChangeEvent, 7 | FormEvent, 8 | } from 'react' 9 | import { Observable } from 'rxjs' 10 | import { 11 | AuthUserContext, 12 | AuthStateContext, 13 | AuthActionsContext, 14 | AuthActionCreators, 15 | AuthUser, 16 | } from './Auth' 17 | import { CurryAuthApiFn, CurryAuthApiFnPromise } from './types' 18 | 19 | export function useAuthState() { 20 | const authState = useContext(AuthStateContext) 21 | return authState 22 | } 23 | 24 | export function useAuthActions() { 25 | const actions = useContext>(AuthActionsContext) 26 | return actions 27 | } 28 | 29 | export function useAuthCallObservable( 30 | fn: CurryAuthApiFn 31 | ): (...args: FA) => Observable { 32 | const { callAuthApiObservable } = useContext< 33 | AuthActionCreators 34 | >(AuthActionsContext) 35 | return useCallback( 36 | (...args: FA) => callAuthApiObservable(fn, ...args), 37 | [callAuthApiObservable, fn] 38 | ) 39 | } 40 | 41 | export function useAuthCallPromise( 42 | fn: CurryAuthApiFnPromise 43 | ): (...args: FA) => Promise { 44 | const { callAuthApiPromise } = useContext< 45 | AuthActionCreators 46 | >(AuthActionsContext) 47 | return useCallback( 48 | (...args: any[]) => callAuthApiPromise(fn, ...args), 49 | [callAuthApiPromise, fn] 50 | ) 51 | } 52 | 53 | export function useAuthUser() { 54 | const user = useContext>(AuthUserContext) 55 | return user 56 | } 57 | 58 | interface ValueProp { 59 | value: string 60 | } 61 | 62 | interface OnChangeProp { 63 | onChange(e: ChangeEvent): void 64 | } 65 | 66 | export interface ShapeLoginResult { 67 | handleSubmit(e: FormEvent): void 68 | login(): void 69 | loginError: any 70 | loginLoading: boolean 71 | } 72 | 73 | export type LoginResult = ShapeLoginResult & 74 | Record 75 | 76 | // TODO: On the very end this hook sucks and realted types sucks 77 | // in future we must rewrite it or find a more suitable solution 78 | // here for compatibility reasons 79 | export function useLogin( 80 | credentialsConf: string[] = ['username', 'password'] 81 | ): LoginResult { 82 | const [credentials, setCredentials] = useState>({}) 83 | 84 | const { loginError, loginLoading } = useAuthState() 85 | 86 | const { login } = useAuthActions() 87 | 88 | const loginWithCredentials = useCallback(() => { 89 | login(credentials) 90 | }, [login, credentials]) 91 | 92 | const handleSubmit = useCallback( 93 | (e: FormEvent) => { 94 | e.preventDefault() 95 | loginWithCredentials() 96 | }, 97 | [loginWithCredentials] 98 | ) 99 | 100 | const valuesProps: Record = useMemo(() => { 101 | return credentialsConf.reduce( 102 | (out, name) => ({ 103 | ...out, 104 | [name]: { 105 | value: credentials[name] === undefined ? '' : credentials[name], 106 | }, 107 | }), 108 | {} 109 | ) 110 | }, [credentials, credentialsConf]) 111 | 112 | const onChangesProps: Record = useMemo(() => { 113 | return credentialsConf.reduce( 114 | (out, name) => ({ 115 | ...out, 116 | [name]: { 117 | onChange: (e: ChangeEvent) => { 118 | const value = e.target.value 119 | setCredentials((credentials) => ({ 120 | ...credentials, 121 | [name]: value, 122 | })) 123 | }, 124 | }, 125 | }), 126 | {} 127 | ) 128 | }, [setCredentials, credentialsConf]) 129 | 130 | const credentialsProps: Record< 131 | string, 132 | ValueProp & OnChangeProp 133 | > = useMemo(() => { 134 | return Object.keys(valuesProps).reduce( 135 | (r, name) => ({ 136 | ...r, 137 | [name]: { 138 | ...valuesProps[name], 139 | ...onChangesProps[name], 140 | }, 141 | }), 142 | {} 143 | ) 144 | }, [valuesProps, onChangesProps]) 145 | 146 | return { 147 | handleSubmit, 148 | login: loginWithCredentials, 149 | loginError, 150 | loginLoading, 151 | ...credentialsProps, 152 | } as LoginResult 153 | } 154 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useAuthState, 3 | useAuthActions, 4 | useAuthUser, 5 | useLogin, 6 | useAuthCallPromise, 7 | useAuthCallObservable, 8 | } from './hooks' 9 | export { default } from './Auth' 10 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | // Form login auth actions 3 | LOGIN_LOADING, 4 | LOGIN_FAILURE, 5 | LOGIN_SUCCESS, 6 | 7 | // Clear the login error 8 | CLEAR_LOGIN_ERROR, 9 | 10 | // Auth initialization 11 | BOOTSTRAP_AUTH_START, 12 | BOOTSTRAP_AUTH_END, 13 | 14 | // Token refreshed 15 | TOKEN_REFRESHED, 16 | 17 | // Explicit set tokens 18 | SET_TOKENS, 19 | 20 | // Update user data 21 | UPDATE_USER, 22 | 23 | // Patch user data 24 | PATCH_USER, 25 | 26 | // Logout action 27 | LOGOUT, 28 | AuthActions, 29 | FunctionalUpdaterUser, 30 | } from './actionTypes' 31 | import { InitialAuthData } from './types' 32 | 33 | export interface AuthStateShape { 34 | // Is auth initialized? 35 | bootstrappingAuth: boolean 36 | bootstrappedAuth: boolean 37 | // Current logged user 38 | user: U | null 39 | // Tokens 40 | accessToken: A | null 41 | refreshToken: R | null 42 | expires?: number | null 43 | // Login state 44 | loginLoading: boolean 45 | loginError: any 46 | } 47 | 48 | const initialState: AuthStateShape = { 49 | // Is auth initialized? 50 | bootstrappingAuth: false, 51 | bootstrappedAuth: false, 52 | // Current logged user 53 | user: null, 54 | // Tokens 55 | accessToken: null, 56 | refreshToken: null, 57 | expires: null, 58 | // Login state 59 | loginLoading: false, 60 | loginError: null, 61 | } 62 | 63 | export function initAuthState( 64 | initialData: InitialAuthData | undefined 65 | ): AuthStateShape { 66 | if (initialData) { 67 | // Only fill user and access token together 68 | if (initialData.user && initialData.accessToken) { 69 | return { 70 | ...initialState, 71 | bootstrappedAuth: true, 72 | user: initialData.user, 73 | accessToken: initialData.accessToken, 74 | refreshToken: initialData.refreshToken ?? null, 75 | expires: initialData.expires ?? null, 76 | } 77 | } else { 78 | return { 79 | ...initialState, 80 | bootstrappedAuth: true, 81 | } 82 | } 83 | } 84 | return initialState 85 | } 86 | 87 | export default function authReducer( 88 | previousState: AuthStateShape = initialState, 89 | action: AuthActions 90 | ): AuthStateShape { 91 | switch (action.type) { 92 | case LOGIN_LOADING: 93 | return { 94 | ...previousState, 95 | loginLoading: true, 96 | loginError: null, 97 | } 98 | case LOGIN_FAILURE: 99 | return { 100 | ...previousState, 101 | loginLoading: false, 102 | loginError: action.error, 103 | } 104 | case CLEAR_LOGIN_ERROR: { 105 | if (previousState.loginError === null) { 106 | return previousState 107 | } 108 | return { 109 | ...previousState, 110 | loginError: null, 111 | } 112 | } 113 | case LOGIN_SUCCESS: 114 | return { 115 | ...previousState, 116 | loginLoading: false, 117 | user: action.payload.user, 118 | accessToken: action.payload.accessToken, 119 | refreshToken: action.payload.refreshToken ?? null, 120 | expires: action.payload.expires, 121 | // logoutFromPermission: false, 122 | } 123 | case BOOTSTRAP_AUTH_START: 124 | return { 125 | ...previousState, 126 | bootstrappingAuth: true, 127 | } 128 | case BOOTSTRAP_AUTH_END: { 129 | let nextState = { 130 | ...previousState, 131 | bootstrappedAuth: true, 132 | bootstrappingAuth: false, 133 | } 134 | if (action.payload.authenticated) { 135 | const { 136 | user, 137 | accessToken, 138 | refreshToken = null, 139 | expires = null, 140 | } = action.payload 141 | return { 142 | ...nextState, 143 | user, 144 | accessToken, 145 | refreshToken, 146 | expires, 147 | } 148 | } 149 | return nextState 150 | } 151 | case SET_TOKENS: 152 | return { 153 | ...previousState, 154 | expires: action.payload.expires, 155 | accessToken: action.payload.accessToken, 156 | refreshToken: action.payload.refreshToken, 157 | } 158 | case TOKEN_REFRESHED: 159 | return { 160 | ...previousState, 161 | expires: action.payload.expires, 162 | accessToken: action.payload.accessToken, 163 | refreshToken: action.payload.refreshToken, 164 | } 165 | case UPDATE_USER: { 166 | const userOrUpdater = action.payload 167 | return { 168 | ...previousState, 169 | // NOTE: Improve types when better solution 2 170 | // https://github.com/microsoft/TypeScript/issues/37663 171 | user: 172 | typeof userOrUpdater === 'function' 173 | ? (userOrUpdater as FunctionalUpdaterUser)(previousState.user) 174 | : userOrUpdater, 175 | } 176 | } 177 | case PATCH_USER: 178 | return { 179 | ...previousState, 180 | user: { 181 | ...previousState.user, 182 | ...action.payload, 183 | } as U, 184 | } 185 | case LOGOUT: 186 | return { 187 | ...initialState, 188 | // Logout doesn't mean reinitialization 189 | bootstrappedAuth: previousState.bootstrappedAuth, 190 | } 191 | default: 192 | return previousState 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/routes/AuthRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ComponentType, 3 | createElement, 4 | ReactNode, 5 | useContext, 6 | useMemo, 7 | } from 'react' 8 | import { Redirect, Route, RouteProps } from 'react-router-dom' 9 | import { Location } from 'history' 10 | import { useAuthState, useAuthUser } from '../hooks' 11 | import AuthRoutesContext from './AuthRoutesContext' 12 | import { Dictionary } from './types' 13 | 14 | type RedirectAuthRouteProps = { 15 | redirectTo?: string | Location 16 | spinner?: ReactNode 17 | spinnerComponent?: ComponentType 18 | rememberReferrer?: boolean 19 | userRedirectTo: string | null | Location 20 | authenticated: boolean 21 | bootstrappedAuth: boolean 22 | loginLoading: boolean 23 | } & RouteProps 24 | 25 | const RedirectAuthRoute = React.memo( 26 | ({ 27 | children, 28 | component, 29 | render, 30 | spinner, 31 | spinnerComponent, 32 | redirectTo = '/login', 33 | rememberReferrer = true, 34 | userRedirectTo, 35 | authenticated, 36 | bootstrappedAuth, 37 | loginLoading, 38 | ...rest 39 | }: RedirectAuthRouteProps) => ( 40 | { 43 | if (!bootstrappedAuth || loginLoading) { 44 | // Spinner or Spinner Component 45 | return spinnerComponent 46 | ? createElement(spinnerComponent) 47 | : spinner ?? null 48 | } 49 | // User authenticated 50 | if (authenticated) { 51 | // Redirect a logged user? 52 | if (userRedirectTo) { 53 | return 54 | } 55 | // Render as a Route 56 | return children 57 | ? children 58 | : component 59 | ? createElement(component, props) 60 | : render 61 | ? render(props) 62 | : null 63 | } 64 | // User not authenticated, redirect to login 65 | const to: Location = (typeof redirectTo === 'string' 66 | ? { 67 | pathname: redirectTo, 68 | } 69 | : redirectTo) as Location 70 | return ( 71 | 85 | ) 86 | }} 87 | /> 88 | ) 89 | ) 90 | 91 | export type AuthRouteProps = { 92 | redirectTest?: null | ((user: U) => string | null | undefined | Location) 93 | redirectTo?: string | Location 94 | spinner?: ReactNode 95 | spinnerComponent?: ComponentType 96 | rememberReferrer?: boolean 97 | } & RouteProps 98 | 99 | /** 100 | * Wrapper around ``, render given spinner or null while auth is loading 101 | * then ensure authenticated user otherwise render a ``. 102 | * 103 | */ 104 | export default function AuthRoute({ 105 | redirectTest: localRedirectTest, 106 | redirectTo: localRedirectTo, 107 | spinner: localSpinner, 108 | spinnerComponent: localSpinnerComponent, 109 | rememberReferrer: localRememberReferrer, 110 | ...rest 111 | }: AuthRouteProps) { 112 | const routesCtxConfig = useContext(AuthRoutesContext) 113 | const spinner = 114 | localSpinner === undefined ? routesCtxConfig.spinner : localSpinner 115 | const spinnerComponent = 116 | localSpinnerComponent ?? routesCtxConfig.spinnerComponent 117 | const redirectTo = localRedirectTo ?? routesCtxConfig.authRedirectTo 118 | const rememberReferrer = 119 | localRememberReferrer ?? routesCtxConfig.rememberReferrer 120 | const redirectTest = 121 | localRedirectTest === undefined 122 | ? routesCtxConfig.authRedirectTest 123 | : localRedirectTest 124 | 125 | const { authenticated, bootstrappedAuth, loginLoading } = useAuthState() 126 | const { user } = useAuthUser() 127 | 128 | const userRedirectTo = useMemo(() => { 129 | if (user && typeof redirectTest === 'function') { 130 | const userRedirectTo = redirectTest(user) 131 | if (userRedirectTo) { 132 | return userRedirectTo 133 | } 134 | } 135 | return null 136 | }, [user, redirectTest]) 137 | 138 | // NOTE: split in two components is only an optimization 139 | // to avoid re-execute Route render when user changes but 140 | // the output of redirect test doesnt't change 141 | return ( 142 | 153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /src/routes/AuthRoutesContext.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, createContext, ReactNode } from 'react' 2 | import { Location } from 'history' 3 | import { Dictionary } from './types' 4 | 5 | export interface AuthRoutesConfig { 6 | guestRedirectTo?: string | Location 7 | authRedirectTo?: string | Location 8 | authRedirectTest?: (user: U) => string | null | undefined | Location 9 | spinner?: ReactNode 10 | spinnerComponent?: ComponentType 11 | rememberReferrer?: boolean 12 | redirectToReferrer?: boolean 13 | } 14 | 15 | const AuthRoutesContext = createContext({}) 16 | 17 | export default AuthRoutesContext 18 | -------------------------------------------------------------------------------- /src/routes/AuthRoutesProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import AuthRoutesContext, { AuthRoutesConfig } from './AuthRoutesContext' 3 | import useShallowMemo from './useShallowMemo' 4 | 5 | export type AuthRoutesConfigProps = { 6 | children: ReactNode 7 | } & AuthRoutesConfig 8 | 9 | export default function AuthRoutesProvider({ 10 | children, 11 | ...props 12 | }: AuthRoutesConfigProps) { 13 | const ctxValue = useShallowMemo(props) 14 | 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/GuestRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ComponentType, 3 | createElement, 4 | ReactNode, 5 | useContext, 6 | } from 'react' 7 | import { Redirect, Route, RouteProps } from 'react-router-dom' 8 | import { Location } from 'history' 9 | import { useAuthState } from '../hooks' 10 | import AuthRoutesContext from './AuthRoutesContext' 11 | import { Dictionary } from './types' 12 | 13 | export type GuestRouteProps = { 14 | redirectTo?: string | Location 15 | redirectToReferrer?: boolean 16 | spinner?: ReactNode 17 | spinnerComponent?: ComponentType 18 | } & RouteProps 19 | 20 | /** 21 | * Wrapper around ``, render given spinner or null while auth is loading 22 | * then ensure guest (not authenticated) user otherwise render a ``. 23 | * 24 | */ 25 | export default function GuestRoute({ 26 | children, 27 | component, 28 | render, 29 | spinner: localSpinner, 30 | spinnerComponent: localSpinnerComponent, 31 | redirectTo: localRedirectTo, 32 | redirectToReferrer: localRedirectToReferrer, 33 | ...rest 34 | }: GuestRouteProps) { 35 | const routesCtxConfig = useContext(AuthRoutesContext) 36 | const spinner = 37 | localSpinner === undefined ? routesCtxConfig.spinner : localSpinner 38 | const spinnerComponent = 39 | localSpinnerComponent ?? routesCtxConfig.spinnerComponent 40 | const redirectTo = localRedirectTo ?? routesCtxConfig.guestRedirectTo ?? '/' 41 | const redirectToReferrer = 42 | localRedirectToReferrer ?? routesCtxConfig.redirectToReferrer ?? true 43 | 44 | const { authenticated, bootstrappedAuth } = useAuthState() 45 | return ( 46 | { 49 | if (authenticated) { 50 | // Redirect to referrer location 51 | const { location } = props as { location: Location } 52 | if (redirectToReferrer && location.state && location.state.referrer) { 53 | return ( 54 | 69 | ) 70 | } 71 | 72 | return 73 | } 74 | 75 | if (!bootstrappedAuth) { 76 | // Spinner as element as component or null 77 | return spinnerComponent 78 | ? createElement(spinnerComponent) 79 | : spinner ?? null 80 | } 81 | // Render as a Route 82 | return children 83 | ? children 84 | : component 85 | ? createElement(component, props) 86 | : render 87 | ? render(props) 88 | : null 89 | }} 90 | /> 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/routes/MaybeAuthRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, createElement, ReactNode, useContext } from 'react' 2 | import { Route, RouteProps } from 'react-router-dom' 3 | import { useAuthState } from '../hooks' 4 | import AuthRoutesContext from './AuthRoutesContext' 5 | 6 | export type MaybeAuthRouteProps = { 7 | spinner?: ReactNode 8 | spinnerComponent?: ComponentType 9 | } & RouteProps 10 | 11 | /** 12 | * Wrapper around ``, render given spinner or null while auth is loading 13 | * then render given content for both guest and authenticated user. 14 | * 15 | */ 16 | export default function MaybeAuthRoute({ 17 | children, 18 | component, 19 | render, 20 | spinner: localSpinner, 21 | spinnerComponent: localSpinnerComponent, 22 | ...rest 23 | }: MaybeAuthRouteProps) { 24 | const routesCtxConfig = useContext(AuthRoutesContext) 25 | const spinner = 26 | localSpinner === undefined ? routesCtxConfig.spinner : localSpinner 27 | const spinnerComponent = 28 | localSpinnerComponent ?? routesCtxConfig.spinnerComponent 29 | 30 | const { bootstrappedAuth, loginLoading } = useAuthState() 31 | 32 | return ( 33 | { 36 | if (!bootstrappedAuth || loginLoading) { 37 | // Spinner as element as component or null 38 | return spinnerComponent 39 | ? createElement(spinnerComponent) 40 | : spinner ?? null 41 | } 42 | // Render as a Route 43 | return children 44 | ? children 45 | : component 46 | ? createElement(component, props) 47 | : render 48 | ? render(props) 49 | : null 50 | }} 51 | /> 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/routes/__tests__/Routes.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, fireEvent, render } from '@testing-library/react' 3 | import { Link, MemoryRouter, Route, Switch, useHistory } from 'react-router-dom' 4 | import { AuthTokens } from 'src/types' 5 | import Auth, { useAuthActions } from '../../index' 6 | import { AuthRoute, GuestRoute, MaybeAuthRoute } from '../index' 7 | import AuthRoutesProvider from '../AuthRoutesProvider' 8 | 9 | interface TestCallBack { 10 | (value: V): void 11 | } 12 | 13 | interface DummyUser { 14 | username: string 15 | } 16 | 17 | interface DummyLoginCredentials { 18 | username: string 19 | password: string 20 | } 21 | 22 | function Home() { 23 | return
Home
24 | } 25 | 26 | function SpinnyBoy() { 27 | return
Spinny
28 | } 29 | 30 | function Anon() { 31 | return
Anon
32 | } 33 | 34 | describe('AuthRoutes', () => { 35 | describe('', () => { 36 | it('should render content when user is authenticated', async () => { 37 | const loginCall = jest.fn() 38 | 39 | let resolveMe: TestCallBack 40 | const meCall = jest.fn( 41 | () => 42 | new Promise((resolve) => { 43 | resolveMe = resolve 44 | }) 45 | ) 46 | 47 | // Fake stroage 48 | const resolvesGetItem: TestCallBack[] = [] 49 | const localStorageMock = { 50 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 51 | setItem: jest.fn(), 52 | removeItem: jest.fn(), 53 | } 54 | Object.defineProperty(global, '_localStorage', { 55 | value: localStorageMock, 56 | writable: true, 57 | }) 58 | 59 | const App = () => ( 60 | 61 | 62 | 63 | } path={'/home'}> 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | 71 | const { getByTestId, queryAllByTestId } = render() 72 | 73 | // Spinner on the page 74 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 75 | 76 | // Local storage 77 | await act(async () => { 78 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 79 | }) 80 | 81 | // Me Call 82 | await act(async () => { 83 | resolveMe({ username: 'GenGar' }) 84 | }) 85 | 86 | // No Spinnry 87 | expect(queryAllByTestId('spinner')).toEqual([]) 88 | // Ma Home 89 | expect(getByTestId('home').textContent).toEqual('Home') 90 | }) 91 | 92 | it('should redirect default to "/login" when user is not authenticated', async () => { 93 | const loginCall = jest.fn() 94 | 95 | const meCall = jest.fn() 96 | 97 | // Fake stroage 98 | const resolvesGetItem: TestCallBack[] = [] 99 | const localStorageMock = { 100 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 101 | setItem: jest.fn(), 102 | removeItem: jest.fn(), 103 | } 104 | Object.defineProperty(global, '_localStorage', { 105 | value: localStorageMock, 106 | writable: true, 107 | }) 108 | 109 | function Login() { 110 | return
Login
111 | } 112 | 113 | const App = () => ( 114 | 115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
128 |
129 | ) 130 | 131 | const { getByTestId } = render() 132 | 133 | // Spinner on the page 134 | expect(getByTestId('main').textContent).toEqual('Spinny') 135 | 136 | // Local storage 137 | await act(async () => { 138 | resolvesGetItem[0](null) 139 | }) 140 | 141 | // Anon page 142 | expect(getByTestId('login').textContent).toEqual('Login') 143 | }) 144 | 145 | it('should redirect when user is not authenticated', async () => { 146 | const loginCall = jest.fn() 147 | 148 | const meCall = jest.fn() 149 | 150 | // Fake stroage 151 | const resolvesGetItem: TestCallBack[] = [] 152 | const localStorageMock = { 153 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 154 | setItem: jest.fn(), 155 | removeItem: jest.fn(), 156 | } 157 | Object.defineProperty(global, '_localStorage', { 158 | value: localStorageMock, 159 | writable: true, 160 | }) 161 | 162 | const App = () => ( 163 | 164 |
165 | 166 | 167 | 168 | 169 | 170 | 176 | 177 | 178 |
179 |
180 | ) 181 | 182 | const { getByTestId } = render() 183 | 184 | // Spinner on the page 185 | expect(getByTestId('main').textContent).toEqual('Spinny') 186 | 187 | // Local storage 188 | await act(async () => { 189 | resolvesGetItem[0](null) 190 | }) 191 | 192 | // Anon page 193 | expect(getByTestId('anon').textContent).toEqual('Anon') 194 | }) 195 | 196 | it('should redirect based un test function', async () => { 197 | const loginCall = jest.fn() 198 | 199 | let resolveMe: TestCallBack 200 | const meCall = jest.fn( 201 | () => 202 | new Promise((resolve) => { 203 | resolveMe = resolve 204 | }) 205 | ) 206 | 207 | // Fake stroage 208 | const resolvesGetItem: TestCallBack[] = [] 209 | const localStorageMock = { 210 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 211 | setItem: jest.fn(), 212 | removeItem: jest.fn(), 213 | } 214 | Object.defineProperty(global, '_localStorage', { 215 | value: localStorageMock, 216 | writable: true, 217 | }) 218 | 219 | function Gengar() { 220 | const history = useHistory() 221 | const { updateUser } = useAuthActions() 222 | return ( 223 |
224 | 233 |
234 | ) 235 | } 236 | 237 | const App = () => ( 238 | 239 | 240 | 241 | 242 | 243 | 244 | 246 | user.username === 'GenGar' ? '/gengar' : null 247 | } 248 | spinnerComponent={SpinnyBoy} 249 | path={'/home'} 250 | > 251 | 252 | 253 | 254 | 255 | 256 | ) 257 | 258 | const { getByTestId } = render() 259 | 260 | // Spinner on the page 261 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 262 | 263 | // Local storage 264 | await act(async () => { 265 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 266 | }) 267 | 268 | // Me Call 269 | await act(async () => { 270 | resolveMe({ username: 'GenGar' }) 271 | }) 272 | 273 | expect(getByTestId('gengar-btn').textContent).toEqual('K') 274 | 275 | fireEvent.click(getByTestId('gengar-btn')) 276 | 277 | expect(getByTestId('home').textContent).toEqual('Home') 278 | }) 279 | }) 280 | 281 | describe('', () => { 282 | it('should render content when user is unauthenticated', async () => { 283 | const loginCall = jest.fn() 284 | 285 | const meCall = jest.fn().mockRejectedValue('Bu') 286 | 287 | // Fake stroage 288 | const resolvesGetItem: TestCallBack[] = [] 289 | const localStorageMock = { 290 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 291 | setItem: jest.fn(), 292 | removeItem: jest.fn(), 293 | } 294 | Object.defineProperty(global, '_localStorage', { 295 | value: localStorageMock, 296 | writable: true, 297 | }) 298 | 299 | function Guest() { 300 | return
Guest
301 | } 302 | 303 | const App = () => ( 304 | 305 | 306 | 307 | } path="/guest"> 308 | 309 | 310 | 311 | 312 | 313 | ) 314 | 315 | const { getByTestId, queryAllByTestId } = render() 316 | 317 | // Spinner on the page 318 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 319 | 320 | // Local storage 321 | await act(async () => { 322 | resolvesGetItem[0](null) 323 | }) 324 | 325 | // No Spinny 326 | expect(queryAllByTestId('spinner')).toEqual([]) 327 | // Show Guest 328 | expect(getByTestId('guest').textContent).toEqual('Guest') 329 | }) 330 | 331 | it('should redirect default to "/" when user is authenticated', async () => { 332 | const loginCall = jest.fn() 333 | 334 | const meCall = jest.fn().mockResolvedValue({ 335 | username: 'Gio Va', 336 | }) 337 | 338 | // Fake stroage 339 | const resolvesGetItem: TestCallBack[] = [] 340 | const localStorageMock = { 341 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 342 | setItem: jest.fn(), 343 | removeItem: jest.fn(), 344 | } 345 | Object.defineProperty(global, '_localStorage', { 346 | value: localStorageMock, 347 | writable: true, 348 | }) 349 | 350 | function Guest() { 351 | return
Guest
352 | } 353 | 354 | function Other() { 355 | return
Other
356 | } 357 | 358 | function MaHome() { 359 | return
MaHome
360 | } 361 | 362 | const App = () => ( 363 | 364 | 365 | 366 | } path="/guest"> 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | ) 379 | 380 | const { getByTestId, queryAllByTestId } = render() 381 | 382 | // Spinner on the page 383 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 384 | 385 | // Local storage 386 | await act(async () => { 387 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 388 | }) 389 | 390 | // No Spinny 391 | expect(queryAllByTestId('spinner')).toEqual([]) 392 | // No Guest 393 | expect(queryAllByTestId('guest')).toEqual([]) 394 | // No Other 395 | expect(queryAllByTestId('other')).toEqual([]) 396 | // Show Home 397 | expect(getByTestId('home').textContent).toEqual('MaHome') 398 | }) 399 | 400 | it('should redirect to given location when user is authenticated', async () => { 401 | const loginCall = jest.fn() 402 | 403 | const meCall = jest.fn().mockResolvedValue({ 404 | username: 'Gio Va', 405 | }) 406 | 407 | // Fake stroage 408 | const resolvesGetItem: TestCallBack[] = [] 409 | const localStorageMock = { 410 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 411 | setItem: jest.fn(), 412 | removeItem: jest.fn(), 413 | } 414 | Object.defineProperty(global, '_localStorage', { 415 | value: localStorageMock, 416 | writable: true, 417 | }) 418 | 419 | function Guest() { 420 | return
Guest
421 | } 422 | 423 | function Other() { 424 | return
Other
425 | } 426 | 427 | const App = () => ( 428 | 429 | 430 | 431 | } 434 | path="/guest" 435 | > 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | ) 445 | 446 | const { getByTestId, queryAllByTestId } = render() 447 | 448 | // Spinner on the page 449 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 450 | 451 | // Local storage 452 | await act(async () => { 453 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 454 | }) 455 | 456 | // No Spinny 457 | expect(queryAllByTestId('spinner')).toEqual([]) 458 | // No Guest 459 | expect(queryAllByTestId('guest')).toEqual([]) 460 | // Show Other 461 | expect(getByTestId('other').textContent).toEqual('Other') 462 | }) 463 | 464 | it('should redirect to referrer', async () => { 465 | const loginCall = jest 466 | .fn>, [DummyLoginCredentials]>() 467 | .mockResolvedValue({ 468 | accessToken: 99, 469 | }) 470 | 471 | const meCall = jest.fn, [number]>().mockResolvedValue({ 472 | username: 'Gio Va', 473 | }) 474 | 475 | // Fake stroage 476 | const resolvesGetItem: TestCallBack[] = [] 477 | const localStorageMock = { 478 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 479 | setItem: jest.fn(), 480 | removeItem: jest.fn(), 481 | } 482 | Object.defineProperty(global, '_localStorage', { 483 | value: localStorageMock, 484 | writable: true, 485 | }) 486 | 487 | function Guest() { 488 | const { login } = useAuthActions< 489 | number, 490 | never, 491 | DummyUser, 492 | DummyLoginCredentials 493 | >() 494 | 495 | return ( 496 |
497 |
Guest
498 | 510 |
511 | ) 512 | } 513 | 514 | const App = () => ( 515 | 516 | 517 | 518 | } 521 | path="/guest" 522 | > 523 | 524 | 525 | } 527 | redirectTo="/guest" 528 | path="/my-home-is-cool" 529 | > 530 | 531 | 532 | 533 | 534 | 535 | ) 536 | 537 | const { getByTestId, queryAllByTestId } = render() 538 | 539 | // Spinner on the page 540 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 541 | 542 | // Local storage 543 | await act(async () => { 544 | resolvesGetItem[0](null) 545 | }) 546 | 547 | // No Spinny 548 | expect(queryAllByTestId('spinner')).toEqual([]) 549 | 550 | // Log Me In! 551 | await act(async () => { 552 | fireEvent.click(getByTestId('login-btn')) 553 | }) 554 | expect(getByTestId('home').textContent).toEqual('Home') 555 | }) 556 | 557 | it('should redirect to referrer unless is set to false', async () => { 558 | const loginCall = jest 559 | .fn>, [DummyLoginCredentials]>() 560 | .mockResolvedValue({ 561 | accessToken: 99, 562 | }) 563 | 564 | const meCall = jest.fn, [number]>().mockResolvedValue({ 565 | username: 'Gio Va', 566 | }) 567 | 568 | // Fake stroage 569 | const resolvesGetItem: TestCallBack[] = [] 570 | const localStorageMock = { 571 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 572 | setItem: jest.fn(), 573 | removeItem: jest.fn(), 574 | } 575 | Object.defineProperty(global, '_localStorage', { 576 | value: localStorageMock, 577 | writable: true, 578 | }) 579 | 580 | function Guest() { 581 | const { login } = useAuthActions< 582 | number, 583 | never, 584 | DummyUser, 585 | DummyLoginCredentials 586 | >() 587 | 588 | return ( 589 |
590 |
Guest
591 | 603 |
604 | ) 605 | } 606 | 607 | function Other() { 608 | return
Other
609 | } 610 | 611 | const App = () => ( 612 | 613 | 614 | 615 | } 619 | path="/guest" 620 | component={Guest} 621 | /> 622 | 623 | 624 | 625 | } 627 | redirectTo="/guest" 628 | path="/my-home-is-cool" 629 | > 630 | 631 | 632 | 633 | 634 | 635 | ) 636 | 637 | const { getByTestId, queryAllByTestId } = render() 638 | 639 | // Spinner on the page 640 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 641 | 642 | // Local storage 643 | await act(async () => { 644 | resolvesGetItem[0](null) 645 | }) 646 | 647 | // No Spinny 648 | expect(queryAllByTestId('spinner')).toEqual([]) 649 | 650 | await act(async () => { 651 | fireEvent.click(getByTestId('login-btn')) 652 | }) 653 | 654 | // Show other 655 | expect(getByTestId('other').textContent).toEqual('Other') 656 | }) 657 | }) 658 | 659 | describe('', () => { 660 | it('should render content when user is unauthenticated ... but wait for boot', async () => { 661 | const loginCall = jest.fn() 662 | 663 | const meCall = jest.fn().mockRejectedValue('Bu') 664 | 665 | // Fake stroage 666 | const resolvesGetItem: TestCallBack[] = [] 667 | const localStorageMock = { 668 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 669 | setItem: jest.fn(), 670 | removeItem: jest.fn(), 671 | } 672 | Object.defineProperty(global, '_localStorage', { 673 | value: localStorageMock, 674 | writable: true, 675 | }) 676 | 677 | function Maybe() { 678 | return
Maybe
679 | } 680 | 681 | const App = () => ( 682 | 683 | 684 | 685 | } path="/maybe"> 686 | 687 | 688 | 689 | 690 | 691 | ) 692 | 693 | const { getByTestId, queryAllByTestId } = render() 694 | 695 | // Spinner on the page 696 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 697 | 698 | // Local storage 699 | await act(async () => { 700 | resolvesGetItem[0](null) 701 | }) 702 | 703 | // No Spinny 704 | expect(queryAllByTestId('spinner')).toEqual([]) 705 | // Show Guest 706 | expect(getByTestId('maybe').textContent).toEqual('Maybe') 707 | }) 708 | it('should render content when user is authenticated ... but wait for boot', async () => { 709 | const loginCall = jest.fn() 710 | 711 | const meCall = jest.fn().mockResolvedValue({ 712 | username: 'Gio Va', 713 | }) 714 | 715 | // Fake stroage 716 | const resolvesGetItem: TestCallBack[] = [] 717 | const localStorageMock = { 718 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 719 | setItem: jest.fn(), 720 | removeItem: jest.fn(), 721 | } 722 | Object.defineProperty(global, '_localStorage', { 723 | value: localStorageMock, 724 | writable: true, 725 | }) 726 | 727 | function Maybe() { 728 | return
Maybe
729 | } 730 | 731 | const App = () => ( 732 | 733 | 734 | 735 | 740 | 741 | 742 | 743 | ) 744 | 745 | const { getByTestId, queryAllByTestId } = render() 746 | 747 | // Spinner on the page 748 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 749 | 750 | // Local storage 751 | await act(async () => { 752 | resolvesGetItem[0]({ accessToken: 23 }) 753 | }) 754 | 755 | // No Spinny 756 | expect(queryAllByTestId('spinner')).toEqual([]) 757 | // Show Guest 758 | expect(getByTestId('maybe').textContent).toEqual('Maybe') 759 | }) 760 | }) 761 | 762 | describe('', () => { 763 | it('should configure global spinners', async () => { 764 | const loginCall = jest.fn() 765 | const meCall = jest.fn() 766 | 767 | // Fake stroage 768 | const resolvesGetItem: TestCallBack[] = [] 769 | const localStorageMock = { 770 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 771 | setItem: jest.fn(), 772 | removeItem: jest.fn(), 773 | } 774 | Object.defineProperty(global, '_localStorage', { 775 | value: localStorageMock, 776 | writable: true, 777 | }) 778 | 779 | const NavBar = () => ( 780 |
781 | 782 | Custom 783 | 784 | 785 | Empty 786 | 787 | 788 | CustomComponent 789 | 790 | 791 | Maybe 792 | 793 | 794 | Maybe Empty 795 | 796 | 797 | Maybe Custom 798 | 799 | 803 | Maybe Custom Component 804 | 805 | 806 | Auth 807 | 808 | 809 | Auth Empty 810 | 811 | 812 | Auth Custom 813 | 814 | 818 | Auth Custom 819 | 820 |
821 | ) 822 | 823 | function CustomSpinnyComponent() { 824 | return
CustomSpinnyComponent
825 | } 826 | 827 | function MaybeCustomSpinner({ msg }: { msg?: string }) { 828 | return
MaybeCustomSpinner{msg}
829 | } 830 | 831 | function MaybeCustomSpinner2() { 832 | return 833 | } 834 | 835 | function AuthSpinner() { 836 | return
AuthSpinnerComponent
837 | } 838 | 839 | const App = () => ( 840 | 841 | }> 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | CustomSpinny} 854 | > 855 | 856 | 857 | 861 | 862 | 863 | 864 | 865 | 866 | 867 | } 870 | > 871 | 872 | 873 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | AuthSpinny} 888 | > 889 | 890 | 891 | 895 | 896 | 897 | 898 | 899 | 900 | 901 | ) 902 | 903 | const { getByTestId, queryAllByTestId } = render() 904 | 905 | // Spinner on the page 906 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 907 | 908 | await act(async () => { 909 | fireEvent.click(getByTestId('link-to-empty')) 910 | }) 911 | 912 | // No spinner 913 | expect(queryAllByTestId('spinner').length).toBe(0) 914 | 915 | await act(async () => { 916 | fireEvent.click(getByTestId('link-to-custom')) 917 | }) 918 | 919 | // Custom Spinner on the page 920 | expect(getByTestId('spinner').textContent).toEqual('CustomSpinny') 921 | 922 | await act(async () => { 923 | fireEvent.click(getByTestId('link-to-custom-component')) 924 | }) 925 | 926 | // Custom Spinner Component on the page 927 | expect(getByTestId('spinner').textContent).toEqual( 928 | 'CustomSpinnyComponent' 929 | ) 930 | 931 | await act(async () => { 932 | fireEvent.click(getByTestId('link-to-maybe')) 933 | }) 934 | 935 | // Spinner on the page 936 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 937 | 938 | await act(async () => { 939 | fireEvent.click(getByTestId('link-to-maybe-empty')) 940 | }) 941 | 942 | // No spinner 943 | expect(queryAllByTestId('spinner').length).toBe(0) 944 | 945 | await act(async () => { 946 | fireEvent.click(getByTestId('link-to-maybe-custom')) 947 | }) 948 | 949 | // Custom Spinner on the page 950 | expect(getByTestId('spinner').textContent).toEqual('MaybeCustomSpinnerXD') 951 | 952 | await act(async () => { 953 | fireEvent.click(getByTestId('link-to-maybe-custom-component')) 954 | }) 955 | 956 | // Custom Spinner component on the page 957 | expect(getByTestId('spinner').textContent).toEqual('MaybeCustomSpinner') 958 | 959 | await act(async () => { 960 | fireEvent.click(getByTestId('link-to-auth')) 961 | }) 962 | 963 | // Spinner on the page 964 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 965 | 966 | await act(async () => { 967 | fireEvent.click(getByTestId('link-to-auth-empty')) 968 | }) 969 | 970 | // No spinner 971 | expect(queryAllByTestId('spinner').length).toBe(0) 972 | 973 | await act(async () => { 974 | fireEvent.click(getByTestId('link-to-auth-custom')) 975 | }) 976 | 977 | // Custom Spinner on the page 978 | expect(getByTestId('spinner').textContent).toEqual('AuthSpinny') 979 | 980 | await act(async () => { 981 | fireEvent.click(getByTestId('link-to-auth-custom-component')) 982 | }) 983 | 984 | // Custom Spinner component on the page 985 | expect(getByTestId('spinner').textContent).toEqual('AuthSpinnerComponent') 986 | }) 987 | 988 | it('should configure auth redirects', async () => { 989 | const loginCall = jest.fn() 990 | 991 | const meCall = jest.fn() 992 | 993 | // Fake stroage 994 | const resolvesGetItem: TestCallBack[] = [] 995 | const localStorageMock = { 996 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 997 | setItem: jest.fn(), 998 | removeItem: jest.fn(), 999 | } 1000 | Object.defineProperty(global, '_localStorage', { 1001 | value: localStorageMock, 1002 | writable: true, 1003 | }) 1004 | 1005 | const NavBar = () => ( 1006 |
1007 | 1008 | CustomRedirect 1009 | 1010 |
1011 | ) 1012 | 1013 | function CustomAnon() { 1014 | return
CustomAnon
1015 | } 1016 | 1017 | const App = () => ( 1018 | 1019 | 1020 | 1021 | 1022 | 1023 | 1024 | 1025 | 1026 | 1027 | 1028 | 1029 | 1030 | 1031 | 1032 | 1033 | 1034 | 1035 | 1036 | 1037 | 1038 | 1039 | ) 1040 | 1041 | const { getByTestId } = render() 1042 | 1043 | // Empty local storage 1044 | await act(async () => { 1045 | resolvesGetItem[0](null) 1046 | }) 1047 | 1048 | expect(getByTestId('anon').textContent).toEqual('Anon') 1049 | 1050 | await act(async () => { 1051 | fireEvent.click(getByTestId('link-to-custom-redirect')) 1052 | }) 1053 | 1054 | expect(getByTestId('anon').textContent).toEqual('CustomAnon') 1055 | }) 1056 | 1057 | it('should configure guest redirects', async () => { 1058 | const loginCall = jest.fn() 1059 | 1060 | const meCall = jest 1061 | .fn, [number]>() 1062 | .mockResolvedValue({ username: 'Gio Va' }) 1063 | 1064 | // Fake stroage 1065 | const resolvesGetItem: TestCallBack[] = [] 1066 | const localStorageMock = { 1067 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 1068 | setItem: jest.fn(), 1069 | removeItem: jest.fn(), 1070 | } 1071 | Object.defineProperty(global, '_localStorage', { 1072 | value: localStorageMock, 1073 | writable: true, 1074 | }) 1075 | 1076 | const NavBar = () => ( 1077 |
1078 | 1079 | Guest 1080 | 1081 | 1082 | Custom Guest 1083 | 1084 |
1085 | ) 1086 | 1087 | function WelcomeHome() { 1088 | return
WelcomeHome
1089 | } 1090 | 1091 | function CustomHome() { 1092 | return
CustomHome
1093 | } 1094 | 1095 | const App = () => ( 1096 | 1097 | 1098 | 1099 | 1100 | 1101 | 1102 | 1103 | 1104 | 1105 | 1106 | 1107 | 1108 | 1109 | 1110 | 1111 | 1112 | 1113 | 1114 | 1115 | 1116 | 1117 | 1118 | 1119 | 1120 | ) 1121 | 1122 | const { getByTestId } = render() 1123 | 1124 | // Empty local storage 1125 | await act(async () => { 1126 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 1127 | }) 1128 | 1129 | expect(meCall).toHaveBeenCalled() 1130 | 1131 | // Show ma home 1132 | expect(getByTestId('home').textContent).toEqual('Home') 1133 | 1134 | await act(async () => { 1135 | fireEvent.click(getByTestId('link-to-guest')) 1136 | }) 1137 | 1138 | // Show welcome home 1139 | expect(getByTestId('home').textContent).toEqual('WelcomeHome') 1140 | 1141 | await act(async () => { 1142 | fireEvent.click(getByTestId('link-to-custom-guest')) 1143 | }) 1144 | 1145 | expect(getByTestId('home').textContent).toEqual('CustomHome') 1146 | }) 1147 | 1148 | it('should configure redirect based un test function', async () => { 1149 | const loginCall = jest.fn() 1150 | 1151 | let resolveMe: TestCallBack 1152 | const meCall = jest.fn( 1153 | () => 1154 | new Promise((resolve) => { 1155 | resolveMe = resolve 1156 | }) 1157 | ) 1158 | 1159 | // Fake stroage 1160 | const resolvesGetItem: TestCallBack[] = [] 1161 | const localStorageMock = { 1162 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 1163 | setItem: jest.fn(), 1164 | removeItem: jest.fn(), 1165 | } 1166 | Object.defineProperty(global, '_localStorage', { 1167 | value: localStorageMock, 1168 | writable: true, 1169 | }) 1170 | 1171 | function Gengar() { 1172 | return
Gengar
1173 | } 1174 | 1175 | const App = () => ( 1176 | 1177 | 1179 | user.username === 'GenGar' ? '/gengar' : null 1180 | } 1181 | > 1182 | 1183 | 1184 | 1185 |
Vulpix
1186 |
1187 | '/vulpix'} 1189 | path="/custom-redirect" 1190 | > 1191 | 1192 | 1193 | 1194 |
1195 |
Gengar
1196 | 1197 | 2 Vulpix 1198 | 1199 |
1200 |
1201 | 1202 | 1203 | 1204 |
1205 |
1206 |
1207 |
1208 | ) 1209 | 1210 | const { getByTestId } = render() 1211 | 1212 | // Local storage 1213 | await act(async () => { 1214 | resolvesGetItem[0](JSON.stringify({ accessToken: 23 })) 1215 | }) 1216 | 1217 | // Me Call 1218 | await act(async () => { 1219 | resolveMe({ username: 'GenGar' }) 1220 | }) 1221 | 1222 | expect(getByTestId('home').textContent).toEqual('Gengar') 1223 | 1224 | await act(async () => { 1225 | fireEvent.click(getByTestId('to-vulpix')) 1226 | }) 1227 | 1228 | expect(getByTestId('home').textContent).toEqual('Vulpix') 1229 | }) 1230 | 1231 | it('should configure referrer', async () => { 1232 | const loginCall = jest 1233 | .fn>, [DummyLoginCredentials]>() 1234 | .mockResolvedValue({ 1235 | accessToken: 99, 1236 | }) 1237 | 1238 | const meCall = jest.fn, [number]>().mockResolvedValue({ 1239 | username: 'Gio Va', 1240 | }) 1241 | 1242 | // Fake stroage 1243 | const resolvesGetItem: TestCallBack[] = [] 1244 | const localStorageMock = { 1245 | getItem: jest.fn(() => new Promise((r) => resolvesGetItem.push(r))), 1246 | setItem: jest.fn(), 1247 | removeItem: jest.fn(), 1248 | } 1249 | Object.defineProperty(global, '_localStorage', { 1250 | value: localStorageMock, 1251 | writable: true, 1252 | }) 1253 | 1254 | function Login() { 1255 | const { login } = useAuthActions< 1256 | number, 1257 | never, 1258 | DummyUser, 1259 | DummyLoginCredentials 1260 | >() 1261 | 1262 | return ( 1263 |
1264 |
Login
1265 | 1277 |
1278 | ) 1279 | } 1280 | 1281 | function NavBar() { 1282 | const { logout } = useAuthActions() 1283 | return ( 1284 |
1285 | 1288 | 1289 | Remember 1290 | 1291 |
1292 | ) 1293 | } 1294 | 1295 | function FuckingHome({ msg = 'Home' }: { msg?: string }) { 1296 | return
{msg}
1297 | } 1298 | 1299 | const App = () => ( 1300 | 1301 | 1305 | 1309 | 1310 | 1311 | 1312 | 1313 | 1314 | 1315 | 1316 | 1317 | 1318 | 1319 | 1320 | 1321 | 1322 | 1323 | 1324 | 1325 | ) 1326 | 1327 | const { getByTestId, queryAllByTestId } = render() 1328 | 1329 | // Spinner on the page 1330 | expect(getByTestId('spinner').textContent).toEqual('Spinny') 1331 | 1332 | // Local storage 1333 | await act(async () => { 1334 | resolvesGetItem[0](null) 1335 | }) 1336 | 1337 | // No Spinny 1338 | expect(queryAllByTestId('spinner')).toEqual([]) 1339 | 1340 | expect(getByTestId('login').textContent).toEqual('Login') 1341 | 1342 | await act(async () => { 1343 | fireEvent.click(getByTestId('login-btn')) 1344 | }) 1345 | 1346 | // Show home 1347 | expect(getByTestId('home').textContent).toEqual('Home') 1348 | 1349 | await act(async () => { 1350 | fireEvent.click(getByTestId('logout-btn')) 1351 | }) 1352 | // Back 2 login 1353 | expect(getByTestId('login').textContent).toEqual('Login') 1354 | 1355 | await act(async () => { 1356 | fireEvent.click(getByTestId('link-to-remember')) 1357 | }) 1358 | 1359 | expect(getByTestId('login').textContent).toEqual('Login') 1360 | 1361 | await act(async () => { 1362 | fireEvent.click(getByTestId('login-btn')) 1363 | }) 1364 | expect(getByTestId('home').textContent).toEqual('RememberHome') 1365 | }) 1366 | }) 1367 | }) 1368 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthRoute } from './AuthRoute' 2 | export { default as GuestRoute } from './GuestRoute' 3 | export { default as MaybeAuthRoute } from './MaybeAuthRoute' 4 | export { default as AuthRoutesProvider } from './AuthRoutesProvider' 5 | -------------------------------------------------------------------------------- /src/routes/types.ts: -------------------------------------------------------------------------------- 1 | export type Dictionary = Record -------------------------------------------------------------------------------- /src/routes/useShallowMemo.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { shallowEqualObjects } from './utils' 3 | 4 | export default function useShallowMemo(next: V) : V { 5 | const previousRef = useRef(next) 6 | const previous = previousRef.current 7 | 8 | const isEqual = shallowEqualObjects(previous, next) 9 | 10 | useEffect(() => { 11 | if (!isEqual) { 12 | previousRef.current = next 13 | } 14 | }) 15 | 16 | return isEqual ? previous : next 17 | } 18 | -------------------------------------------------------------------------------- /src/routes/utils.ts: -------------------------------------------------------------------------------- 1 | export function shallowEqualObjects(objA: any, objB: any): boolean { 2 | if (objA === objB) { 3 | return true 4 | } 5 | 6 | if (!objA || !objB) { 7 | return false 8 | } 9 | 10 | const aKeys = Object.keys(objA) 11 | const bKeys = Object.keys(objB) 12 | const len = aKeys.length 13 | 14 | if (bKeys.length !== len) { 15 | return false 16 | } 17 | 18 | for (let i = 0; i < len; i++) { 19 | const key = aKeys[i] 20 | 21 | if ( 22 | objA[key] !== objB[key] || 23 | !Object.prototype.hasOwnProperty.call(objB, key) 24 | ) { 25 | return false 26 | } 27 | } 28 | 29 | return true 30 | } 31 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { AuthTokens } from './types' 2 | 3 | export interface StorageBackend { 4 | getItem: (key: string) => string | null | Promise 5 | setItem: (key: string, value: string) => Promise | void 6 | removeItem: (key: string) => Promise | void 7 | } 8 | 9 | const noopStorageBackend: StorageBackend = { 10 | getItem: () => null, 11 | setItem: () => {}, 12 | removeItem: () => {}, 13 | } 14 | 15 | // If a promise return them if other return the value as resolved promise ... 16 | function getResolvedOrPromise(value: V | Promise): Promise { 17 | // Check if a Promise... 18 | if ( 19 | value !== null && 20 | typeof value === 'object' && 21 | typeof (value as Promise).then === 'function' 22 | ) { 23 | return value as Promise 24 | } 25 | return Promise.resolve(value) 26 | } 27 | 28 | const checkStorage = (storageCandidate: any) => { 29 | if (typeof storageCandidate.getItem !== 'function') { 30 | console.error( 31 | '[use-eazy-auth] Invalid storage backend, it lacks function getItem, no storage will be used' 32 | ) 33 | return false 34 | } 35 | if (typeof storageCandidate.setItem !== 'function') { 36 | console.error( 37 | '[use-eazy-auth] Invalid storage backend, it lacks function setItem, no storage will be used' 38 | ) 39 | return false 40 | } 41 | if (typeof storageCandidate.removeItem !== 'function') { 42 | console.error( 43 | '[use-eazy-auth] Invalid storage backend, it lacks function removeItem, no storage will be used' 44 | ) 45 | return false 46 | } 47 | return true 48 | } 49 | 50 | export interface AuthStorage
{ 51 | getTokens(): Promise> 52 | setTokens(tokens: AuthTokens): Promise 53 | removeTokens(): Promise 54 | } 55 | 56 | /** 57 | * makeStorage creates a wrapper around a compatible StorageLike object 58 | * The wrapper solves two tasks 59 | * - serialize and deserialize the token bag to string 60 | * - return a consistent Promise-base interface towards the store 61 | * in particular, some stores like ReactNative AsyncStorage are asynchronous, 62 | * while window.localStorage is synchronous, we want to uniform these behaviours 63 | * @param {StorageBackend} givenStorageBackend 64 | * @param {string} storageNamespace 65 | */ 66 | export function makeStorage( 67 | givenStorageBackend: StorageBackend | false | undefined, 68 | storageNamespace: string, 69 | ): AuthStorage { 70 | let storageBackend = noopStorageBackend 71 | if ( 72 | typeof givenStorageBackend === 'undefined' || 73 | givenStorageBackend === null 74 | ) { 75 | if ( 76 | typeof window !== 'undefined' && 77 | typeof window.localStorage !== 'undefined' 78 | ) { 79 | // If provided by environment use local storage 80 | storageBackend = window.localStorage 81 | } 82 | } else if ( 83 | givenStorageBackend !== false && 84 | checkStorage(givenStorageBackend) 85 | ) { 86 | // When given use provided storage backend 87 | storageBackend = givenStorageBackend 88 | } 89 | 90 | const getTokens = (): Promise => { 91 | return getResolvedOrPromise(storageBackend.getItem(storageNamespace)).then( 92 | (rawTokens) => { 93 | // Empty storage... 94 | if (typeof rawTokens !== 'string') { 95 | return Promise.reject() 96 | } 97 | try { 98 | const parsedTokens = JSON.parse(rawTokens) 99 | if ( 100 | typeof parsedTokens === 'object' && 101 | parsedTokens !== null && 102 | parsedTokens.accessToken 103 | ) { 104 | // TODO: Maybe validate in more proper way the content of local storeage.... 105 | return Promise.resolve(parsedTokens) 106 | } 107 | return Promise.reject() 108 | } catch (e) { 109 | // BAD JSON 110 | return Promise.reject() 111 | } 112 | } 113 | ) 114 | } 115 | 116 | const setTokens = (tokens: AuthTokens) => { 117 | return getResolvedOrPromise( 118 | storageBackend.setItem(storageNamespace, JSON.stringify(tokens)) 119 | ) 120 | } 121 | 122 | const removeTokens = () => { 123 | return getResolvedOrPromise(storageBackend.removeItem(storageNamespace)) 124 | } 125 | 126 | return { 127 | setTokens, 128 | removeTokens, 129 | getTokens, 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | 3 | export interface AuthTokens { 4 | accessToken: A 5 | refreshToken?: R | null 6 | expires?: number | null // Optional expires time in seconds 7 | } 8 | 9 | export interface InitialAuthData { 10 | accessToken: A | null 11 | refreshToken?: R | null 12 | expires?: number | null 13 | user: U | null 14 | } 15 | 16 | export type RefreshTokenCall = ( 17 | refreshToken: R 18 | ) => Promise> | Observable> 19 | 20 | export type MeCall = ( 21 | accessToken: T, 22 | loginResponse?: L 23 | ) => Promise | Observable 24 | 25 | export type LoginCall = ( 26 | loginCredentials: C 27 | ) => Promise> | Observable> 28 | 29 | export type CurryAuthApiFnPromise = ( 30 | accessToken: A 31 | ) => (...args: FA) => Promise 32 | 33 | export type CurryAuthApiFn = ( 34 | accessToken: A 35 | ) => (...args: FA) => Observable | Promise -------------------------------------------------------------------------------- /src/useConstant.ts: -------------------------------------------------------------------------------- 1 | // Thanks 2 ma man @Andarist <3 2 | // https://github.com/Andarist/use-constant 3 | import { useRef } from 'react' 4 | 5 | type ResultBox = { v: T } 6 | 7 | export default function useConstant(fn: () => T): T { 8 | const ref = useRef>() 9 | 10 | if (!ref.current) { 11 | ref.current = { v: fn() } 12 | } 13 | 14 | return ref.current.v 15 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function isUnauthorizedError(error: any) { 3 | return error?.status === 401 || error?.response?.status === 401 4 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "allowJs": false, 7 | "noEmit": false, 8 | "emitDeclarationOnly": true 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": [ 12 | "node_modules", 13 | "**/*.test.ts", 14 | "**/*.test.tsx", 15 | "**/*.test-d.ts", 16 | "**/*.test-d.tsx" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "lib": ["dom", "esnext"], 5 | "target": "es5", 6 | "allowJs": true, 7 | "noImplicitAny": true, 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true, 10 | "strictPropertyInitialization": true, 11 | "strictBindCallApply": true, 12 | "alwaysStrict": true, 13 | "strict": true, 14 | "noImplicitThis": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "declaration": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "moduleResolution": "node", 22 | "jsx": "react", 23 | "noEmit": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "use-eazy-auth": ["./src/index"], 27 | "use-eazy-auth/routes": ["./src/routes/index"], 28 | } 29 | }, 30 | "include": ["src/**/*", "example/**/*"], 31 | "exclude": ["lib/**/*"] 32 | } 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './example/index.js', 5 | mode: 'development', 6 | output: { 7 | filename: 'bundle.js' 8 | }, 9 | devServer: { 10 | static: './example', 11 | historyApiFallback: true, 12 | port: 3000, 13 | }, 14 | devtool: 'inline-source-map', 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(t|j)sx?$/, 19 | exclude: /(node_modules)/, 20 | use: 'babel-loader' 21 | }, 22 | { 23 | test: /\.css$/, 24 | use: [ 'style-loader', 'css-loader' ] 25 | } 26 | ] 27 | }, 28 | resolve: { 29 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 30 | alias: { 31 | 'use-eazy-auth': path.resolve(__dirname, 'src'), 32 | } 33 | } 34 | }; 35 | --------------------------------------------------------------------------------