Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(rbac frontend) Protected routes and menu #82

Merged
merged 15 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/flat": "^5.0.5",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.8",
"@types/lodash": "^4.17.0",
"@types/node": "^22.5.4",
"@types/react": "^18.2.79",
Expand Down Expand Up @@ -67,6 +68,7 @@
"eslint": "8.57",
"flat": "^6.0.1",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"next": "^14.2.3",
"next-auth": "^4.24.7",
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/list/_components/AasListDataWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { SelectRepository } from './filter/SelectRepository';
import { useTranslations } from 'next-intl';
import { ApiResponseWrapperError, ApiResultStatus } from 'lib/util/apiResponseWrapper/apiResponseWrapper';
import { AuthenticationPrompt } from 'components/azureAuthentication/AuthenticationPrompt';
import { AuthenticationPrompt } from 'components/authentication/AuthenticationPrompt';

export default function AasListDataWrapper() {
const [isLoadingList, setIsLoadingList] = useState(false);
Expand Down
34 changes: 19 additions & 15 deletions src/app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { PrivateRoute } from 'components/azureAuthentication/PrivateRoute';
import { PrivateRoute } from 'components/authentication/PrivateRoute';
import { Box, Card } from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import { ViewHeading } from 'components/basics/ViewHeading';
Expand All @@ -14,7 +14,7 @@ import { useEnv } from 'app/env/provider';

enum settingsPageTypes {
ID_STRUCTURE,
MNESTIX_CONNECTIONS
MNESTIX_CONNECTIONS,
}

export default function Page() {
Expand All @@ -25,13 +25,14 @@ export default function Page() {
const settingsTabItems: TabSelectorItem[] = [
{
id: settingsPageTypes[settingsPageTypes.MNESTIX_CONNECTIONS],
label: intl.formatMessage(messages.mnestix.connections.title)
}]
label: intl.formatMessage(messages.mnestix.connections.title),
},
];

if(env.MNESTIX_BACKEND_API_URL){
if (env.MNESTIX_BACKEND_API_URL) {
const settingsTabToAdd = {
id: settingsPageTypes[settingsPageTypes.ID_STRUCTURE],
label: intl.formatMessage(messages.mnestix.idStructure)
label: intl.formatMessage(messages.mnestix.idStructure),
};
settingsTabItems.splice(0, 0, settingsTabToAdd);
}
Expand All @@ -41,24 +42,27 @@ export default function Page() {
const renderActiveSettingsTab = () => {
switch (selectedTab.id) {
case settingsPageTypes[settingsPageTypes.ID_STRUCTURE]:
return <IdSettingsCard/>
return <IdSettingsCard />;
case settingsPageTypes[settingsPageTypes.MNESTIX_CONNECTIONS]:
return <MnestixConnectionsCard/>
return <MnestixConnectionsCard />;
default:
return <></>
return <></>;
}
}
};

return (
<PrivateRoute>
<Box sx={{ p:4, width: '100%', margin: '0 auto' }}>
<PrivateRoute currentRoute={'/settings'}>
<Box sx={{ p: 4, width: '100%', margin: '0 auto' }}>
<Box sx={{ mb: 3 }}>
<ViewHeading title={<FormattedMessage {...messages.mnestix.settings} />}/>
<ViewHeading title={<FormattedMessage {...messages.mnestix.settings} />} />
</Box>
<Card sx={{ p: 2 }}>
<Box display="grid" gridTemplateColumns={isMobile ? '1fr' : '1fr 3fr'}>
<VerticalTabSelector items={settingsTabItems} selected={selectedTab}
setSelected={setSelectedTab}/>
<VerticalTabSelector
items={settingsTabItems}
selected={selectedTab}
setSelected={setSelectedTab}
/>
{renderActiveSettingsTab()}
</Box>
</Card>
Expand Down
19 changes: 13 additions & 6 deletions src/app/[locale]/templates/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { PrivateRoute } from 'components/azureAuthentication/PrivateRoute';
import { PrivateRoute } from 'components/authentication/PrivateRoute';
import { CheckCircle, CloudUploadOutlined, Delete, MoreVert, Restore } from '@mui/icons-material';
import {
Box,
Expand All @@ -25,7 +25,8 @@ import {
updateNodeIds,
getParentOfElement,
splitIdIntoArray,
rewriteNodeIds, generateSubmodelViewObjectFromSubmodelElement,
rewriteNodeIds,
generateSubmodelViewObjectFromSubmodelElement,
} from 'lib/util/SubmodelViewObjectUtil';
import { TemplateEditFields, TemplateEditFieldsProps } from '../_components/template-edit/TemplateEditFields';
import { useAuth } from 'lib/hooks/UseAuth';
Expand Down Expand Up @@ -74,11 +75,18 @@ export default function Page() {
function generateSubmodelViewObject(sm: Submodel): SubmodelViewObject {
const localSm = cloneDeep(sm);
// Ids are unique for the tree, start with 0, children have 0-0, 0-1, and so on
const frontend: SubmodelViewObject = { id: '0', name: localSm.idShort!, children: [], isAboutToBeDeleted: false };
const frontend: SubmodelViewObject = {
id: '0',
name: localSm.idShort!,
children: [],
isAboutToBeDeleted: false,
};

if (localSm.submodelElements) {
const arr = localSm.submodelElements;
arr.forEach((el, i) => frontend.children?.push(generateSubmodelViewObjectFromSubmodelElement(el, '0-' + i)));
arr.forEach((el, i) =>
frontend.children?.push(generateSubmodelViewObjectFromSubmodelElement(el, '0-' + i)),
);
localSm.submodelElements = [];
}
frontend.data = localSm;
Expand Down Expand Up @@ -257,7 +265,6 @@ export default function Page() {
return undefined;
}


function deleteItem(elementToDeleteId: string, submodel: SubmodelViewObject): SubmodelViewObject {
const idArray = splitIdIntoArray(elementToDeleteId);
const parentElement = getParentOfElement(elementToDeleteId, submodel);
Expand Down Expand Up @@ -313,7 +320,7 @@ export default function Page() {
}

return (
<PrivateRoute>
<PrivateRoute currentRoute={'/templates'}>
<Box sx={{ p: 3, maxWidth: '1125px', width: '100%', margin: '0 auto' }}>
<Breadcrumbs
links={[
Expand Down
4 changes: 2 additions & 2 deletions src/app/[locale]/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { PrivateRoute } from 'components/azureAuthentication/PrivateRoute';
import { PrivateRoute } from 'components/authentication/PrivateRoute';
import { Add, FolderOutlined } from '@mui/icons-material';
import { Box, Button, Divider, Paper, Skeleton, Typography } from '@mui/material';
import { TabSelectorItem, VerticalTabSelector } from 'components/basics/VerticalTabSelector';
Expand Down Expand Up @@ -199,7 +199,7 @@ export default function Page() {
};

return (
<PrivateRoute>
<PrivateRoute currentRoute={'/templates'}>
<Box sx={{ p: 3, maxWidth: '1125px', width: '100%', margin: '0 auto' }}>
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ViewHeading title={<FormattedMessage {...messages.mnestix.templates} />} />
Expand Down
12 changes: 8 additions & 4 deletions src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import NextAuth, { DefaultSession } from 'next-auth';
import { authOptions } from 'authConfig';
import NextAuth, { DefaultSession, User } from 'next-auth';
import { authOptions } from 'components/authentication/authConfig';

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST }
export { handler as GET, handler as POST };

declare module 'next-auth' {
interface Session extends DefaultSession {
accessToken: string;
idToken: string;
user: {
isAdmin: boolean;
role: string[];
} & User;
}
}
}
12 changes: 6 additions & 6 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { getServerSession } from 'next-auth';
import { authOptions } from 'authConfig';
import { authOptions } from 'components/authentication/authConfig';
import { NextResponse } from 'next/server';

export async function GET() {
const session = await getServerSession(authOptions);
const redirectUri = process.env.NEXTAUTH_URL ? process.env.NEXTAUTH_URL : '';
const endSessionUrl = `${process.env.KEYCLOAK_ISSUER}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`
const endSessionUrl = `${process.env.KEYCLOAK_ISSUER}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`;
try {
if (session) {
const idToken = session.idToken;

const url = `${endSessionUrl}?id_token_hint=${idToken}&post_logout_redirect_uri=${redirectUri}`;

const response = await fetch(url, { method: 'GET' });

if (!response.ok) {
console.error('Failed to log out from Keycloak:', response.statusText);
return new NextResponse('Failed to log out', { status: 500 });
}

return NextResponse.redirect(redirectUri);
}

return new NextResponse('Unauthorized', { status: 401 });
} catch (err) {
console.error('Error logging out:', err);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Typography } from '@mui/material';
import AuthenticationLock from 'assets/authentication_lock.svg';
import SignInButton from 'components/azureAuthentication/SignInButton';
import SignInButton from 'components/authentication/SignInButton';
import { useTranslations } from 'next-intl';

export function AuthenticationPrompt() {
Expand Down
24 changes: 24 additions & 0 deletions src/components/authentication/NotAllowedPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Box, Typography } from '@mui/material';
import AuthenticationLock from 'assets/authentication_lock.svg';
import { useTranslations } from 'next-intl';

export function NotAllowedPrompt() {
const t = useTranslations('authentication');
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
width: 'auto',
m: 3,
mt: 5,
}}
>
<Typography variant="h2" sx={{ mb: 2 }} color="primary" align="center">
{t('contactAdmin')}
</Typography>
<AuthenticationLock />
</Box>
);
}
25 changes: 25 additions & 0 deletions src/components/authentication/PrivateRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { useAuth } from 'lib/hooks/UseAuth';
import { useEnv } from 'app/env/provider';
import { AuthenticationPrompt } from 'components/authentication/AuthenticationPrompt';
import Roles from 'components/authentication/Roles';
import { NotAllowedPrompt } from 'components/authentication/NotAllowedPrompt';

export function PrivateRoute({ currentRoute, children }: { currentRoute: string; children: React.JSX.Element }) {
const auth = useAuth();
const env = useEnv();
const isAdmin = auth.getAccount()?.user.isAdmin;
const allowedRoutes = isAdmin ? Roles.mnestixAdmin : Roles.mnestixUser;
const useAuthentication = env.AUTHENTICATION_FEATURE_FLAG;

if (!useAuthentication) return <>{children}</>;

if (useAuthentication && auth.isLoggedIn) {
if (allowedRoutes.includes(currentRoute)) {
return <>{children}</>;
} else {
return <NotAllowedPrompt />;
}
}
return <AuthenticationPrompt />;
}
9 changes: 9 additions & 0 deletions src/components/authentication/Roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* This map is used to set the allowed routes per Role.
*/
const Roles = {
mnestixAdmin: ['/templates', '/settings'],
mnestixUser: ['/templates'],
};

export default Roles;
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Button } from '@mui/material';
import { Login } from '@mui/icons-material';
import { FormattedMessage } from 'react-intl';
import { messages } from 'lib/i18n/localization';
import { useAuth } from 'lib/hooks/UseAuth';
import { useTranslations } from 'next-intl';

const SignInButton = () => {
const auth = useAuth();
const t = useTranslations('mainMenu');
return (
<Button
sx={{ m: 2, mt: 3, minWidth: '200px' }}
Expand All @@ -14,7 +14,7 @@ const SignInButton = () => {
onClick={() => auth.login()}
data-testid="sign-in-button"
>
<FormattedMessage {...messages.mnestix.login} />
{t('login')}
</Button>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
import AzureADProvider from 'next-auth/providers/azure-ad';
import type { JWT } from 'next-auth/jwt';
import { JWT } from 'next-auth/jwt';
import jwt from 'jsonwebtoken';

const isEmptyOrWhiteSpace = (input: string | undefined) => {
return !input || input.trim() === '';
Expand Down Expand Up @@ -48,14 +49,26 @@ export const authOptions: AuthOptions = {
},
callbacks: {
async jwt({ token, account }) {
let roles = null;

const nowTimeStamp = Math.floor(Date.now() / 1000);

if (account) {
token.access_token = account.access_token;
token.id_token = account.id_token;
token.expires_at = account.expires_at;
token.refresh_token = account.refresh_token;
return token;

// The Roles are stored inside the access_token
if (account.access_token) {
const decodedToken = jwt.decode(account.access_token);
if (decodedToken) {
// @ts-expect-error role exits
roles = decodedToken?.role;
}
}
// Store Roles inside token
return { ...token, roles: roles };
} else if (nowTimeStamp < (token.expires_at as number)) {
return token;
}
Expand All @@ -75,6 +88,7 @@ export const authOptions: AuthOptions = {
async session({ session, token }) {
session.accessToken = token.access_token as string;
session.idToken = token.id_token as string;
session.user.role = token.roles as string[];
return session;
},
},
Expand Down Expand Up @@ -133,4 +147,3 @@ const refreshAzureADToken = async (token: JWT) => {
refresh_token: refreshedTokens.refresh_token ?? token.refresh_token, // Fallback to old refresh token if not provided
};
};

12 changes: 0 additions & 12 deletions src/components/azureAuthentication/PrivateRoute.tsx

This file was deleted.

Loading
Loading