How to maintain the authenticated user state on page refresh with react - reactjs

I have some protected routers but I need to maintain the state on page refresh.
import React from 'react'
import { Route, Redirect, Switch } from 'react-router-dom'
const auth = {
isAuthenticated: true // this would be an http call to get user data
}
const PrivateRoute = ({ component: Component, ...rest }) => (
<Route {...rest} render={(props) => (
auth.isAuthenticated === true
? <Component {...props} />
: <Redirect to="/login" />
)} />
)
const Main = () => {
return (
<Switch>
<Route path="/login" exact strict component={Login}/>
<Route path="/logout" exact strict component={Logout}/>
<PrivateRoute path="/profile" exact strict component={Profile}/>
</Switch>
)
}
export default Main
where should I make the service call? in the Main app? in a context?
update: I added a check in Main that makes a call to the api sending the token that I have stored. that call could return 200 with data or 401
const auth = {
isAuthenticated: Api.status() // this is just a fetch to /status endpoint
.then(
status => {
console.log(status);
return true;
},
error => {
console.log(error);
return false;
}
)
}
but when i hit /profile it redirects me immediately to login (because isAuthenticated is false)
my question is based entirely on the scenario where the user refresh the page (F5) other scenarios are working fine. sorry If I´m not clear enough happy to clarify anything thanks again!

the best way in my opinion is the local storage save the token in there dispatch an action in componentDidMount() in the root component of you app and store the token in readucer state

