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