Persist the login state in Next js using localstorage - javascript

I'm writing an authentication application in Next Js (v12.2.5). The application also uses React (v18.2.0).
The problem is with persisting the authentication state. When the browser is refreshed, the login session is killed. Why is this happening even though I am getting and setting the token in the local storage. I would like to persist the login session to survive browser refresh and only kill the session when the user logs out.
Application flow
Users authenticates through a login form which calls a Spring API to fetch credentials from a mySQL database. Everything works as expected. The user is able to login, conditional rendering and route protection all function as expected.
Persisting the session relies on the localstorage API to store the JWT token once the user logs in. The Chrome browser console shows that the token is successfully set and stored throughout the authentication process. The get method for getting the initial token also seems to work.
Background
There are several questions on SFO that cover this topic but most seem to cover the use of cookies like this example. This question covers localstorage, but simply says to wrap the token get method is useEffect which doesn't address the actual questions and problems I'm having.
This example also covers localstorage but takes a different approach, using useReducer where my approach is trying to use use Effect. I'm open to restructure my whole application to use useReducer if this is the correct way, but first I want to make sure I understand if I'm taking the right approach.
I also suspect there is a difference between persisting the user state using React and Next. From researching, the difference seems to be in the way Next also includes SSR which may explain why I'm not able to persist the state in Next?
Application code
auth-context.js
const AuthContext = React.createContext({
token: '',
admintoken: '',
isLoggedIn: false,
isAdmin: false,
login: (token) => { },
adminAccess: (admintoken) => { },
logout: () => { },
});
export const AuthContextProvider = (props) => {
useEffect(()=> {
if(typeof window !== 'undefined') {
console.log('You are on the browser');
initialToken = localStorage.getItem('token');
console.log("InitialToken set "+ initialToken);
} else {
initialToken = localStorage.getItem('token');
console.log('You are on the server and token is ' + initialToken);
}
},[AuthContext])
const [token, setToken] = useState(initialToken);
const [admintoken, setAdminToken] = useState(initialToken);
const userIsLoggedIn = !!token;
const userHasAdmin = !!admintoken;
const loginHandler = (token) => {
setToken(token);
localStorage.setItem('token', token);
console.log("token stored " + token);
};
const logoutHandler = () => {
setToken(null);
localStorage.removeItem('token');
};
const adminTokenHandler = (admintoken) => {
setAdminToken(admintoken);
}
const contextValue = {
token: token,
admintoken: admintoken,
isAdmin: userHasAdmin,
isLoggedIn: userIsLoggedIn,
adminAccess: adminTokenHandler,
login: loginHandler,
logout: logoutHandler,
};
return (
<AuthContext.Provider value={contextValue}>
{props.children}
</AuthContext.Provider>
);
};
export default AuthContext;
ProtectRoute.js
const ProtectRoute = ({ children }) => {
const authCtx = useContext(AuthContext);
const isLoggedIn = authCtx.isLoggedIn;
if (!isLoggedIn && typeof window !== 'undefined' && window.location.pathname == '/') {
return <HomePage />;
} else {
if (!isLoggedIn && typeof window !== 'undefined' && window.location.pathname !== '/auth') {
return <RestrictedSection />;
}
else {
console.log("User logged in");
// return <RestrictedSection />;
return children;
}
}
}
export default ProtectRoute;
Authform.js (login page)
const AuthForm = () => {
const emailInputRef = useRef();
const passwordInputRef = useRef();
const [isLoading, setIsLoading] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const router = useRouter();
const authCtx = useContext(AuthContext);
const submitHandler = (event) => {
event.preventDefault();
const enteredEmail = emailInputRef.current.value;
const enteredPassword = passwordInputRef.current.value;
var myHeaders = new Headers();
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
var urlencoded = new URLSearchParams();
urlencoded.append("username", enteredEmail);
urlencoded.append("password", enteredPassword);
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: urlencoded,
redirect: 'follow'
};
fetch(API_LOGIN_URL, requestOptions)
.then(async (res) => {
setIsLoading(false);
if (res.ok) {
return res.json();
} else {
const data = await res.json();
let errorMessage = 'Authentication failed!';
throw new Error(errorMessage);
}
})
.then((data)=> {
authCtx.login(data.access_token);
router.replace('/');
const processedData = JSON.stringify(data);
console.log("Admin status "+ processedData);
for(let i = 0; i < processedData.length; i++) {
if(processedData.includes("ROLE_SUPER_ADMIN")) {
console.log("Found Admin");
authCtx.adminAccess(true);
}
if(processedData.includes("ROLE_USER")) {
console.log("Found User");
break;
}
else {
console.log("Not Found");
}
}})
.catch((err) => {
alert(err.message);
});
};
return (
<section className={classes.auth}>
<h1>Login</h1>
<form onSubmit={submitHandler}>
<div className={classes.control}>
<label htmlFor='email'>Your Email</label>
<input type='email' id='email' required ref={emailInputRef} />
</div>
<div className={classes.control}>
<label htmlFor='password'>Your Password</label>
<input type='password' id='password' required ref={passwordInputRef} />
</div>
<div className={classes.actions}>
{!isLoading && <button>Login</button>}
{isLoading && <p>Sending request</p>}
</div>
</form>
</section>
);
};
export default AuthForm;

