Custom route to handle authorized users? - reactjs

I would like to create a custom express route that would allow to determine if a user has the right to access a page or not.
So far I got it working by using something like that :
import React from 'react';
import { Route } from 'react-router-dom';
import { AuthenticationContext } from '../../contexts/authentication/context';
import Unauthorized from '../Unauthorized';
import axios from 'axios';
const ProtectedRoute = ({ component: Component, redirect: Redirect, contextProvider: ContextProvider, path, ...routeProps }) => {
const { authenticationState: {isAuthenticated, isFetchingTheUser, currentUser} } = React.useContext(AuthenticationContext)
const [authorized, setAuthorized] = React.useState([]);
const [isFetchingAuthorizations, setIsFetchingAuthorizations] = React.useState(false);
React.useEffect(() => {
setIsFetchingAuthorizations(true);
axios.get(`${global.REST_API_ADDR}/api/pages/${encodeURIComponent(path)}`)
.then((response) => {
setAuthorized(response.data.authorized);
setIsFetchingAuthorizations(false);
})
.catch((error) => {
setIsFetchingAuthorizations(false);
// console.log("Protected route use Effect error : ", error);
})
}, [path])
return (
<Route {...routeProps}
render={ props => {
if(isFetchingTheUser || isFetchingAuthorizations) return <div>Chargement...</div>
if(isAuthenticated && authorized.includes(currentUser.rank)){
return ContextProvider ? <ContextProvider><Component {...props} /></ContextProvider> : <Component {...props} />
}else if(isAuthenticated && !authorized.includes(currentUser.rank)) {
return <Unauthorized {...props} />;
}
else{
return <Redirect {...props}/>;
}
}}/>
);
};
export default ProtectedRoute;
The problem here is that if the user changes routes a second time, the component will keep the previous authorized array for a few milliseconds, render the component and if the component has any kind of
useEffect with some API calls it will throw an error in the console. I would like to know if there is any way to prevent this ?
Like maybe emptying the state after the route renders the component ?
Thanks in advance,
EDIT 1 : from the user's point of view there are no visual bugs or latency but as a developer I have a hard time leaving an error message in the console if there is any way to avoid it

Related

How can I control access to certain pages using custom claims on the client [duplicate]

