Search code examples
reactjsreact-router-domredux-toolkit

Although the identity is authenticated, it does not navigate to the requested page


After pressing the login button and authenticating, I want the application to direct me to the desired page. But either it does not redirect when I press the button, or even if I enter the wrong password, it goes to the desired page and comes back. because it hasn't been authenticated yet. I couldn't find the right way.

When authenticated, I want to go to the page with the resource list. The path of this page is "/".

I'm sharing a few pieces of code to give you an idea. App:

const Layout = ({ children }) => {
  return (
    <div className="link">
      <MiniDrawer>
        <Outlet />
      </MiniDrawer>
    </div>
  );
};

const router = createBrowserRouter([
  {
    path: "/login",
    element: <Login />,
  },

  {
    path: "/",
    element: (
      <AdminGuard>
        <Layout />
      </AdminGuard>
    ),
    children: [
      {
        path: "/",
        element: <Resources />,
      },
      {
        path: "/create",
        element: <CreateResource />,
      },
      {
        path: "/edit/:id",
        element: <EditResource />,
      },
    ],
  },
]);

function App({ Component }) {
  return (
    <div className="App">
      <RouterProvider router={router} />
    </div>
  );
}

export default App;

Guard:

import React from "react";
import { Route, Navigate, Outlet } from "react-router-dom";
import { AuthService } from "../services/auth.services";

const AdminGuard = ({ children }) => {
  if (AuthService.isAuthenticated()) {
    return children;
  } else {
    return <Navigate to="/login" />;
  }
};

export default AdminGuard;

Login Page:

import { useDispatch } from "react-redux";
import LoginView from "./view";
import { AuthService } from "../../services/auth.services";

function Login() {
  const dispatch = useDispatch();
  function onSubmit({ username, password }) {
    dispatch(AuthService.userLogin({ username, password }));
  }

  return (
    <>
      <LoginView onSubmit={onSubmit} />
    </>
  );
}

export default Login;

Auth Service:

import { createAsyncThunk } from "@reduxjs/toolkit";
import { axiosApiInstance } from "./axios.services";
import { TokenService } from "./token.service";

export const AuthService = {};
AuthService.userLogin = createAsyncThunk(
  "userSlice/userLogin",
  async ({ username, password }, { rejectWithValue }) => {
    try {
      const response = await axiosApiInstance.post(
        "https://localhost:7105/api/Connect",
        { username, password },
        {
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            username: typeof username === "string" ? username.toString() : "",
            password: typeof password === "string" ? password.toString() : "",
          }),
        }
      );

      const tokenResponse = { accessToken: response?.data };
      TokenService.setToken(tokenResponse);

      return tokenResponse;
    } catch (error) {
      return rejectWithValue(error);
    }
  }
);

AuthService.isAuthenticated = () => {
  return localStorage.getItem("access_token") ? true : false;
};
AuthService.logout = () => {
  TokenService.clearToken();
  window.location.href = "/login";
};

Token Service:

export const TokenService = {};

TokenService.setToken = ({ accessToken }) => {
  localStorage.setItem("access_token", accessToken);
};

TokenService.clearToken = () => {
  localStorage.removeItem("access_token");
};

Auth Slice:

import { useNavigate } from "react-router-dom";
import { AuthService } from "../../services/auth.services";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

const initialState = {
  data: {},
  isLoading: false,
  hasError: false,
};

const authSlice = createSlice({
  name: "authSlice",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(AuthService.userLogin.pending, (state) => {
        state.isLoading = true;
        state.hasError = false;
      })
      .addCase(AuthService.userLogin.fulfilled, (state, { payload }) => {
        state.pending = false;
        state.data = payload;
      })
      .addCase(AuthService.userLogin.rejected, (state) => {
        state.isLoading = false;
        state.hasError = true;
      });
  },
});

export const selectAuthState = (state) => state.authSlice.data;
export default authSlice.reducer;