Issue 1
From your code in auth-context.js, you are calling useEffect inside a condition.
if(typeof window !== 'undefined') {
console.log('You are on the browser');
useEffect(()=> {
initialToken = localStorage.getItem('token');
console.log("InitialToken set "+ initialToken);
})
} else {
useEffect(()=> {
initialToken = localStorage.getItem('token');
console.log('You are on the server and token is ' + initialToken);
})
}
You SHOULD NOT call your useEffect(or any other hook) inside conditions, loops and nested functions.
Doc reference: https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level.
Consider moving your conditions code inside the hook.
useEffect(()=> {
if(condition)
{run your localstorage related logic here...}
})
Issue 2
I think you should consider adding a dependency array to your useEffect hook because getting your token on every rerender seems quite expensive.
useEffect(()=> {
if(condition)
{run your localstorage related logic here...}
},[])
Still, its just a suggestion, as I don't know your code in much depth.
Issue 3
The initial token is not getting set in the use effect.
Kindly add setToken(initialToken) in the useEffect after initial token assignment.
initialToken = localStorage.getItem('token');
setToken(initialToken);
The main issue is with you trying to run serverside code on the fronted:
useEffect(()=> {
if(typeof window !== 'undefined') {
console.log('You are on the browser');
initialToken = localStorage.getItem('token');
console.log("InitialToken set "+ initialToken);
} else {
initialToken = localStorage.getItem('token');
console.log('You are on the server and token is ' + initialToken);
}
},[AuthContext])
The above part of the code will always run on the front end(so you don't need the if part).
If you want to clear your concepts on what part of the code will work on the server and what part will run on the client, kindly refer to these documentations:
SSR: https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props
SSG: https://nextjs.org/docs/basic-features/data-fetching/get-static-props
ISR: https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration

Related

what is the way to change a state's value in concurrent tabs using reactjs

In my website, I implemented Authentication system using MongoDB. When I do successfully Log in to the website, a token is passed from server. As a result, current tab identify the log in process and is authenticated.
But the concurrently opened tabs are still logged out. In the case of log out same thing happens. (only the current tab is effected by the value of token from local storage.)
import jwt_decode from 'jwt-decode';
import { useEffect, useState } from 'react';
const API = 'http://localhost:5000/api/v1';
const useAuth= () => {
const [user, setUser] = useState({});
const [changeState, setChangeState] = useState(false);
useEffect(() => {
const jwt = JSON.parse(localStorage.getItem('jwt'));
// no jwt means no user logged in
if (jwt === null) {
setUser({});
setIsLoading(false);
return;
}
//decode jwt
const decoded = jwt_decode(jwt);
setUser({ ...decoded });
}, [changeState])
const authenticate = (token) => {
if (typeof window !== 'undefined') {
localStorage.setItem('jwt', JSON.stringify(token));
}
setChangeState(!changeState)
}
const login = (user) => {
//when successfully logged in, authenticate(token) is called
return fetch(`${API}/user/login`, {
method: 'post',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(user)
})
};
const logOut = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('jwt');
setUser({});
}
}
return {
user,
register,
login,
authenticate,
logOut,
setUser,
}
}
export default useAuth;
I want to change the value of user state in all the tabs at the same time. So all opened tabs can identify whether Log In or Log Out is done.