This question already has answers here:
How to create a protected route with react-router-dom?
(5 answers)
Closed 14 days ago.
Essentially,
I have a higher order component (HoC), that takes routes, and determines whether or not the currently authenticated user can access the component passed in via props.
Im trying to have a useEffect in my HoC that checks the ID token result of a user, and extracts the custom claims on the user, which were created for the user on the server side at the time of creation using the firebaseAdmin SDK, the custom claims are just the following:
{trial: true, isSubscribed: false}
Next it stores the value of these custom claims in the state for the component and uses a series of if else statements to determine whether the user is authenticated, and whether or not the user is subscribed if the route trying to be accessed is one requiring a subscription.
Lastly in the return method of my HoC, I render the component conditionally using a ternary operator.
return (
<>
{isAuthenticated? (
props.component
) : (
<Navigate to='/' />
)}
</>
)
Here is my app.js where I pass in the protected routes into my Hoc which is ProtectedPage as a component prop, I also specify whether or not the route going into ProtectedPage requires a subscription or not using the isSubcribedRoute prop:
//App.js
import "./styles/bootstrap.css";
import Home from './components/Home'
import Signup from './components/Signup';
import Login from './components/Login'
import LandingPage from './components/LandingPage'
import Account from './components/Account/Account';
import UserDetails from './components/Account/UserDetails';
import Quizzes from './components/Quizzes';
import Lessons from './components/Lessons';
import Learn from './components/Learn/Learn';
import LearnCourses from './components/Learn/LearnCourses';
import Features from './components/Features'
import ProtectedPage from './components/ProtectedPage';
import { FbMethodContextProvider } from "./firebase/fbMethodContext";
import { createBrowserRouter, RouterProvider} from 'react-router-dom';
import { AuthContextProvider, } from "./firebase/authContext";
const router = createBrowserRouter([
{
path: '/',
element: <Home />,
children: [
{
index: true,
element: <LandingPage />
},
{
path: '/signup',
element: <Signup />
},
{
path: '/login',
element: <Login />
},
{
path:'/features',
element: <ProtectedPage component={<Features />} isSubscribedRoute={false } />
}
]
},
{
path: '/account',
element: <ProtectedPage component={ <Account />} isSubscribedRoute={true}/>,
children:[
{
index: true,
element: <UserDetails/>,
}
]
},
{
path: '/quizzes',
element: <Quizzes />,
},
{
path: '/lessons',
element: <Lessons />
},
{
path: '/learn',
element: <ProtectedPage component ={<Learn />} isSubscribedRoute={false}/>,
children:[
{
index: true,
element: <LearnCourses />
}
]
}
]);
function App() {
return (
<FbMethodContextProvider>
<AuthContextProvider>
<RouterProvider router={router}/>
</AuthContextProvider>
</FbMethodContextProvider>
);
}
export default App;
Next this is my protected page component:
//ProtectedPage.js
import React,{useEffect, useState} from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import {auth} from '../firebase/firebaseConfig';
import {redirect , useNavigate, Navigate} from 'react-router-dom';
const ProtectedPage = (props) => {
const [user, setUser] = useState(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [trial, setTrial] = useState()
const [isSubscribed, setIsSubscribed] = useState()
const isSubscribedRoute = props.isSubscribedRoute
const navigate = useNavigate();
useEffect(()=>{
onAuthStateChanged(auth, async (user) =>{
if(user){
const idTokenResult = await user.getIdTokenResult(true);
const onTrial = idTokenResult.claims.trial;
setTrial(onTrial);
console.log(trial);
const Subscribed = idTokenResult.claims.subscribed;
setIsSubscribed(Subscribed)
console.log(isSubscribed)
console.log(`Trial is ${onTrial} & Subscribed is ${isSubscribed}`)
setUser(user);
if(trial || isSubscribed) {
setIsAuthenticated(true);
}
} else {
setUser(user)
}
})
let isAuthorized;
if ( isSubscribedRoute) {
if(isSubscribed){
isAuthorized = true;
console.log('user is subscribed and can access this page')
} else if (trial && isSubscribed === false){
navigate('/login')
}
}
if (!isAuthenticated){
console.log('not authenticated')
navigate('/login')
//The code works when I replace this call to the navigate hook above with react-router-dom's redirect, and replace the <Navigate /> component in the render method below with <div></div>
} else if(!isSubscribedRoute && trial){
isAuthorized = true;
}else if(!isAuthorized){
console.log('not authorized')
navigate('/login')
}
} )
return (
<>
{isAuthenticated? (
props.component
) : (
<Navigate to='/' />
)}
</>
)
}
export default ProtectedPage
Essentially whenever a user logs in or signsup it should navigate them to the learn page. It all worked fine before I tried to control access with custom claims.
Now the only way I can get it to work is by replacing the navigate with redirect('/') in the if(!isAuthenticated) code block and replacing the <Navigate =to '/' /> with in the return function at the bottom.
Any help will be appreciated, or if any better methods or patterns are available please let me know as Im quite new to coding and don't know if my method is even good.
You should consider creating a hook that uses a global context where you handle all checks for the current logged in user. (Here is a guide to set it up)
Inside this hook you can have different states depending on if the user is on trial or subscribed. This way you have direct access when redirecting via routes and don't need to do the check every time.
Right now you fetch the token on each mount but you do the check for isAuthenticated before you've fetched the token.
If you use global context you will fetch token one time (the first time you enter the site) and it will be saved between routes.
This will probably resolve your problems.
Edit:
To make sure you are making comparisons once the fetch is complete you could use a loading state:
Initialize a boolean state to true: const [loading, setLoading] = useState(true)
Right after const idTokenResult = await user.getIdTokenResult(true); set the loading state to false:
const idTokenResult = await user.getIdTokenResult(true);
setLoading(false)
Then change your return to:
return (
<>
{loading? (
<span>Loading...</span>
) : isAuthenticated? (
props.component
) : (
<Navigate to='/' />
)}
</>
)

Next.js: How to prevent flash of the Unauthorized route/page prior to redirect when implementing a private route?

Essentially I created a HOC for two pages in my Next.js app (i.e. profile and dashboard) two prevent users from accessing them if they're not authorized.
Example: pages/profile.js
import withAuth from "../components/AuthCheck/index";
function Profile() {
return (
<>
<h1>Profile</h1>
</>
)
}
export default withAuth(Profile);
My Auth component/HOC:
import { useRouter } from 'next/router'
import { useUser } from '../../lib/hooks'
import { PageLoader } from '../Loader/index'
const withAuth = Component => {
const Auth = (props) => {
const { isError } = useUser(); //My hook which is calling /api/user see if there is a user
const router = useRouter()
if (isError === 'Unauthorized') {
if (typeof window !== 'undefined' && router.pathname === '/profile' || router.pathname === 'dashboard') router.push('/login')
return <PageLoader />
}
return (
<Component {...props} />
);
};
if (Component.getInitialProps) {
Auth.getInitialProps = Component.getInitialProps;
}
return Auth;
};
export default withAuth;
Now what is happening is if you happen to enter /profile or /dashboard in the browser URL bar, before the redirect you'll see the page for a second i.e. flash.
Any idea why that is happening?
I'd consider making use of getServerSideProps on pages that need to be authed, have a look at getServerSideProps . It'll run server side on a page per request.
Alternatively, it might make sense (depending on your project setup - especially if you have access to auth state in _app.tsx_) to render the auth component in your _app. More specifically, you could add a protected:true prop to the pages that are behind auth wall (using static props). Then in app you can check if a particular page has protected===true and redirect to auth component if the user isn't authed, for example:
{pageProps.protected && !user ? (
<LoginComponent />
) : (
<Component {...pageProps} />
)}
Based on what Juliomalves and Adrian mentioned I re-read the Next.js docs based on what they included, Always good to get a refresh.
That being said I tried what Adian posted.
In the _app.js file I did the following:
import dynamic from "next/dynamic";
import { useRouter } from 'next/router'
import { useEffect } from 'react';
import { PageLoader } from '../components/Loader/index'
import { useUser } from '../lib/hooks'
import Login from '../pages/login'
const Layout = dynamic(() => import('../components/Layout'));
function MyApp({ Component, pageProps }) {
const { user, isLoading } = useUser();
const router = useRouter();
useEffect(() => {
router.replace(router.pathname, undefined, { shallow: true })
}, [user])
function AuthLogin() {
useEffect(() => {
router.replace('/login', undefined, { shallow: true })
}, [])
return <Login />
}
return (
<Layout>
{isLoading ? <PageLoader /> :
pageProps.auth && !user ? (
<AuthLogin />
) : (
<Component {...pageProps} />
)
}
</Layout>
);
}
export default MyApp
So the isLoading prop from the SWR hook useUser() is a part of the first conditional ternary, While true you get the <Loader/>, when false you get the next ternary to kick of;
If the both the auth and !user props are true, the AuthLogin get rendered!
This is how I did it. I went into the pages I wanted private and used the async function getStaticProps and created the prop auth and set it to true.
/pages/dashboard.js Or whatever you want to be private;
export default function Dashboard() {
return (
<>
<h1>Dashboard</h1>
</>
)
}
export async function getStaticProps() {
return {
props: {
auth: true
},
}
}
So back in _app.js when the pages are getting rendered the getStaticProps will, as said docs say:
Next.js will pre-render this page at build time using the props
returned by getStaticProps.
So when pageProps.auth && !user is reached in _app, that is where auth comes from.
Last two things:
You need this useEffect function in the MyApp component with the user prop from the hook in its dependency. Because that will keep the URL in sync/correct, between the redirects.
In /pages/_app, MyApp add:
useEffect(() => {
router.replace(router.pathname, undefined, { shallow: true })
}, [user]);
In the AuthLogin component add:
useEffect(() => {
router.replace('/login', undefined, { shallow: true })
}, []);
This ensures when component gets rendered, the URL would be right.
I am sure if your page is changing frequently you'll have to look into getServerSideProps but for this solved my use case for static pages!
Thanks Juliomalves and Adrian!

React-Router & useContext, infinite Redirect or Rerender

I have a web application that I've been developing for a little over a year and some change. The frontend is react w/ react-router-dom 5.2 to handle navigation, a service worker, to handle caching, installing, and webpush notifications, and then the backend is a Javalin application, which exists on top of Jetty.
I am using the context API to store some session details. When you navigate to my application, if you are not already logged in, then you won't have your information stored in that context yet, so you will be redirected to /login which will begin that process. The LoginLogout component simply redirects to an external authserver that handles the authentication workflow before redirecting back to another endpoint.
Here's the detail:
There are no redirects to /login in the server code and the ProtectedRoute code is definitely to blame for this issue. Navigating to /login is causing either an infinite redirect or an infinite rerender.
All redirects server side are performed with code 302 temporary. And again, none of them point to /login
The issue, as I have tracked it down, I believe has something to do with the context itself. I have made modifications to the context and now I am experiencing different behavior from before, when I believed the service worker to be the culprit. The issue is still an infinite redirect or rerender and is hard to troubleshoot.
I know the server is doing it's part and the /auth/check endpoint is providing exactly what it should at all times.
Here's my ProtectedRoute code
import { Redirect, Route, useLocation } from "react-router-dom";
import PropTypes from "prop-types";
import React, { useContext, useEffect, useState } from "react";
import { AuthContext } from "../Contexts/AuthProvider";
import LoadingComponent from "components/Loading/LoadingComponent";
import { server } from "variables/sitevars";
export const ProtectedRoute = ({ component: Component, ...rest }) => {
const { session, setSession } = useContext(AuthContext);
const [isLoading, setLoading] = useState(true);
const [isError, setError] = useState(false);
const cPath = useLocation().pathname;
//Create a checkAgainTime
const getCAT = (currTime, expireTime) => {
return new Date(
Date.now() + (new Date(expireTime) - new Date(currTime)) * 0.95
);
};
//See if it's time to check with the server about our session
const isCheckAgainTime = (checkTime) => {
if (checkTime === undefined) {
return true;
} else {
return Date.now() >= checkTime;
}
};
useEffect(() => {
let isMounted = true;
let changed = false;
if (isMounted) {
(async () => {
let sesh = session;
try {
//If first run, or previously not logged in, or past checkAgain
if (!sesh.isLoggedIn || isCheckAgainTime(sesh.checkAgainTime)) {
//Do fetch
const response = await fetch(`${server}/auth/check`);
if (response.ok) {
const parsed = await response.json();
//Set Login Status
if (!sesh.isLoggedIn && parsed.isLoggedIn) {
sesh.isLoggedIn = parsed.isLoggedIn;
sesh.webuser = parsed.webuser;
sesh.perms = parsed.perms;
if (sesh.checkAgainTime === undefined) {
//Set checkAgainTime if none already set
sesh.checkAgainTime = getCAT(
parsed.currTime,
parsed.expireTime
);
}
changed = true;
}
if (sesh.isLoggedIn && !parsed.isLoggedIn) {
sesh.isLoggedIn = false;
sesh.checkAgainTime = undefined;
sesh.webuser = undefined;
sesh.perms = undefined;
changed = true;
}
} else {
setError(true);
}
}
if (changed) {
setSession(sesh);
}
} catch (error) {
setError(true);
}
setLoading(false);
})();
}
return function cleanup() {
isMounted = false;
};
}, []);
if (isLoading) {
return <LoadingComponent isLoading={isLoading} />;
}
if (session.isLoggedIn && !isError) {
return (
<Route
{...rest}
render={(props) => {
return <Component {...props} />;
}}
/>
);
}
if (!session.isLoggedIn && !isError) {
return <Redirect to="/login" />;
}
if (isError) {
return <Redirect to="/offline" />;
}
return null;
};
ProtectedRoute.propTypes = {
component: PropTypes.any.isRequired,
exact: PropTypes.bool,
path: PropTypes.string.isRequired,
};
Here's the use of the Authprovider. I also went ahead and give login/logout a different endpoint:
export default function App() {
return (
<BrowserRouter>
<Switch>
<Suspense fallback={<LoadingComponent />}>
<Route path="/login" exact component={InOutRedirect} />
<Route path="/logout" exact component={InOutRedirect} />
<Route path="/auth/forbidden" component={AuthPage} />
<Route path="/auth/error" component={ServerErrorPage} />
<Route path="/offline" component={OfflinePage} />
<AuthProvider>
<ProtectedRoute path="/admin" component={AdminLayout} />
</AuthProvider>
</Suspense>
</Switch>
</BrowserRouter>
);
}
And this is the AuthProvider itself:
import React, { createContext, useState } from "react";
import PropTypes from "prop-types";
export const AuthContext = createContext(null);
import { defaultProfilePic } from "../../views/Users/UserVarsAndFuncs/UserVarsAndFuncs";
const AuthProvider = (props) => {
const [session, setSesh] = useState({
isLoggedIn: undefined,
checkAgainTime: undefined,
webuser: {
IDX: undefined,
LastName: "",
FirstName: "",
EmailAddress: "",
ProfilePic: defaultProfilePic,
},
perms: {
IDX: undefined,
Name: "",
Descr: "",
},
});
const setSession = (newSession) => {
setSesh(newSession);
};
return (
<AuthContext.Provider value={{ session, setSession }}>
{props.children}
</AuthContext.Provider>
);
};
export default AuthProvider;
AuthProvider.propTypes = {
children: PropTypes.any,
};
Update: Because it was asked for, here is my login/logout component, with the changes suggested (separated from the ProtectedRoute dependency)
import React, { useEffect, useState } from "react";
import { Redirect, useLocation } from "react-router-dom";
//Components
import LoadingComponent from "components/Loading/LoadingComponent";
import { server } from "variables/sitevars";
//Component Specific Vars
export default function InOutRedirect() {
const rPath = useLocation().pathname;
const [isError, setError] = useState(false);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
if (isMounted) {
(async () => {
try {
//Do fetch
const response = await fetch(`${server}/auth/server/data`);
if (response.ok) {
const parsed = await response.json();
if (rPath === "/login") {
window.location.assign(`${parsed.LoginURL}`);
} else if (rPath === "/logout") {
window.location.assign(`${parsed.LogoutURL}`);
}
}
} catch (error) {
setError(true);
}
})();
setLoading(false);
}
return function cleanup() {
isMounted = false;
};
}, []);
if (isLoading) {
return <LoadingComponent />;
}
if (isError) {
return <Redirect to="/offline" />;
}
}
How can I track down this issue?
UPDATE: I have done further troubleshooting and am now convinced that something is wrong with how I'm using context and that the service worker does not actually play a role in this issue. I've updated the post to reflect this.
UPDATE 2: I have done further simplification. The issue is assuredly that the context is not updating via setSession either prior to the page rendering the redirect component and redirecting back to login, or altogether.
UPDATE 3: I believe I found the issue, not positive but I think it's resolved. The bounty already being offered, if someone can explain why this happened, it's yours.
I think your issue is that you're trying to redirect to a server-side route. I ran into the same issue before. React-router is picking up the path and trying to serve it to you on the client side where there is no route available and it's using the default route which is causing the loop.
To resolve, create a route to mimic the server-side route, then redirect on the client side with react-router once the server-side workflow is completed.
EDIT: The suspense tag should be outside your switch iirc. And I would include a default route pointing to a 404 or similar.
The issue seems to be with your unordered conditions. What if you have not logged in but has error? There'll be no default render for this and will cause the application halt. When user tries to login the state is touched and and upon error, this won't match any render. When you put return null, it will first render that and after a while it will match to the correct condition and return that. So, you could order your conditions like:
if (isLoading) {
// return ...
}
if (isError) {
// return ...
}
if (session.isLoggedIn) {
// return ...
}
return <Redirect to="/login" />;
Here, we're first checking if there is any error, and if it is so, redirect to error page. Otherwise, route to the logged in component or redirect to the login page.
Here I was naively believing that Switch only allows Route or Redirect as direct children, or at the very least components with path will be treated like routes. So I have no idea how any of your routing is working, to me it looks like Suspense will just always be loaded and after that everything will be considered as children of Suspense after which it will just load and render all the routes.
Other than that you really don't need to do stuff like let isMounted = true, the fact that you're using useEffect means that it's mounted, you don't have to ask or tell it or clean up or anything, it's mounted, you can just know that, whatever is inside useEffect will only be executed on component mount, while whatever you return from useEffect will be executed on component unmount.
Other than that you really shouldn't put the auth context inside routes, it's business logic and it belongs outside, way outside of your page routes. The login should also not 'redirect' the user somewhere, it should just access the auth context and update the state, which should automatically rerender the tree from your auth context, no manual redirection required. Put the auth context in something like App and then import the router as a child of it.
I am hesitant to call this resolved. And will not accept this answer until I am sure. But the issue appears to have been, that I had no default render path in my ProtectedRoute. I've updated the ProtectedRoute code to include:
return null;
This was missing from my original ProtectedRoute. Without a default render path, the /login render path (if user not signed in and no error) was the only one that would return. I am not sure why. I expect it has something to do with how react batches state updates and renders. With return null this code is working....knock on wood.

