Supercharging React: Seamless Authentication with Microsoft Entra ID
In my last article, I covered the end-to-end configuration and setup of an app registration using Microsoft Entra as our Identity Provider. We all agree that making this setup all along doesn’t mean much if we don’t use it on our application.
In this article, we will develop a React application integrated with Microsoft Entra and apply the configurations from the previous article.
Disclaimer
This article or sample code is not intended to be production-ready but to highlight some snippets for integrating our code and setting the Entra configuration. I am not a React or frontend developer, so some practices might not be the best, and there may be other better ways to achieve this. I am counting on you to help me keep the code as close to best practices as possible.
Create a scaffold React App.
For many years, Create React App was the standard for creating new applications. However, this package has become deprecated and has numerous vulnerabilities, so we will not use it. Instead, we are using Vite to create the “react-ts-entra” application.
$ npm create vite@latest react-ts-entra -- --template react-ts
Follow the instructions, as we are creating a React app with Typescript + SWC. They are pretty straightforward.
Change the working directory:
$ cd react-ts-entra
Now we are going to add the libraries. Since we are using Material UI components, we will also add these libraries.
Material UI libraries
$ npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
$ npm i --save-dev @types/node
Microsoft Entra libraries
$ npm install @azure/msal-react @azure/msal-browser react-router-dom
Install package.json
$ npm install
Environment Variables
To ensure our application works properly, we need to create a .env file at the root of the solution, containing the values from the app registration that was created:
VITE_APP_CLIENT_ID=
VITE_APP_TENANT_ID=
VITE_APP_REDIRECT_URI=
In the app registration, we have the redirect URI set to http://localhost:8000/auth, so we need to ensure that our React app launches on port 8000. We also need to update the vite.config.ts file.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [react()],
server: {
port: 8000,
},
});
By now, we are ready to develop our application.
Microsoft Entra Integration
For our application, we will create a self-contained feature. After that, we will use it in our application.
The application is not complex; it is just a site with two toolbars and a set of pages, one for each role: authenticated and unauthorized.
Feature features/auth
On this feature, we will have 3 folders:
- components: with a top bar with a profile button.
- config: with the configurations needed.
- pages: with the several pages.
features/auth/config
Let us start with the authConfig.ts.
// URL utility
const URLUtil = {
getCleanOrigin: (): string => {
const origin = window.location.origin;
return origin.endsWith('/') ? origin.slice(0, -1) : origin;
}
};
interface MsalConfig {
clientId: string;
tenantId: string;
redirectUri: string;
postLogoutRedirectUri: string;
}
const config: MsalConfig = {
clientId: import.meta.env.VITE_APP_CLIENT_ID || "",
tenantId: import.meta.env.VITE_APP_TENANT_ID || "",
redirectUri: import.meta.env.VITE_APP_REDIRECT_URI || URLUtil.getCleanOrigin()+"/auth",
postLogoutRedirectUri: import.meta.env.VITE_APP_POST_LOGOUT_REDIRECT_URI || URLUtil.getCleanOrigin(),
};
export const msalConfig = {
auth: {
clientId: config.clientId || "",
authority: `https://login.microsoftonline.com/${config.tenantId}`,
redirectUri: config.redirectUri,
postLogoutRedirectUri: config.postLogoutRedirectUri,
},
};
export const loginRequest = {
scopes: ["User.Read"],
};
This code will map the environment variables to the code variables, to simplify the usage.
We need to map the roles, with roles.ts. As you may remember we configured app_user, and app_admin in our app register, and our user only has these two roles.
export enum Roles {
APP_USER = 'app_user',
APP_CONTRIBUTER = 'app_contributer',
APP_ADMIN = 'app_admin',
}
For last, we are configuring useAuth.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
// Define the shape of the authentication context
interface AuthContextType {
user: any; // User object, type 'any' for flexibility
login: (userData: any) => void; // Function to log in a user
logout: () => void; // Function to log out a user
isAuthenticated: boolean; // Flag indicating if a user is authenticated
}// Create a context for authentication with undefined as initial value
const AuthContext = createContext<AuthContextType | undefined>(undefined);// AuthProvider component to wrap the app and provide authentication context
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// State to hold the current user
const [user, setUser] = useState<any>(null); useEffect(() => {
// Check for existing user session on component mount
const storedUser = localStorage.getItem('user');
if (storedUser) {
// If a user is found in localStorage, set it as the current user
setUser(JSON.parse(storedUser));
}
}, []); // Empty dependency array ensures this runs only once on mount // Function to log in a user
const login = (userData: any) => {
setUser(userData); // Set the user in state
localStorage.setItem('user', JSON.stringify(userData)); // Store user in localStorage
}; // Function to log out a user
const logout = () => {
setUser(null); // Clear the user from state
localStorage.removeItem('user'); // Remove user from localStorage
}; // Create the context value object
const value = {
user,
login,
logout,
isAuthenticated: !!user // Convert user to boolean (true if user exists, false otherwise)
}; // Provide the authentication context to children components
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};// Custom hook to use the auth context
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
// Throw an error if useAuth is used outside of an AuthProvider
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
With this component, we can perform login and logout actions and gather information about the user.
Remark:
This is all you need to configure and integrate Microsoft Entra into a React application. From this point forward, several approaches for using the attributes from the authentication will be presented.
features/auth/components
TopBar.tsx
This top bar will be the blue bar where we have the “My App” title on the left and the “login” or profile on the right, depending on whether the user is logged in or not. The profile picture comes from a Microsoft Graph call. We need the User.Read scope defined to access this property.
import React, { useEffect, useState } from "react";
import {
AppBar,
Toolbar,
Avatar,
Typography,
Menu,
MenuItem,
Divider,
CircularProgress,
Box,
ListItemIcon,
ListItemText,
Button,
} from "@mui/material";
import { useMsal } from "@azure/msal-react";
import { loginRequest } from "../config/authConfig";
import LogoutIcon from "@mui/icons-material/Logout";
import PersonIcon from "@mui/icons-material/Person";
const TopBar: React.FC = () => {
const { instance, accounts } = useMsal();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [profilePicture, setProfilePicture] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const isAuthenticated = accounts.length > 0;
const user = isAuthenticated ? accounts[0] : null; useEffect(() => {
const fetchProfilePhoto = async () => {
if (!user) return; // Check local storage for the profile picture
const storedProfilePicture = localStorage.getItem("profilePicture");
if (storedProfilePicture) {
setProfilePicture(storedProfilePicture);
setLoading(false);
return;
} const graphRequest = {
scopes: ["User.Read"],
account: user,
}; try {
const response = await instance.acquireTokenSilent(graphRequest);
const photoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me/photo/$value",
{
headers: {
Authorization: `Bearer ${response.accessToken}`,
},
}
); if (photoResponse.ok) {
const blob = await photoResponse.blob();
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
setProfilePicture(base64data);
localStorage.setItem("profilePicture", base64data); // Store Base64 in local storage
setLoading(false);
};
reader.readAsDataURL(blob);
} else {
console.error("Failed to fetch profile photo:", photoResponse.statusText);
setProfilePicture(null);
setLoading(false);
}
} catch (error) {
console.error("Error fetching profile photo:", error);
setProfilePicture(null);
setLoading(false);
}
}; if (isAuthenticated) {
fetchProfilePhoto();
}
}, [instance, user, isAuthenticated]); const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
}; const handleMenuClose = () => {
setAnchorEl(null);
}; const handleLogout = async () => {
try {
localStorage.removeItem("profilePicture"); // Clear stored photo on logout
await instance.logoutRedirect({
postLogoutRedirectUri: window.location.origin, // Redirects to the homepage after logout
});
} catch (error) {
console.error("Error during logout:", error);
} finally {
handleMenuClose();
}
}; const renderAvatar = () => {
if (loading) {
return (
<Box
sx={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={24} />
</Box>
);
} return (
<Avatar
alt={user?.name || "User"}
src={profilePicture || undefined}
sx={{ cursor: "pointer", width: 40, height: 40 }}
onClick={handleMenuOpen}
/>
);
}; return (
<AppBar position="static" sx={{ backgroundColor: "#1976d2" }}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
My App
</Typography>
{isAuthenticated ? (
<>
{renderAvatar()}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
elevation: 3,
sx: {
mt: 1.5,
width: 230,
},
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<Box
sx={{
px: 2,
py: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar
alt={user?.name || "User"}
src={profilePicture || undefined}
sx={{ width: 56, height: 56, mb: 1 }}
/>
<Typography variant="subtitle1" fontWeight="bold">
{user?.name || "User"}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.username || "email@example.com"}
</Typography>
</Box>
<Divider />
<MenuItem onClick={handleMenuClose}>
<ListItemIcon>
<PersonIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Profile" />
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Logout" />
</MenuItem>
</Menu>
</>
) : (
<Button
color="inherit"
onClick={() =>
instance.loginRedirect(loginRequest).catch(console.error)
}
>
Login
</Button>
)}
</Toolbar>
</AppBar>
);
};export default TopBar;
To finish the feature we need to create some pages.
features/auth/pages
Our authentication integration feature includes the following pages:
- HomePage: The application landing page. (/)
- LoginPage: The page where the user can click to log in. (/login)
- LogoutPage: The page where the user can click to log out. (/logout)
- NotAuthorizedPage: The page displayed if the user is authenticated but not authorized.
- PrivatePage: The page shown to the user who is authenticated and authorized.
HomePage.tsx
import React from 'react';
import { Container, Typography, Button, Box } from "@mui/material";
import { useMsal } from "@azure/msal-react";
import { loginRequest, msalConfig } from "../config/authConfig";
const HomePage: React.FC = () => {
const { instance, accounts } = useMsal(); const isAuthenticated = accounts.length > 0;
const userName = isAuthenticated ? accounts[0].name : '';
const userClaims = isAuthenticated ? accounts[0].idTokenClaims : {};
const userRoles = userClaims?.roles || []; const handleAuthClick = () => {
if (isAuthenticated) {
instance.logoutRedirect({
postLogoutRedirectUri: msalConfig.auth.postLogoutRedirectUri
}).catch(e => {
console.error(e);
});
} else {
instance.loginRedirect(loginRequest).catch(e => {
console.error(e);
});
}
}; return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Typography variant="h3" gutterBottom>
Welcome to the Fun Zone! 🎉
</Typography>
<Typography variant="h6" gutterBottom>
Life's too short for boring pages. Jump in and explore!
</Typography>
{isAuthenticated && (
<>
<Typography variant="subtitle1" gutterBottom>
Hello, {userName}!
</Typography>
<Box sx={{ mt: 2 }}>
<Typography variant="h6">Your Roles:</Typography>
{userRoles.length > 0 ? (
<ul>
{userRoles.map((role, index) => (
<li key={index}>
<Typography variant="body1">{role}</Typography>
</li>
))}
</ul>
) : (
<Typography variant="body1">No roles assigned.</Typography>
)}
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="h6">Your Claims:</Typography>
<pre style={{ textAlign: 'left' }}>
{JSON.stringify(userClaims, null, 2)}
</pre>
</Box>
</>
)}
<Button
variant="contained"
color="primary"
size="large"
onClick={handleAuthClick}
>
{isAuthenticated ? 'Logout' : 'Login for Fun'}
</Button>
</Container>
);
};export default HomePage;
LoginPage.tsx
import React from 'react';
import { Box, Typography, Button } from "@mui/material";
import { useMsal } from "@azure/msal-react";
const Login: React.FC = () => {
const { instance, accounts } = useMsal(); const handleLogin = () => {
instance.loginRedirect();
}; const isAuthenticated = accounts.length > 0;
const userName = isAuthenticated ? accounts[0].name : ''; return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100vh",
textAlign: "center",
padding: 3,
}}
>
<Typography variant="h2" gutterBottom>
Let the Fun Begin 🎈
</Typography>
<Typography variant="body1" gutterBottom>
Sign in to access the most exciting dashboard ever!
</Typography>
{isAuthenticated ? (
<>
<Typography variant="h5" gutterBottom>
Welcome back, {userName}! 🎉
</Typography>
<Typography variant="body1" gutterBottom>
Why did the scarecrow win an award? Because he was outstanding in his field! 😂
</Typography>
</>
) : (
<Button
variant="contained"
color="secondary"
onClick={handleLogin}
sx={{ marginTop: 2 }}
>
Login with Microsoft 🚀
</Button>
)}
</Box>
);
};export default Login;
LogoutPage.tsx
import React from 'react';
import { Container, Typography, Button } from "@mui/material";
import { useMsal } from "@azure/msal-react";
const LogoutPage: React.FC = () => {
const { instance, accounts } = useMsal();
const isAuthenticated = accounts.length > 0; const handleLogout = () => {
instance.logoutRedirect({
postLogoutRedirectUri: "/",
}).catch(e => {
console.error(e);
});
}; return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
{isAuthenticated ? (
<>
<Typography variant="h4" gutterBottom>
You are currently logged in.
</Typography>
<Button
variant="contained"
color="primary"
onClick={handleLogout}
sx={{ marginTop: 2 }}
>
Logout
</Button>
</>
) : (
<Typography variant="h4" gutterBottom>
You are already logged out.
</Typography>
)}
</Container>
);
};export default LogoutPage;
NotAuthorizedPage.tsx
import React from 'react';
import { Container, Typography, Button, Box } from "@mui/material";
import LockIcon from "@mui/icons-material/Lock";
import { useMsal } from "@azure/msal-react";
const NotAuthorizedPage: React.FC = () => {
const { instance, accounts } = useMsal();
const isAuthenticated = accounts.length > 0; const handleLogin = () => {
instance.loginRedirect();
}; const handleLogout = () => {
instance.logoutRedirect({
postLogoutRedirectUri: "/",
}).catch(e => {
console.error(e);
});
}; const goToHome = () => {
window.location.href = "/";
}; return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<LockIcon sx={{ fontSize: 80, color: "#f44336", mb: 2 }} />
<Typography variant="h4" gutterBottom>
Access Denied! 🚫
</Typography>
<Typography variant="h6" gutterBottom>
Oops, you don't have the proper authorization to access this page.
</Typography>
<Typography variant="body1" gutterBottom>
Please log in with the right credentials or contact your administrator.
</Typography>
<Box sx={{ mt: 2 }}>
{!isAuthenticated ? (
<>
<Button
variant="contained"
color="primary"
sx={{ mr: 1 }}
onClick={handleLogin}
>
Go to Login
</Button>
</>
) : (
<>
<Button
variant="outlined"
color="secondary"
sx={{ mr: 1 }}
onClick={handleLogout}
>
Logout
</Button>
</>
)}
<Button variant="outlined" color="secondary" onClick={goToHome}>
Go to Home
</Button>
</Box>
</Container>
);
};export default NotAuthorizedPage;
PrivatePage.tsx
import { Container, Typography, Button, Grid, Paper } from "@mui/material";
import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
const PrivatePage = () => {
return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
backgroundColor: "#b3e5fc",
padding: 4,
}}
>
<RocketLaunchIcon sx={{ fontSize: 80, color: "#2196f3", mb: 2 }} />
<Typography variant="h4" gutterBottom>
Welcome to the Secret Dashboard! 🚀
</Typography>
<Typography variant="h6" gutterBottom>
You've unlocked the super-awesome private zone.
</Typography>
<Grid container spacing={3} sx={{ mt: 4 }}>
<Grid item xs={12} sm={6}>
<Paper elevation={3} sx={{ padding: 2 }}>
<Typography variant="h6">Private Metric #1</Typography>
<Typography variant="body1">Data: 42 🌟</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={6}>
<Paper elevation={3} sx={{ padding: 2 }}>
<Typography variant="h6">Private Metric #2</Typography>
<Typography variant="body1">Data: 73 🚀</Typography>
</Paper>
</Grid>
</Grid>
<Button
variant="contained"
color="secondary"
size="large"
sx={{ mt: 4 }}
onClick={() => alert("You're already in the private zone!")}
>
Stay Awesome
</Button>
</Container>
);
};export default PrivatePage;
Composing our Application
Now we will compose our demo application, and we will start by creating a theme to simplify customization.
src/themes/theme.ts
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#0066cc',
},
secondary: {
main: '#ff6600',
},
background: {
default: '#ffffff',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 700,
},
h2: {
fontSize: '2rem',
fontWeight: 600,
},
body1: {
fontSize: '1rem',
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: '25px',
textTransform: 'none',
fontWeight: 600,
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '10px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
},
},
},
},
});export default theme;const lightTheme = createTheme({
palette: {
mode: "light",
primary: { main: "#1976d2" },
secondary: { main: "#dc004e" },
background: {
default: "#f5f5f5",
paper: "#ffffff",
},
text: {
primary: "#000000",
secondary: "#555555",
},
},
typography: {
fontFamily: "'Roboto', 'Arial', sans-serif",
h1: { fontSize: "2.5rem", fontWeight: 700 },
h4: { fontSize: "1.5rem", fontWeight: 600 },
body1: { fontSize: "1rem", lineHeight: 1.5 },
},
});const darkTheme = createTheme({
palette: {
mode: "dark",
primary: { main: "#90caf9" },
secondary: { main: "#f48fb1" },
background: {
default: "#121212",
paper: "#1e1e1e",
},
text: {
primary: "#ffffff",
secondary: "#bbbbbb",
},
},
typography: {
fontFamily: "'Roboto', 'Arial', sans-serif",
h1: { fontSize: "2.5rem", fontWeight: 700 },
h4: { fontSize: "1.5rem", fontWeight: 600 },
body1: { fontSize: "1rem", lineHeight: 1.5 },
},
});export { lightTheme, darkTheme };
src/themes/ThemeProvider.tsx
import React, { useState, createContext, useContext } from "react";
import { ThemeProvider as MUIThemeProvider, createTheme, CssBaseline } from "@mui/material";
interface ThemeContextProps {
toggleDarkMode: () => void;
}const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);export const useThemeContext = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error("useThemeContext must be used within ThemeProvider");
return context;
};export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [darkMode, setDarkMode] = useState(false); const theme = createTheme({
palette: {
mode: darkMode ? "dark" : "light",
primary: { main: "#1976d2" },
secondary: { main: "#dc004e" },
},
}); const toggleDarkMode = () => setDarkMode((prev) => !prev); return (
<ThemeContext.Provider value={{ toggleDarkMode }}>
<MUIThemeProvider theme={theme}>
<CssBaseline />
{children}
</MUIThemeProvider>
</ThemeContext.Provider>
);
};
src/main.tsx
Adding our providers.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import { MsalProvider } from '@azure/msal-react';
import { PublicClientApplication } from '@azure/msal-browser';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './themes/theme';
import App from './App';
import { msalConfig } from './features/auth/config/authConfig';
const msalInstance = new PublicClientApplication(msalConfig);ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<App />
</Router>
</ThemeProvider>
</MsalProvider>
</React.StrictMode>
);
src/App.tsx
Composing our UI
import React from 'react';
import AppRoutes from './routes/Routes';
import TopBar from './features/auth/components/TopBar';
import { AuthProvider } from './features/auth/config/useAuth';
import MyTopbar from './components/MyTopbar';
const App: React.FC = () => (
<>
<AuthProvider>
<TopBar />
<MyTopbar/>
<AppRoutes />
</AuthProvider>
</>
);export default App;
src/components/MyTopbar.tsx
import React from 'react';
import { AppBar, Toolbar, Button, Box } from '@mui/material';
import { useMsal } from "@azure/msal-react";
import { useNavigate } from 'react-router-dom';
const MyTopbar: React.FC = () => {
const { instance, accounts } = useMsal();
const navigate = useNavigate(); const isAuthenticated = accounts.length > 0; const handleLogout = () => {
instance.logoutRedirect({
postLogoutRedirectUri: "/",
}).catch(e => {
console.error(e);
});
}; return (
<>
{isAuthenticated && (
<AppBar position="static" color="secondary">
<Toolbar>
<Box sx={{ display: 'flex', flexGrow: 1 }}>
<Button color="inherit" onClick={() => navigate('/')}>
Home
</Button>
<Button color="inherit" onClick={() => navigate('/login')}>
Login
</Button>
<Button color="inherit" onClick={() => navigate('/logout')}>
Logout
</Button>
<Button color="inherit" onClick={() => navigate('/unauthorized')}>
Unauthorized
</Button>
<Button color="inherit" onClick={() => navigate('/private-user')}>
Private as User
</Button>
<Button color="inherit" onClick={() => navigate('/private-admin')}>
Private as Admin
</Button>
<Button color="inherit" onClick={() => navigate('/private-contributer')}>
Private as Contributor
</Button>
</Box>
</Toolbar>
</AppBar>
)}
</>
);
};export default MyTopbar;
src/routes/Router.tsx
Notice the role based access.
import React from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { AccountInfo } from "@azure/msal-browser";
import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from "@azure/msal-react";
import { Roles } from "../features/auth/config/roles";
import HomePage from "../features/auth/pages/HomePage";
import LoginPage from "../features/auth/pages/LoginPage";
import LogoutPage from "../features/auth/pages/LogoutPage";
import NotAuthorizedPage from "../features/auth/pages/NotAuthorizedPage";
import PrivatePage from "../features/auth/pages/PrivatePage";
import PrivateContributerPage from "../pages/PrivateContributerPage";
import PrivateAdminPage from "../pages/PrivateAdminPage";
import PrivateUserPage from "../pages/PrivateUserPage";const hasRequiredRole = (account: AccountInfo | null, requiredRole: string): boolean => {
if (!account || !account.idTokenClaims) return false; const roles = (account.idTokenClaims as { roles?: string[] }).roles;
return Array.isArray(roles) && roles.includes(requiredRole);
};interface PrivateRouteProps {
children: React.ReactNode;
requiredRole: string;
}const PrivateRoute: React.FC<PrivateRouteProps> = ({ children, requiredRole }) => {
const { accounts } = useMsal();
const account = accounts[0]; if (!account) {
return <Navigate to="/login" />;
} if (!hasRequiredRole(account, requiredRole)) {
return <Navigate to="/unauthorized" />;
} return <>{children}</>;
};interface AuthenticatedRouteProps {
element: React.ReactNode;
requiredRole: string;
}const AuthenticatedRoute: React.FC<AuthenticatedRouteProps> = ({ element, requiredRole }) => (
<>
<AuthenticatedTemplate>
<PrivateRoute requiredRole={requiredRole}>{element}</PrivateRoute>
</AuthenticatedTemplate>
<UnauthenticatedTemplate>
<LoginPage />
</UnauthenticatedTemplate>
</>
);const AppRoutes: React.FC = () => (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/unauthorized" element={<NotAuthorizedPage />} />
<Route
path="/private"
element={<AuthenticatedRoute requiredRole={Roles.APP_USER} element={<PrivatePage />} />}
/>
<Route
path="/private-user"
element={<AuthenticatedRoute requiredRole={Roles.APP_USER} element={<PrivateUserPage />} />}
/>
<Route
path="/private-admin"
element={<AuthenticatedRoute requiredRole={Roles.APP_ADMIN} element={<PrivateAdminPage />} />}
/>
<Route
path="/private-contributer"
element={
<AuthenticatedRoute requiredRole={Roles.APP_CONTRIBUTER} element={<PrivateContributerPage />} />
}
/>
</Routes>
);export default AppRoutes;
src/pages/PrivateAdminPage.tsx
import React from 'react';
import { Container, Typography, Button, Grid, Paper } from "@mui/material";
import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
import { useMsal } from "@azure/msal-react";
import { Roles } from '../features/auth/config/roles';
// Updated metrics data with more varied content
const metricsData = [
{
title: "User Engagement Rate",
data: Math.floor(Math.random() * 100), // Random data between 0 and 99
description: "This reflects how engaged users are with your content."
},
{
title: "Monthly Active Users",
data: Math.floor(Math.random() * 500), // Random data between 0 and 499
description: "The number of unique users who interacted with your platform this month."
},
{
title: "Customer Satisfaction Index",
data: Math.floor(Math.random() * 100),
description: "An index based on user feedback and surveys."
},
{
title: "Task Completion Rate",
data: Math.floor(Math.random() * 100),
description: "Percentage of tasks completed successfully by users."
},
{
title: "Average Session Duration",
data: Math.floor(Math.random() * 60) + 1, // Random data between 1 and 60 minutes
description: "The average time users spend on your platform per session."
},
];const PrivateAdminPage: React.FC = () => {
const { accounts } = useMsal();
const isAuthenticated = accounts.length > 0; // Assuming roles are stored in account claims or a similar structure
const userRoles = isAuthenticated ? accounts[0].idTokenClaims?.roles : [];
const hasUserRole = userRoles && userRoles.includes(Roles.APP_ADMIN); // If the user does not have the required role, show unauthorized message
if (!hasUserRole) {
return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Typography variant="h4" gutterBottom>
Access Denied 🚫
</Typography>
<Typography variant="h6" gutterBottom>
You do not have permission to view this page.
</Typography>
<Button variant="contained" color="primary" onClick={() => window.location.href = '/login'}>
Go to Login
</Button>
</Container>
);
} // Randomly select metrics
const selectedMetrics = metricsData.sort(() => Math.random() - Math.random()).slice(0, metricsData.length); return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
backgroundColor: "#e3f2fd", // Light blue background for a fresh look
padding: 4,
}}
>
<RocketLaunchIcon sx={{ fontSize: 80, color: "#1976d2", mb: 2 }} />
<Typography variant="h1" gutterBottom>
Welcome, Role {Roles.APP_ADMIN}! 🎉
</Typography>
<Typography variant="h4" gutterBottom>
You've unlocked your exclusive dashboard.
</Typography>
<Grid container spacing={3} sx={{ mt: 4 }}>
{selectedMetrics.map((metric, index) => (
<Grid item xs={12} sm={6} key={index}>
<Paper elevation={3} sx={{ padding: 2 }}>
<Typography variant="h6">{metric.title}</Typography>
<Typography variant="body1">Current Value: {metric.data} 🌟</Typography>
<Typography variant="body2">{metric.description}</Typography>
</Paper>
</Grid>
))}
</Grid>
<Button
variant="contained"
color="secondary"
size="large"
sx={{ mt: 4 }}
onClick={() => alert("You're doing great! Keep it up!")}
>
Keep Exploring!
</Button>
</Container>
);
};export default PrivateAdminPage;
src/pages/PrivateContributerPage.tsx
import React from 'react';
import { Container, Typography, Button, Grid, Paper } from "@mui/material";
import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
import { useMsal } from "@azure/msal-react";
import { Roles } from '../features/auth/config/roles';
// New metrics data with fresh content
const metricsData = [
{
title: "Total Users Registered",
data: Math.floor(Math.random() * 1000), // Random data between 0 and 999
description: "The total number of users who have registered on the platform."
},
{
title: "Daily Active Users",
data: Math.floor(Math.random() * 500), // Random data between 0 and 499
description: "The number of unique users who logged in today."
},
{
title: "Feedback Score",
data: Math.floor(Math.random() * 100),
description: "An average score based on user feedback collected over the last month."
},
{
title: "New Features Adopted",
data: Math.floor(Math.random() * 50), // Random data between 0 and 49
description: "The number of new features that users have started using this month."
},
{
title: "Support Tickets Resolved",
data: Math.floor(Math.random() * 200),
description: "The total number of support tickets resolved in the last week."
},
];const PrivateContributerPage: React.FC = () => {
const { accounts } = useMsal();
const isAuthenticated = accounts.length > 0; // Assuming roles are stored in account claims or a similar structure
const userRoles = isAuthenticated ? accounts[0].idTokenClaims?.roles : [];
const hasUserRole = userRoles && userRoles.includes(Roles.APP_CONTRIBUTER); // If the user does not have the required role, show unauthorized message
if (!hasUserRole) {
return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Typography variant="h4" gutterBottom>
Access Denied 🚫
</Typography>
<Typography variant="h6" gutterBottom>
You do not have permission to view this page.
</Typography>
<Button variant="contained" color="primary" onClick={() => window.location.href = '/login'}>
Go to Login
</Button>
</Container>
);
} // Randomly select metrics
const selectedMetrics = metricsData.sort(() => Math.random() - Math.random()).slice(0, metricsData.length); return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
backgroundColor: "#e1f5fe", // Light blue background for a fresh look
padding: 4,
}}
>
<RocketLaunchIcon sx={{ fontSize: 80, color: "#0288d1", mb: 2 }} />
<Typography variant="h1" gutterBottom>
Welcome, Role {Roles.APP_CONTRIBUTER}! 🎉
</Typography>
<Typography variant="h4" gutterBottom>
Your dashboard is ready for exploration!
</Typography>
<Grid container spacing={3} sx={{ mt: 4 }}>
{selectedMetrics.map((metric, index) => (
<Grid item xs={12} sm={6} key={index}>
<Paper elevation={3} sx={{ padding: 2 }}>
<Typography variant="h6">{metric.title}</Typography>
<Typography variant="body1">Current Value: {metric.data} 📊</Typography>
<Typography variant="body2">{metric.description}</Typography>
</Paper>
</Grid>
))}
</Grid>
<Button
variant="contained"
color="secondary"
size="large"
sx={{ mt: 4 }}
onClick={() => alert("You're making great progress!")}
>
Continue Your Journey!
</Button>
</Container>
);
};export default PrivateContributerPage;
src/pages/PrivateUserPage.tsx
import React from 'react';
import { Container, Typography, Button, Grid, Paper } from "@mui/material";
import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
import { useMsal } from "@azure/msal-react";
import { Roles } from '../features/auth/config/roles';
const metricsData = [
{
title: "Performance Metric #1",
data: Math.floor(Math.random() * 100), // Random data between 0 and 99
description: "This metric shows your current performance level."
},
{
title: "Engagement Metric #2",
data: Math.floor(Math.random() * 100),
description: "This metric reflects your engagement with our platform."
},
{
title: "Satisfaction Score",
data: Math.floor(Math.random() * 100),
description: "Your satisfaction score based on recent surveys."
},
{
title: "Activity Level",
data: Math.floor(Math.random() * 100),
description: "This metric indicates how active you are on the platform."
},
];const PrivateUserPage: React.FC = () => {
const { accounts } = useMsal();
const isAuthenticated = accounts.length > 0; // Assuming roles are stored in account claims or a similar structure
const userRoles = isAuthenticated ? accounts[0].idTokenClaims?.roles : [];
const hasUserRole = userRoles && userRoles.includes(Roles.APP_USER); // If the user does not have the required role, show unauthorized message
if (!hasUserRole) {
return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Typography variant="h4" gutterBottom>
Access Denied 🚫
</Typography>
<Typography variant="h6" gutterBottom>
You do not have permission to view this page.
</Typography>
<Button variant="contained" color="primary" onClick={() => window.location.href = '/login'}>
Go to Login
</Button>
</Container>
);
} // Randomly select metrics
const selectedMetrics = metricsData.sort(() => Math.random() - Math.random()).slice(0, metricsData.length); return (
<Container
sx={{
textAlign: "center",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100vh",
backgroundColor: "#e3f2fd", // Light blue background for a fresh look
padding: 4,
}}
>
<RocketLaunchIcon sx={{ fontSize: 80, color: "#1976d2", mb: 2 }} />
<Typography variant="h1" gutterBottom>
Welcome, Role {Roles.APP_USER}! 🎉
</Typography>
<Typography variant="h4" gutterBottom>
You've unlocked your exclusive dashboard.
</Typography>
<Grid container spacing={3} sx={{ mt: 4 }}>
{selectedMetrics.map((metric, index) => (
<Grid item xs={12} sm={6} key={index}>
<Paper elevation={3} sx={{ padding: 2 }}>
<Typography variant="h6">{metric.title}</Typography>
<Typography variant="body1">Data: {metric.data} 🌟</Typography>
<Typography variant="body2">{metric.description}</Typography>
</Paper>
</Grid>
))}
</Grid>
<Button
variant="contained"
color="secondary"
size="large"
sx={{ mt: 4 }}
onClick={() => alert("You're doing great! Keep it up!")}
>
Keep Exploring!
</Button>
</Container>
);
};export default PrivateUserPage;
Testing
So when we start unauthenticated.
We click to “Login for fun”
After we authenticated.
Conclusion
Although the article seems long, if we strip the code it becomes very short one. The implementation is straight forward and simple. By implementing it as a feature, we can reuse it in other projects, or extract it and create a library to share.
Resources
- React single-page application using MSAL React to authenticate users against Microsoft Entra External ID — Code Samples | Microsoft Learn
- microsoft-authentication-library-for-js/lib/msal-react/README.md at dev · AzureAD/microsoft-authentication-library-for-js
- Enable authentication in a React application by using Azure Active Directory B2C building blocks | Microsoft Learn
- Tutorial: Handle authentication flows in a React SPA — Microsoft Entra External ID | Microsoft Learn
- microsoft-authentication-library-for-js/lib/msal-react/docs/getting-started.md at dev · AzureAD/microsoft-authentication-library-for-js