Next JS with magic login, trying to grab the router after being redirected from email login

I am using magic login in javascript and next js to add users to my app, it works just fine, but the problem I am having is. When a user lands back on the page I have to manually refresh the page to get my data. I have tried checking for the url param, and reloading if it exists then changing the changing the url to not have the url param so it doesn't loop.
But the router isn't even found after clicking the login button from the email sent from magic login.
import Head from "next/head";
import Image from "next/image";
import styles from "../styles/Home.module.css";
import { gql, useQuery } from "#apollo/client";
import useSWR from "swr";
import { useEffect } from "react";
import Layout from "../components/layout";
import { useRouter } from "next/router";
export const GET_DAD_HAT = gql`
query FindUserByID($id: ID!) {
findUserByID(id: $id) {
_id
hats {
data {
name
}
}
}
}
`;
const fetcher = (url) => fetch(url).then((r) => r.json());
function useAuth() {
const { data: user, error, mutate } = useSWR("/api/user", fetcher);
const loading = user?.token === false || user === undefined;
return {
user,
loading,
error,
};
}
export default function Profile() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
console.log("window", window);
console.log(Object.keys(router.query)[0]);
if (Object.keys(router.query)[0] === "magic_credentials") {
router.reload(window.location.pathname);
window.history.pushState({}, document.title, "/" + "profile");
}
if (loading) {
}
}, []);
return (
<Layout>
<main>{loading ? "Loading..." : <Data user={user} />}</main>
</Layout>
);
}
const Data = ({ user }) => {
const { loading, error, data } = useQuery(GET_DAD_HAT, {
variables: { id: user.id },
});
if (loading) return <h1>Loading...</h1>;
if (error) return <h1>{error.message}</h1>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
};
What happens is the data is just stuck on Loading until I manually hit refresh, after being redirected to the app from the email login flow.
UPDATE: I made a reproducible sandbox. https://omg5u.sse.codesandbox.io/login-magic
Enter your email, click the login link sent to email.
Feel free to use disposable email service like https://getnada.com/
if it's not blocked
When you arrive on profile page see that it is just loading, until you hit refresh then it should show your user id, and an empty array for data.
UPDATE: It looks like when I first land on the page the cookie lookup for fauna_client in the fetch to the user api route returns undefined. However after refreshing it returns the cookie. If I inspect the cookie is there before hitting refresh, but if I look in the terminal for next, the cookie is created after it is looked for. I think it has something to do with Magic redirect in the login api route creating the cookie after magic is logged in.
Still quite confused.
Had to do the following on a auth-verify page and a login-verify api route
useEffect(() => {
finishEmailRedirectLogin();
}, [router.query]);
const finishEmailRedirectLogin = () => {
if (router.query.magic_credential)
magicClient.auth
.loginWithCredential()
.then((didToken) => authenticateWithServer(didToken));
};
// Send token to server to validate
const authenticateWithServer = async (didToken) => {
let res = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + didToken,
},
});
if (res.status === 200) {
// Set the UserContext to the now logged in user
let userMetadata = await magicClient.user.getMetadata();
// await setUser(userMetadata);
router.push("/profile");
}
};
import { Magic } from "#magic-sdk/admin";
// Initiating Magic instance for server-side methods
const magic = new Magic(process.env.MAGIC_SECRET_KEY);
export default async function login(req, res) {
try {
const didToken = req.headers.authorization.substr(7);
await magic.token.validate(didToken);
res.status(200).json({ authenticated: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
}

React: Is it ok to check cookies for refresh token?

I have create a log in page, which has to enter username and password to authenticate. After the authentication, I push the history to the Homepage by using react-router and set a cookie. The code is like:
const handleLogin = () =>{
const options = {
url: "http://test/login",
method: "POST",
withCredentials: true,
headers:{
"Accept": "application/json",
"content-Type": "application/json;charset-UTF-8"
},
data: {
"username": user,
"password": pass,
}
};
Axios(options).then(Response =>{
if(Response.data.JWT){
let decode = jwt_decode(Response.data.JWT);
if(decode.id){
setLoginUser(decode.username);
setAuthenticated(true);
history.push('/Homepage');
Cookies.set("isLogged", {expires: 1} )
}
}else{
alert("Invalid user");
}
});
}
I also used JWT from back-end, here the application is fine. But when I refresh the web page, I check through the cookies for the refresh token to stay in the Homepage, and remove it for log out.
const readCookie = () => {
let user = false;
if(user = Cookies.get("isLogged")){
history.push('/Homepage');
setAuthenticated(true);
}else{
history.push('/');
}
}
useEffect(() => {
readCookie()
},[]);
Is it OK to get refresh token like this?
Your function, on the outside, should be a dependency of your useEffect. To avoid that, just move it inside the hook. Other than that, looks good to me. Reading a cookie is a side-effect, so you're doing it the right way.
useEffect(() => {
const readCookie = () => {
let user = false;
if(user = Cookies.get("isLogged")){
history.push('/Homepage');
setAuthenticated(true);
}else{
history.push('/');
}
}
readCookie()
},[]);
If you wanted to leave readCookies outside of the hook, you'll have to wrap it in a useCallback hook and use that in your dependency, like so:
const readCookiesCallback = useCallback(() => {/* your read cookies code */}, [])
useEffect(() => {
readCookiesCallback()
}, [readCookiesCallback])
For what you're doing, that's unnecessary. My first example is the way to go.

React Admin login by token from url

I'm trying to implement an SSO react-admin login
Most of the react-admin examples a is simple username/password login and get token then store at storage. But for me, token will return from Auth Provider server and redirect to http://example.com/#/login?token=TOKEN, and make react-admin to parse the URL, and set token in localStorage. Then update React admin store to mark a user as "is logged in,"
but for me I failed to simulate logged in as a hook after validating token from app main js can you help me how to do that
A workaround like below is works for me, but this is quite dirty solution:
const url = window.location;
(() => {
if (localStorage.getItem("access_token") === "null" || localStorage.getItem("access_token") === "undefined") {
localStorage.removeItem("access_token");
}
})();
const access_token = () => {
if (localStorage.getItem("access_token")) {
return localStorage.getItem("access_token")
} else if (new URLSearchParams(url.search).get("access_token")) {
return new URLSearchParams(url.search).get("access_token")
}
}
localStorage.setItem("access_token", access_token());
export const authProvider = {
login: () => {
return Promise.resolve();
},
logout: () => {
localStorage.removeItem("access_token");
return Promise.resolve();
},
checkError: (status) => {
if (status.response.status === 401 || status.response.status === 403) {
localStorage.removeItem("access_token");
return Promise.reject();
}
return Promise.resolve();
},
checkAuth: () => {
return localStorage.getItem("access_token") ? Promise.resolve() : Promise.reject();
},
getPermissions: () => Promise.resolve(),
};
There is no login method call at all, just direct setting token to the localStorage and check with checkAuth method. Maybe, sombody will give better solution.

How can I get my firebase listener to load data to my redux state in a React Native app so I can read the data within my ComponentDidMount function?

I am trying to load a notification token (notificationToken) that I've stored within Firebase to a React Native component.
Once the notificationToken is loaded to my redux state, I want to check for my device permissions to see if the notificationToken has expired within the function getExistingPermission() that I run in the componentDidMount().
If the token has expired, then I'll replace the token within Firebase with the new token. If it's the same, then nothing happens (which is intended functionality).
When I'm running my function getExistingPermission() to check if the token is up-to-date the Firebase listener that pulls the notificationToken does not load in time, and so it's always doing a write to the Firebase database with a 'new' token.
I'm pretty sure using async/await would solve for this, but have not been able to get it to work. Any idea how I can ensure that the notificationToken loads from firebase to my redux state first before I run any functions within my componentDidMount() function? Code below - thank you!
src/screens/Dashboard.js
Should I use a .then() or async/await operator to ensure the notificationToken loads prior to running it through the getExistingPermission() function?
import {
getExistingPermission
} from '../components/Notifications/NotificationFunctions';
componentDidMount = async () => {
// Listener that loads the user, reminders, contacts, and notification data
this.unsubscribeCurrentUserListener = currentUserListener((snapshot) => {
try {
this.props.watchUserData();
} catch (e) {
this.setState({ error: e, });
}
});
if (
!getExistingPermission(
this.props.notificationToken, //this doesn't load in time
this.props.user.uid)
) {
this.setState({ showNotificationsModal: true });
}
};
src/components/Notifications/NotificationFunctions.js
The problem is probably not here
export const getExistingPermission = async (
notificationToken,
uid,
) => {
const { status: existingStatus } = await Permissions.askAsync(
Permissions.NOTIFICATIONS
);
if (existingStatus !== 'granted') {
console.log('status not granted');
return false;
} else {
let token = await Notifications.getExpoPushTokenAsync();
/* compare to the firebase token; if it's the same, do nothing,
if it's different, replace */
if (token === notificationToken) {
console.log('existing token loaded');
return true;
} else {
console.log('token: ' + token);
console.log('notificationToken: ' + notificationToken);
console.log('token is not loading, re-writing token to firebase');
writeNotificationToken(uid, token);
return false;
}
}
};
src/actions/actions.js
// Permissions stuff
watchPermissions = (uid) => (
(dispatch) => {
getPermissions(uid + '/notificationToken', (snapshot) => {
try {
dispatch(loadNotificationToken(Object.values([snapshot.val()])[0]));
}
catch (error) {
dispatch(loadNotificationToken(''));
// I could call a modal here so this can be raised at any point of the flow
}
});
}
);
// User Stuff
export const watchUserData = () => (
(dispatch) => {
currentUserListener((user) => {
if (user !== null) {
console.log('from action creator: ' + user.displayName);
dispatch(loadUser(user));
dispatch(watchReminderData(user.uid)); //listener to pull reminder data
dispatch(watchContactData(user.uid)); //listener to pull contact data
dispatch(watchPermissions(user.uid)); //listener to pull notificationToken
} else {
console.log('from action creator: ' + user);
dispatch(removeUser(user));
dispatch(logOutUser(false));
dispatch(NavigationActions.navigate({ routeName: 'Login' }));
}
});
}
);
export const loadNotificationToken = (notificationToken) => (
{
type: 'LOAD_NOTIFICATION_TOKEN',
notificationToken,
}
);
Tony gave me the answer. Needed to move the permissions check to componentDidUpdate(). For those having a similar issue, the component looks like this:
src/screens/Dashboard.js
componentDidUpdate = (prevProps) => {
if (!prevProps.notificationToken && this.props.notificationToken) {
if (!getExistingPermission(
this.props.notificationToken,
this.props.user.uid
)) {
this.setState({ showNotificationsModal: true });
}
}
};
Take a look at redux subscribers for this: https://redux.js.org/api-reference/store#subscribe . I implement a subscriber to manage a small state machine like STATE1_DO_THIS, STATE2_THEN_DO_THAT and store that state in redux and use it to render your component. Only the subscriber should change those states. That gives you a nice way to handle tricky flows where you want to wait on action1 finishing before doing action2. Does this help?

Categories