PrivateRoute wait for response

Im trying to make my privateRoute wait for my API calls. to determine if the user is online and allowed to the page or not. but im getting the following error:
error:
Error: PrivateRoute(...): Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.
i think its not waiting for a response to render since im waiting for my server. How do i solve so it wait?
im calling it like this:
server response privateroute:
import React from 'react';
import PropTypes from 'prop-types';
import { Route, Navigate } from 'react-router-dom';
import { useNavigate } from "react-router-dom";
import Login from './components/login/login.jsx';
import GameComponent from './gameComponent.jsx';
import axios from 'axios';
const PrivateRoute = ({ component: Component, redirectTo, isAuth, path, ...props }) => {
//isAuth = false;
axios.post(`http://localhost:3000/online`,{withCredentials: true})
.then(res => {
console.log(res);
if (res) {
isAuth = true;
} else {
isAuth = false;
}
if(!isAuth) {
return <Navigate to={redirectTo} />;
}
return <Route path={path} element={<Component />} />
});
};
export default PrivateRoute;
old code:
const PrivateRoute = ({ component: Component, redirectTo, isAuth, path, ...props }) => {
//isAuth = false;
// no code checking... or real auth
if(!isAuth) {
return <Navigate to={redirectTo} />;
}
return <Route path={path} element={<Component />} />
};
export default PrivateRoute;
update: Remember it has to work with React-route Version 6
You will find my intake here : https://codesandbox.io/s/react-router-basic-forked-m0624?file=/private.js (see the private.js file, I made a private route for the topic route (topics button))
Your first mistake is that your PrivateRoute component is not returning anything. You would have to do return axios.post to return at least something (that what gives the error).
Then as axios.post is an async function that takes some time to resolve, nothing is rendered from PrivateRoute while it is fetching so you have to make sure that PrivateRoute is at least returning a loading component while it fetches.
if (isLoading) return <div>loading</div>;
Then in your following code, React wont do anything if you change variable like that. (so it won't "react" to the change)
isAuth = true;
} else {
isAuth = false;
}
You have to make a hook function like this let [isAuth, setIsAuth] = React.useState(false) and changing the variable like this setIsAuth(true). In my solution I made a custom hook for isAuth and isLoading to get and set the variables and so that React can react to their change and render the good component.

Simple logout component throws "Cannot update a component from inside the function body of a different component"

This little Logout.jsx component logs-out the user...
import React from 'react';
import { Redirect } from 'react-router';
import { useDispatch } from 'react-redux';
import { userLogout } from '../redux/actions/authActions';
const Logout = ({ to = '/loginForm' }) => {
const dispatch = useDispatch();
dispatch(userLogout());
return <Redirect to={to} />;
};
export default Logout;
and is used in path /logout thus:
<Switch>
...
<Route exact path="/logout" component={Logout} />
In the console it gives the dreaded (and apparently serious) message:
Cannot update a component from inside the function body of a different
component
Can someone spot why, and how to fix it?
Using react 16.13.0
I think it's just a logical mistake causing this error to pop up from another component (than Logout), try logging out once:
const Logout = ({ to = '/loginForm' }) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(userLogout());
}, [dispatch]);
return <Redirect to={to} />;
};
You don't want to dispatch (or logout) on every component render

Resources