diff --git a/docs/pages/toolpad/core/api/app-provider.json b/docs/pages/toolpad/core/api/app-provider.json
index a439e4bee49..6747e9f2587 100644
--- a/docs/pages/toolpad/core/api/app-provider.json
+++ b/docs/pages/toolpad/core/api/app-provider.json
@@ -12,7 +12,7 @@
"navigation": {
"type": {
"name": "arrayOf",
- "description": "Array<{ action?: node, children?: Array<object
| { kind: 'header', title: string }
| { kind: 'divider' }>, icon?: node, kind?: 'page', pattern?: string, segment?: string, title?: string }
| { kind: 'header', title: string }
| { kind: 'divider' }>"
+ "description": "Array<{ action?: node, children?: Array<object
| { kind: 'header', title: string }
| { kind: 'divider' }>, groups?: Array<string>, icon?: node, kind?: 'page', pattern?: string, roles?: Array<string>, segment?: string, title?: string }
| { kind: 'header', title: string }
| { kind: 'divider' }>"
},
"default": "[]"
},
@@ -26,7 +26,7 @@
"session": {
"type": {
"name": "shape",
- "description": "{ user?: { email?: string, id?: string, image?: string, name?: string } }"
+ "description": "{ user?: { email?: string, groups?: Array<string>, id?: string, image?: string, name?: string, roles?: Array<string> } }"
},
"default": "null"
},
diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx
index ba183f2bc88..ca990bc25d4 100644
--- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx
+++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx
@@ -42,6 +42,8 @@ export interface NavigationPageItem {
pattern?: string;
action?: React.ReactNode;
children?: Navigation;
+ groups?: string[];
+ roles?: string[];
}
export interface NavigationSubheaderItem {
@@ -63,6 +65,8 @@ export interface Session {
name?: string | null;
image?: string | null;
email?: string | null;
+ groups?: string[];
+ roles?: string[];
};
}
@@ -220,9 +224,11 @@ AppProvider.propTypes /* remove-proptypes */ = {
}),
]).isRequired,
),
+ groups: PropTypes.arrayOf(PropTypes.string),
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
+ roles: PropTypes.arrayOf(PropTypes.string),
segment: PropTypes.string,
title: PropTypes.string,
}),
@@ -251,9 +257,11 @@ AppProvider.propTypes /* remove-proptypes */ = {
session: PropTypes.shape({
user: PropTypes.shape({
email: PropTypes.string,
+ groups: PropTypes.arrayOf(PropTypes.string),
id: PropTypes.string,
image: PropTypes.string,
name: PropTypes.string,
+ roles: PropTypes.arrayOf(PropTypes.string),
}),
}),
/**
diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
index 3ead6b6805e..ccfe1f9583d 100644
--- a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
+++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import { styled, useTheme, SxProps } from '@mui/material';
import MuiAppBar from '@mui/material/AppBar';
+import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
@@ -15,9 +16,16 @@ import type {} from '@mui/material/themeCssVarsAugmentation';
import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import { Link } from '../shared/Link';
-import { BrandingContext, NavigationContext, WindowContext } from '../shared/context';
+import {
+ BrandingContext,
+ NavigationContext,
+ WindowContext,
+ RouterContext,
+} from '../shared/context';
import { Account, type AccountProps } from '../Account';
import { useApplicationTitle } from '../shared/branding';
+import { matchPath, hasMatchingRole } from '../shared/navigation';
+import { useSession } from '../useSession';
import { DashboardSidebarSubNavigation } from './DashboardSidebarSubNavigation';
import { ToolbarActions } from './ToolbarActions';
import { ToolpadLogo } from './ToolpadLogo';
@@ -141,10 +149,27 @@ function DashboardLayout(props: DashboardLayoutProps) {
const theme = useTheme();
const brandingContext = React.useContext(BrandingContext);
+ const routerContext = React.useContext(RouterContext);
const navigationContext = React.useContext(NavigationContext);
const appWindowContext = React.useContext(WindowContext);
const applicationTitle = useApplicationTitle();
+ const pathname = routerContext?.pathname ?? '/';
+ const currentNavigationItem = React.useMemo(
+ () => matchPath(navigationContext, pathname),
+ [navigationContext, pathname],
+ );
+
+ const session = useSession();
+ const userPermissions = React.useMemo(() => {
+ return [...(session?.user?.groups ?? []), ...(session?.user?.roles ?? [])];
+ }, [session?.user?.groups, session?.user?.roles]);
+
+ const hasAccess = React.useMemo(
+ () => (currentNavigationItem ? hasMatchingRole(currentNavigationItem, userPermissions) : false),
+ [currentNavigationItem, userPermissions],
+ );
+
const branding = brandingProp ?? brandingContext;
const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
@@ -455,7 +480,21 @@ function DashboardLayout(props: DashboardLayoutProps) {
overflow: 'auto',
}}
>
- {children}
+ {hasAccess ? (
+ children
+ ) : (
+
+ 404 - Page not found
+
+ )}
diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx
index bb033205131..1bda5aeb623 100644
--- a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx
+++ b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarSubNavigation.tsx
@@ -15,6 +15,7 @@ import type {} from '@mui/material/themeCssVarsAugmentation';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Link } from '../shared/Link';
+import { useSession } from '../useSession';
import { RouterContext } from '../shared/context';
import type { Navigation } from '../AppProvider';
import {
@@ -22,6 +23,7 @@ import {
getPageItemFullPath,
hasSelectedNavigationChildren,
isPageItemSelected,
+ hasMatchingRole,
} from '../shared/navigation';
import { getDrawerSxTransitionMixin } from './utils';
@@ -77,6 +79,10 @@ function DashboardSidebarSubNavigation({
selectedItemId,
}: DashboardSidebarSubNavigationProps) {
const routerContext = React.useContext(RouterContext);
+ const session = useSession();
+ const userPermissions = React.useMemo(() => {
+ return [...(session?.user?.groups ?? []), ...(session?.user?.roles ?? [])];
+ }, [session?.user?.groups, session?.user?.roles]);
const pathname = routerContext?.pathname ?? '/';
@@ -155,6 +161,10 @@ function DashboardSidebarSubNavigation({
);
}
+ if (!hasMatchingRole(navigationItem, userPermissions)) {
+ return null;
+ }
+
const navigationItemFullPath = getPageItemFullPath(basePath, navigationItem);
const navigationItemId = `${depth}-${navigationItemIndex}`;
const navigationItemTitle = getItemTitle(navigationItem);
diff --git a/packages/toolpad-core/src/shared/navigation.tsx b/packages/toolpad-core/src/shared/navigation.tsx
index fae0efa2e66..bf7341ffc0d 100644
--- a/packages/toolpad-core/src/shared/navigation.tsx
+++ b/packages/toolpad-core/src/shared/navigation.tsx
@@ -30,6 +30,17 @@ export function isPageItemSelected(
: getPageItemFullPath(basePath, navigationItem) === pathname;
}
+export function hasMatchingRole(item: NavigationPageItem, userPermissions: string[] = []): boolean {
+ if (!isPageItem(item)) {
+ return false;
+ }
+ if (!item.roles || item.roles.length === 0) {
+ return true; // Public item, no roles defined
+ }
+
+ return item.roles.some((role) => userPermissions.includes(role));
+}
+
export function hasSelectedNavigationChildren(
navigationItem: NavigationItem,
basePath: string,
diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json
index 5490b3ac64a..fb48d6998c6 100644
--- a/playground/nextjs/package.json
+++ b/playground/nextjs/package.json
@@ -9,6 +9,7 @@
"devDependencies": {
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
+ "@auth/core": "0.37.4",
"@mui/icons-material": "6.1.9",
"@mui/material": "6.1.9",
"@mui/material-nextjs": "6.1.9",
diff --git a/playground/nextjs/src/app/(dashboard)/payments/page.tsx b/playground/nextjs/src/app/(dashboard)/payments/page.tsx
new file mode 100644
index 00000000000..a78b162b408
--- /dev/null
+++ b/playground/nextjs/src/app/(dashboard)/payments/page.tsx
@@ -0,0 +1,6 @@
+import * as React from 'react';
+import Typography from '@mui/material/Typography';
+
+export default function PaymentsPage() {
+ return Welcome to the Toolpad payments!;
+}
diff --git a/playground/nextjs/src/app/layout.tsx b/playground/nextjs/src/app/layout.tsx
index e0e8ad5c3ba..980080575dc 100644
--- a/playground/nextjs/src/app/layout.tsx
+++ b/playground/nextjs/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { AppProvider } from '@toolpad/core/nextjs';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import DashboardIcon from '@mui/icons-material/Dashboard';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
+import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import type { Navigation } from '@toolpad/core/AppProvider';
import { SessionProvider, signIn, signOut } from 'next-auth/react';
import { auth } from '../auth';
@@ -22,6 +23,12 @@ const NAVIGATION: Navigation = [
title: 'Orders',
icon: ,
},
+ {
+ segment: 'payments',
+ title: 'Payment',
+ icon: ,
+ roles: ['MEMBER', 'ADMIN'],
+ },
];
const BRANDING = {
diff --git a/playground/nextjs/src/auth.ts b/playground/nextjs/src/auth.ts
index 323ac97b646..12e6038c433 100644
--- a/playground/nextjs/src/auth.ts
+++ b/playground/nextjs/src/auth.ts
@@ -1,5 +1,7 @@
+import { TokenSet } from '@auth/core/types';
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
+import Google, { GoogleProfile } from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import type { Provider } from 'next-auth/providers';
@@ -17,6 +19,14 @@ const providers: Provider[] = [
if (c.password !== 'password') {
return null;
}
+ if (c.email === 'admin@mui.com') {
+ return {
+ id: 'test',
+ name: 'Test User',
+ email: String(c.email),
+ roles: ['ADMIN'],
+ };
+ }
return {
id: 'test',
name: 'Test User',
@@ -24,6 +34,61 @@ const providers: Provider[] = [
};
},
}),
+ Google({
+ clientId: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ authorization: {
+ params: {
+ scope: [
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/cloud-identity.groups.readonly',
+ ].join(' '),
+ },
+ },
+ profile: async (profile: GoogleProfile, tokens: TokenSet) => {
+ try {
+ // Fetch user's groups using Cloud Identity API
+ const groupsResponse = await fetch(
+ `https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups?${new URLSearchParams(
+ {
+ query: `member_key_id == '${profile.email}'`,
+ },
+ )}`,
+ {
+ headers: {
+ Authorization: `Bearer ${tokens.access_token}`,
+ },
+ },
+ );
+
+ const groupsData = await groupsResponse.json();
+ const groups: string[] = [];
+ const roles: string[] = [];
+ groupsData.memberships?.forEach((m: any) => {
+ groups.push(m.group);
+ roles.push(...m.roles.map((r: any) => r.name));
+ });
+
+ return {
+ ...profile,
+ id: profile.sub,
+ image: profile.picture,
+ groups,
+ roles,
+ };
+ } catch (error) {
+ console.error('Error fetching groups:', error);
+ return {
+ ...profile,
+ id: profile.sub,
+ image: profile.picture,
+ groups: [],
+ role: 'user',
+ };
+ }
+ },
+ }),
];
export const providerMap = providers.map((provider) => {
@@ -73,5 +138,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return false; // Redirect unauthenticated users to login page
},
+ jwt({ token, user }) {
+ if (user) {
+ token.roles = user.roles;
+ token.groups = user.groups;
+ }
+ return token;
+ },
+ session({ session, token }) {
+ session.user.roles = token.roles;
+ session.user.groups = token.groups;
+ return session;
+ },
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bf163b78731..2adf6767bd2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1198,6 +1198,9 @@ importers:
playground/nextjs:
devDependencies:
+ '@auth/core':
+ specifier: 0.37.4
+ version: 0.37.4
'@emotion/react':
specifier: 11.13.3
version: 11.13.3(@types/react@18.3.12)(react@18.3.1)
@@ -4524,12 +4527,6 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
- '@vitejs/plugin-react@4.3.3':
- resolution: {integrity: sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==}
- engines: {node: ^14.18.0 || >=16.0.0}
- peerDependencies:
- vite: ^4.2.0 || ^5.0.0
-
'@vitejs/plugin-react@4.3.4':
resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -14052,17 +14049,6 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
- '@vitejs/plugin-react@4.3.3(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0))':
- dependencies:
- '@babel/core': 7.26.0
- '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0)
- '@types/babel__core': 7.20.5
- react-refresh: 0.14.2
- vite: 5.4.11(@types/node@20.17.6)(terser@5.36.0)
- transitivePeerDependencies:
- - supports-color
-
'@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0))':
dependencies:
'@babel/core': 7.26.0