Solution

  • I don't see anything that redirects a user after they authenticate. If I am understanding the issue correctly you need to update the code to handle redirecting to the protected route after they successfully authenticate.

    For this I suggest a few changes.

    Update the auth check to be stateful instead of relying on reading from localStorage. Updates to localStorage don't trigger React components to rerender so auth checks in the AdminGuard are likely to be stale. Since the state.authSlice.data state is the token value the UI should reference this value, or specifically state.authSlice.data.accessToken.

    import React from "react";
    import { Navigate, Outlet, useLocation } from "react-router-dom";
    import { useSelector } from 'react-redux';
    import { selectAuthState } from "../slices/auth.slice";
    
    const AdminGuard = () => {
      // Capture the current location for auth redirect
      const location = useLocation();
    
      // Access authentication state
      const { accessToken } = useSelector(selectAuthState);
    
      // Wait until any initial/pending auth checks complete
      if (accessToken === undefined) {
        return null; // or loading indicator/spinner/etc
      }
    
      // Return Outlet for nested routes if authenticated
      // Return redirect with current location in state
      return accessToken
        ? <Outlet />
        : <Navigate to="/login" redirect state={{ from: location }} />;
    };
    
    export default AdminGuard;
    

    Auth service

    Update the logout function to also be a thunk. This is so you can add reducer cases to handle resetting/clearing the state.data value when a user logs out.

    import { createAsyncThunk } from "@reduxjs/toolkit";
    import { axiosApiInstance } from "./axios.services";
    import { TokenService } from "./token.service";
    
    export const AuthService = {};
    
    AuthService.userLogin = createAsyncThunk(
      "userSlice/userLogin",
      async ({ username, password }, { rejectWithValue }) => {
        try {
          const { data } = await axiosApiInstance.post(
            "https://localhost:7105/api/Connect",
            { username, password },
            {
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                username: typeof username === "string" ? username.toString() : "",
                password: typeof password === "string" ? password.toString() : "",
              }),
            }
          );
    
          const tokenResponse = { accessToken: data };
          TokenService.setToken(tokenResponse);
    
          return tokenResponse;
        } catch (error) {
          return rejectWithValue(error);
        }
      }
    );
    
    AuthService.userLogout = createAsyncThunk(
      "userSlice/userLogin",
      () => {
        TokenService.clearToken();
      }
    );
    
    const authSlice = createSlice({
      name: "authSlice",
      initialState,
      reducers: {},
      extraReducers: (builder) => {
        builder
          .addCase(AuthService.userLogin.pending, (state) => {
            state.isLoading = true;
            state.hasError = false;
          })
          .addCase(AuthService.userLogin.fulfilled, (state, action) => {
            state.pending = false;
            state.data = action.payload;
          })
          .addCase(AuthService.userLogin.rejected, (state) => {
            state.isLoading = false;
            state.hasError = true;
          })
          .addCase(AuthService.userLogout.pending, (state) => {
            state.isLoading = true;
            state.hasError = false;
          })
          .addCase(AuthService.userLogout.fulfilled, (state) => {
            state.pending = false;
            state.data = {}; // <-- reset on logout
          })
          .addCase(AuthService.userLogout.rejected, (state) => {
            state.isLoading = false;
            state.hasError = true;
          });
      },
    });
    

    App

    Update App to render the AdminGuard as a layout route component.

    const router = createBrowserRouter([
      {
        path: "/login",
        element: <Login />,
      },
      {
        element: <Layout />,
        children: [
          {
            element: <AdminGuard />,
            children: [
              {
                path: "/",
                element: <Resources />,
              },
              {
                path: "/create",
                element: <CreateResource />,
              },
              {
                path: "/edit/:id",
                element: <EditResource />,
              },
            ],
          },
        ],
      },
    ]);
    
    function App({ Component }) {
      return (
        <div className="App">
          <RouterProvider router={router} />
        </div>
      );
    }
    

    Login

    Update the Login component's submit handler to wait for the login action to resolve and then issue an imperative redirect back to the route path the user was originally attempting to access.

    import { useLocation, useNavigate } from 'react-router-dom';
    import { useDispatch } from "react-redux";
    import LoginView from "./view";
    import { AuthService } from "../../services/auth.services";
    
    function Login() {
      const dispatch = useDispatch();
      const { state } = useLocation();
      const navigate = useNavigate();
    
      onSubmit = async ({ username, password }) => {
        try {
          // Wait for login action to complete, e.g. resolve
          await dispatch(AuthService.userLogin({ username, password })).unwrap();
    
          // Redirect to original target, or safe non-protected route
          const { from } = state || { from: /* safe fallback that isn't a protected route */ };
          navigate(from, { replace: true });
        } catch(error) {
          // handle errors?
        }
      }
    
      return (
        <>
          <LoginView onSubmit={onSubmit} />
        </>
      );
    }
    
    export default Login;
    

    Similarly now the "logout" function is the same, dispatch the AuthService.userLogout action and redirect after.

    const dispatch = useDispatch();
    const navigate = useNavigate();
    
    const logout = async () => {
      try {
        await dispatch(AuthService.userLogout()).unwrap();
        navigate("/login", { replace: true });
      } catch(error) {
        // handle errors?
      }
    };