You can implement an auth-context like shown here: https://usehooks.com/useAuth/
And in addition store user in localStorage (or storage of your preference) and when the auth-context mounts read from localStorage and set the initial value like so:
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user')));
This way the protected routes will automatically redirect (or whatever they're supposed to do in an authenticated state) when reloading the page.

In this case I'd make an AuthContext component. By using the context provider, you can access the user logged in state everywhere in your app.
import { createContext } from 'react';
const AuthContext = createContext({
token: null,
userId: null,
login: () => {},
logout: () => {}
});
export default AuthContext;
And provide this at the highest level in your app.
import React, { useState } from 'react';
import { Route, Switch } from 'react-router-dom';
import AuthContext from './context/AuthContext';
function App() {
const [token, setToken] = useState(null);
const [userId, setUserId] = useState(null);
const login = (token, userId) => {
setToken(token);
setUserId(userId);
};
const logout = () => {
setToken(null);
setUserId(null);
};
return (
<Switch>
<Route path="/login" exact strict component={Login}/>
<Route path="/logout" exact strict component={Logout}/>
<PrivateRoute path="/profile" exact strict component={Profile}/>
</Switch>
)
}
export default App;
And in a login/logout form you make the call to a database or localstorage.
import React, { useContext } from 'react';
import AuthContext from '../context/AuthContext';
function Auth() {
const { login } = useContext(AuthContext);
const submitHandler = () => {
// Make the call here
};
return (
<Form onSubmit={submitHandler}>
// Put your login form here
</Form>
);
}
export default Auth;

Related

How can I re-run the parent route code when I'm switching between the nested children routes

Take a look at my protected route for unauthenticated pages:
import React, { useEffect } from 'react'
import { Route, Outlet, Navigate } from 'react-router-dom'
import { Login, SignUp } from '../pages'
import useVerifyToken from '../hooks/useVerifyToken'
const InauthenticatedRoutes = () => {
const isAuth = useVerifyToken()
return !isAuth ? <Outlet /> : <Navigate to="/"/>
}
const routes = [
<Route key="3234" path='/' element={<InauthenticatedRoutes />}>
<Route key="6757" path='login' element={<Login />}/>
<Route key="67567" path='signup' element={<SignUp />}/>
</Route>
]
export default routes
The reason this file exported an array is because this will later be iterated inside the Routes element
the useVerifyToken is something like this
function useVerifyToken() {
const jwt = localStorage.getItem('access')
let data = 0
useEffect(() => {
....verifying token
}, [window.location.href])
return !data ? 0 : 1;
}
As you can see inside the useVerifyToken hook there's a useEffect that has a window.location.href as a dependency this is because I want this hook to verify the token every time the user switch in between pages. But the problem here it's that if I went from the route Login to Signup which is nested under the same parent, the useVerifyToken wouldn't be triggered. Personally I can't really find any work around without radically changing the route structure.
My question
How do I trigger the useVerifyToken to run even if I have to access a route under the same parent route? if not then what kind of work around would you recommend
I would suggest a bit of a rewrite of the useVerifyToken to have a loading state and the isAuth state. Return both values from the hook such that you can conditionally render some alternative UI while the token is being verified. Use the useLocation hook to access the location.pathname value to be used as a useEffect hook dependency. When the pathname changes the effect callback will run.
Example:
import { useLocation } from 'react-router-dom';
const useVerifyToken = () => {
const { pathname } = useLocation();
const [isAuth, setIsAuth] = React.useState();
const [isLoading, setIsLoading] = React.useState(true);
useEffect(() => {
const verifyToken = async () => {
const token = localStorage.getItem('access');
setIsLoading(true);
try {
....verifying token
setIsAuth(/* validation result, true|false */);
} catch(error) {
// handle error, log, set any error state, etc...
setIsAuth(false);
} finally {
setIsLoading(false);
}
};
verifyToken();
}, [pathname])
return { isAuth, isLoading };
};
Update InauthenticatedRoutes to use both states returned from the hook:
const InauthenticatedRoutes = () => {
const { isAuth, isLoading } = useVerifyToken();
if (isLoading) {
return null; // <-- or loading indicator, spinner, etc...
}
return !isAuth ? <Outlet /> : <Navigate to="/" replace />;
}
Now just render the routes normally, no need for them to be in any array really.
<Routes>
...
<Route element={<InauthenticatedRoutes />}>
<Route path='login' element={<Login />} />
<Route path='signup' element={<SignUp />} />
... other anonymous routes
</Route>
...
</Routes>

How can I prevent a React state update on an unmounted component in my integration testing?

I'm using the testing-library to write my tests. I'm writing integration tests that load the component and then trying to walk through the UI in the test to mimic what a user might do and then testing the results of these steps. In my test output, I get the following warning when both tests run but do not get the following warning when only one test is run. All tests that run pass successfully.
console.error node_modules/react-dom/cjs/react-dom.development.js:88
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
in Unknown (at Login.integration.test.js:12)
The following is my integration test written in jest. if I comment out either one of the tests, the warning goes away but if they both run then I get the warning.
import React from 'react';
import { render, screen, waitForElementToBeRemoved, waitFor } from '#testing-library/react';
import userEvent from "#testing-library/user-event";
import { login } from '../../../common/Constants';
import "#testing-library/jest-dom/extend-expect";
import { MemoryRouter } from 'react-router-dom';
import App from '../../root/App';
import { AuthProvider } from '../../../middleware/Auth/Auth';
function renderApp() {
render(
<AuthProvider>
<MemoryRouter>
<App />
</MemoryRouter>
</AuthProvider>
);
//Click the Login Menu Item
const loginMenuItem = screen.getByRole('link', { name: /Login/i });
userEvent.click(loginMenuItem);
//It does not display a login failure alert
const loginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
expect(loginFailedAlert).not.toBeInTheDocument();
const emailInput = screen.getByPlaceholderText(login.EMAIL);
const passwordInput = screen.getByPlaceholderText(login.PASSWORD);
const buttonInput = screen.getByRole('button', { text: /Submit/i });
expect(emailInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
expect(buttonInput).toBeInTheDocument();
return { emailInput, passwordInput, buttonInput }
}
describe('<Login /> Integration tests:', () => {
test('Successful Login', async () => {
const { emailInput, passwordInput, buttonInput } = renderApp();
Storage.prototype.getItem = jest.fn(() => {
return JSON.stringify({ email: 'asdf#asdf.com', password: 'asdf' });
});
// fill out and submit form with valid credentials
userEvent.type(emailInput, 'asdf#asdf.com');
userEvent.type(passwordInput, 'asdf');
userEvent.click(buttonInput);
//It does not display a login failure alert
const noLoginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
expect(noLoginFailedAlert).not.toBeInTheDocument();
// It hides form elements
await waitForElementToBeRemoved(() => screen.getByPlaceholderText(login.EMAIL));
expect(emailInput).not.toBeInTheDocument();
expect(passwordInput).not.toBeInTheDocument();
expect(buttonInput).not.toBeInTheDocument();
});
test('Failed Login - invalid password', async () => {
const { emailInput, passwordInput, buttonInput } = renderApp();
Storage.prototype.getItem = jest.fn(() => {
return JSON.stringify({ email: 'brad#asdf.com', password: 'asdf' });
});
// fill out and submit form with invalid credentials
userEvent.type(emailInput, 'brad#asdf.com');
userEvent.type(passwordInput, 'invalidpw');
userEvent.click(buttonInput);
//It displays a login failure alert
await waitFor(() => expect(screen.getByRole('alert', { text: /Login Failed./i })).toBeInTheDocument())
// It still displays login form elements
expect(emailInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
expect(buttonInput).toBeInTheDocument();
});
});
The following is the component:
import React, { useContext } from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import Layout from '../../hoc/Layout/Layout';
import { paths } from '../../common/Constants';
import LandingPage from '../pages/landingPage/LandingPage';
import Dashboard from '../pages/dashboard/Dashboard';
import AddJob from '../pages/addJob/AddJob';
import Register from '../pages/register/Register';
import Login from '../pages/login/Login';
import NotFound from '../pages/notFound/NotFound';
import PrivateRoute from '../../middleware/Auth/PrivateRoute';
import { AuthContext } from '../../middleware/Auth/Auth';
function App() {
let authenticatedRoutes = (
<Switch>
<PrivateRoute path={'/dashboard'} exact component={Dashboard} />
<PrivateRoute path={'/add'} exact component={AddJob} />
<PrivateRoute path={'/'} exact component={Dashboard} />
<Route render={(props) => (<NotFound {...props} />)} />
</Switch>
)
let publicRoutes = (
<Switch>
<Route path='/' exact component={LandingPage} />
<Route path={paths.LOGIN} exact component={Login} />
<Route path={paths.REGISTER} exact component={Register} />
<Route render={(props) => (<NotFound {...props} />)} />
</Switch>
)
const { currentUser } = useContext(AuthContext);
let routes = currentUser ? authenticatedRoutes : publicRoutes;
return (
<Layout>{routes}</Layout>
);
}
export default withRouter(App);
The following is the AuthProvider component that wraps in the renderApp() function. It takes advantadge of the React useContext hook to manage the state of a users authentication for the app:
import React, { useEffect, useState } from 'react'
import { AccountHandler } from '../Account/AccountHandler';
export const AuthContext = React.createContext();
export const AuthProvider = React.memo(({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [pending, setPending] = useState(true);
useEffect(() => {
if (pending) {
AccountHandler.getInstance().registerAuthStateChangeObserver((user) => {
setCurrentUser(user);
setPending(false);
})
};
})
if (pending) {
return <>Loading ... </>
}
return (
<AuthContext.Provider value={{ currentUser }}>
{children}
</AuthContext.Provider>
)
});
It seems as though the first test mounts the component under test but that the second test is somehow trying to reference the first mounted component rather than the newly mounted component but I can't seem to figure out exactly what's happening here to correct these warnings. Any help would be greatly appreciated!
the AccountHandler isn't a singleton() the getInstance method name needs to be refactored to reflect this. So a new instance of AccountHandler is being created every time this is called. But, the register function adds an observer to an array that is iterated and each observer is called in that array when the authentication state changes. I wasn't clearing when new observers were added and thus the tests were calling both the old and unmounted observers as well as the new ones. By simply clearing that array, the issue was resolved. Here's the corrected code that has fixed the issue:
private observers: Array<any> = [];
/**
*
* #param observer a function to call when the user authentication state changes
* the value passed to this observer will either be the email address for the
* authenticated user or null for an unauthenticated user.
*/
public registerAuthStateChangeObserver(observer: any): void {
/**
* NOTE:
* * The observers array needs to be cleared so as to avoid the
* * situation where a reference to setState on an unmounted
* * React component is called. By clearing the observer we
* * ensure that all previous observers are garbage collected
* * and only new observers are used. This prevents memory
* * leaks in the tests.
*/
this.observers = [];
this.observers.push(observer);
this.initializeBackend();
}
It looks like your AccountHandler is a singleton and you subscribe to changes to it.
This means that after you unmount the first component and mount the second instance, the first one is still registered there, and any updates to the AccountHandler will trigger the handler which calls setCurrentUser and setPending of the first component too.
You need to unsubscribe when the component is unmounted.
something like this
const handleUserChange = useCallback((user) => {
setCurrentUser(user);
setPending(false);
}, []);
useEffect(() => {
if (pending) {
AccountHandler.getInstance().registerAuthStateChangeObserver(handleUserChange)
};
return () => {
// here you need to unsubscribe
AccountHandler.getInstance().unregisterAuthStateChangeObserver(handleUserChange);
}
}, [])

Pass object to child then access object methods

So I have a file routes.jsx which handles the routing of my application. Following the docs for react-router-dom, I wanted to start working on login/logout.
For now, I have implemented a fakeAuth object which will authenticate against the backend and set an isAuthenticated var. However, I think I need to be able to pass this auth around to different components, such as the Login.jsx to actually change the state of isAuthenticated, and to let's say a NavBar.jsx so I can change a login button to a logout button (to clarify, all my components are functional components (trying) using hooks and do not extend React.Component in a class manner).
However, how do I pass the fakeAuth as a prop and still have access to the methods inside? If I declare fakeAuth inside the Login.jsx component, fakeAuth is defined. If I pass it in as a prop, it is considered not defined.
routes.js
export default (
<BrowserRouter>
<Route path='/'>
<LandingPage fakeAuth={fakeAuth}/> // Landing page has the NavBar component as a child.
</Route>
<Switch>
<Route path="/login">
<Login fakeAuth={fakeAuth}/> // I want the Login component to have access to fakeAuth.whatever
</Route>
<PrivateRoute path="/home">
<UserApp/>
</PrivateRoute>
</Switch>
</BrowserRouter>
);
const fakeAuth = {
isAuthenticated: false,
authenticate(cb) {
fakeAuth.isAuthenticated = true;
setTimeout(cb, 100); // fake async
},
signout(cb) {
fakeAuth.isAuthenticated = false;
setTimeout(cb, 100);
}
};
Login.jsx
const Login = ({fakeAuth}) => {
let history = useHistory();
let location = useLocation();
let { from } = location.state || { from: { pathname: "/home" } };
let login = () => {
fakeAuth.authenticate(() => { // Need to access authenticate.
history.replace(from);
});
};
return (
// login modal etc
<button onClick={login}>Log in</button>
);
}
The above code is the code that returns fakeAuth is undefined.
Any advice would be great!

Navigate to different page on successful login

App.tsx
import React from 'react';
import {
BrowserRouter as Router,
Redirect,
Route,
Switch
} from 'react-router-dom';
import './app.css';
import Login from './auth/pages/login';
import DashBoard from './dashboard/dashboard';
export const App = () => {
return (
<div className="app">
<Router>
<Switch>
<Route path="/auth/login" component={Login} />
<Route path="/dashboard" component={DashBoard} />
<Redirect from="/" exact to="/auth/login" />
</Switch>
</Router>
</div>
);
};
export default App;
login.tsx
import { useHistory } from 'react-router-dom';
const authHandler = async (email, password) => {
const history = useHistory();
try {
const authService = new AuthService();
await authService
.login({
email,
password
})
.then(() => {
history.push('/dashboard');
});
} catch (err) {
console.log(err);
}
};
From the above code I'm trying to navigate to dashboard on successful login.
The auth handler function is being called once the submit button is clicked.
The login details are successfully got in authhandler function, but once I use history to navigate I get the following error
"Uncaught (in promise) Error: Invalid hook call. Hooks can only be called inside of the body of a function component"
Error text is pretty clear. You can not call useHistory, or any other hook, outside of functional component. Also, hooks must be called unconditionally, on top of component. Try to call useHistory inside your actual component and pass history as a parameter to authHandler.
The problem is that authHandler is an async function and using a hook inside a "normal" function don't work. It breaks the rule of hooks.
What you need to do is separate authHandler and history.push('/dashboard').
What you can do is return the async request and use .then to call history.push.
const authHandler = async (email, password) => {
const authService = new AuthService();
// returning the request
return await authService
.login({
email,
password
})
};
And inside your component you use the useHistory hook and call authHandler on some action.
const MyComponent = () => {
const history = useHistory()
const onClick = (email, password) => {
authHandler(email, password)
.then(() => history.push('/dashboard'))
}
return (...)
}

In a CRA app, how to wait for some action(redux) to get complete first and then only proceed with the App.js render() function?

I am trying to figure out a way to store the authentication state of a user inside the redux store. Suppose isAuthenticated store the state of user if they are logged-in or not. Now, I have a cookie(httpOnly) sent by the server which remembers the user, so that they don't need to enter there credentials every time they visit the app.
Flow: User some day logged in to the application and didn't logged out and closed the browser. Now, he returns and visit my app. Since, the cookie was there in browser, this will be sent automatically(without user interaction) by the application and if the cookie is valid, the isAuthenticated: true. Very simple requirement.
Tracking the authentication status should be the first thing done by the application, so I put that logic at very first, before the App.js renders.
class App extends Component {
store = configureStore();
render() {
return (
<Provider store={this.store}>
<ConnectedRouter history={history}>
<>
<GlobalStyle />
<SiteHeader />
<ErrorWrapper />
<Switch>
<PrivateHomeRoute exact path="/" component={Home} />
<Route exact path="/login" component={LoginPage} />
<PrivateHomeRoute path="/home" component={Home} />
........code
}
This is the configureStore()
export const history = createBrowserHistory();
const configureStore = () => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer(history),
composeEnhancers(applyMiddleware(sagaMiddleware, routerMiddleware(history)))
);
sagaMiddleware.run(rootSaga);
store.dispatch({ type: AUTH.AUTO_LOGIN });
return store;
};
store.dispatch({ type: AUTH.AUTO_LOGIN }); is the code where I am trying the application to do the auto-login as the first operation in the application. This action is handled by a redux-saga
function* handleAutoLogin() {
try {
const response = yield call(autoLoginApi);
if (response && response.status === 200) {
yield put(setAuthenticationStatus(true));
}
} catch (error) {
yield put(setAuthenticationStatus(false));
}
}
function* watchAuthLogin() {
yield takeLatest(AUTH.AUTO_LOGIN, handleAutoLogin);
}
autoLoginApi is the axios call to the server which will carry the cookie with it. setAuthenticationStatus(true) is action creator which will set the isAuthenticated to true false.
So, yes this is working BUT not as expected. Since, the app should first set the isAuthenticated first and then proceed with the App.js render(). But, since setting the isAuthenticated take some seconds(api call), the application first renders with the isAuthenticated: false and then after the AUTH.AUTO_LOGIN gets completed, then the application re-render for authenticaed user.
What's the problem then? For the normal component it may not be the problem, e.g this SiteHeader component
class SiteHeader extends React.Component {
render() {
const { isLoggedIn } = this.props;
if (isLoggedIn === null) {
return "";
} else {
if (isLoggedIn) {
return (
<LoggedInSiteHeader />
);
} else {
return (
<LoggedOutSiteHeader />
);
}
}
}
}
const mapStateToProps = ({ auth, user }) => ({
isLoggedIn: auth.isLoggedIn,
});
export default connect(
mapStateToProps,
null
)(SiteHeader);
But, this solution doesn't work for the Custom routing.
const PrivateHomeRoute = ({ component: ComponentToRender, ...rest }) => (
<Route
{...rest}
render={props =>
props.isLoggedIn ? (
<ComponentToRender {...props} />
) : (
<Redirect to="/login" />
)
}
/>
);
const mapStateToProps = auth => ({
isLoggedin: auth.isLoggedIn
});
export default connect(
mapStateToProps,
null
)(PrivateHomeRoute);
PrivateHomeRoute gets resolved before the redux store gets updated, hence the Route always goes to "/login".
I am looking for a solution, where the application doesn't proceed further until the authentication action doesn't complete. But, I am no clue what and where to put that code?
Few things I tried:
async await on configureStore() - Error came
async await on App.js - Error
PS: Libraries I am using redux, redux-saga,react-router-dom, connected-react-router, axios
One way I figured out:
Create a separate component MyRouteWrapper which will return the routes based on the isLoggedIn status. To, resolve the issue I stop the routes to render until the auto-login changes the isLoggedIn state.
I set the default state of isLoggedIn to null. Now, if the state is null the MyRouteWrapper will return an empty string, and once the state gets changes to true/false, it will return the routes, and then respective components get rendered.
I changed my App.js
const store = configureStore();
class App extends Component {
render() {
return (
<Provider store={store}>
<ConnectedRouter history={history}>
<MyRouteWrapper />
</ConnectedRouter>
</Provider>
);
}
}
export default App;
The component which make sure to return the Route only when the state gets changed to true/false
const MyRouteWrapper = props => {
if (props.isLoggedIn === null) {
return "";
} else {
return (
<>
<GlobalStyle />
<SiteHeader />
<ErrorWrapper />
<Switch>
<ProtectedHomeRoute
exact
path="/"
component={Home}
isLoggedIn={props.isLoggedIn}
/>
<Route path="/profile/:id" component={Profile} />
<Route path="/login" component={LoginPage} />
</Switch>
</>
);
}
};
const mapStateToProps = ({ auth }) => ({
isLoggedIn: auth.isLoggedIn
});
export default connect(mapStateToProps)(MyRouteWrapper);
This solved the issue.
I am still curious to know the solutions(better) anyone have in there mind.

Resources