From e57dd95eaae61b37ad8a340a3b6ec86ad1b7051e Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Wed, 30 Sep 2026 12:05:14 +0530 Subject: [PATCH] Give each role its own sidebar, drawn from that role's own grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IAM built one navigation list from the union of somebody's permissions, so a person who is both a student and an office holder saw the student's own screens — My Applications, My Offers — while acting as the office. The menus now arrive keyed by designation and the shell renders the acting role's share, the way accessible modules already worked. The iframe's sandbox is not a boundary and the comment said it was: same-origin plus scripts lets the frame drop its own sandbox, and it needs both. What keeps the frame safe is that nothing else is served from this origin as a page. --- src/app/AppLayout.jsx | 2 +- src/helper/validateauth.jsx | 2 +- src/redux/userslice.jsx | 7 +++++-- src/redux/userslice.test.jsx | 36 ++++++++++++++++++++++++++++++++ src/ui/routing/PluggedModule.jsx | 5 ++++- 5 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/redux/userslice.test.jsx diff --git a/src/app/AppLayout.jsx b/src/app/AppLayout.jsx index 91ddeadd0..114332f9b 100644 --- a/src/app/AppLayout.jsx +++ b/src/app/AppLayout.jsx @@ -47,7 +47,7 @@ export function Layout({ children = null }) { (state) => state.user.currentAccessibleModules, ); const pluggedNavigation = useSelector( - (state) => state.user.pluggedNavigation, + (state) => state.user.currentPluggedNavigation, ); const unreadCount = useSelector((state) => state.notification.unreadCount); const profilePhoto = useSelector((state) => state.user.profilePhoto); diff --git a/src/helper/validateauth.jsx b/src/helper/validateauth.jsx index 34f01652e..6b2978b6e 100644 --- a/src/helper/validateauth.jsx +++ b/src/helper/validateauth.jsx @@ -49,7 +49,7 @@ function ValidateAuth() { last_selected_role, roll_no, must_complete_profile = false, - plugged_navigation = [], + plugged_navigation = {}, } = data; dispatch(setUserName(name)); diff --git a/src/redux/userslice.jsx b/src/redux/userslice.jsx index d8f8259a4..b0d2c02ea 100644 --- a/src/redux/userslice.jsx +++ b/src/redux/userslice.jsx @@ -15,7 +15,8 @@ const userSlice = createSlice({ authChecked: false, // /api/auth/me has resolved (gates route rendering) programmeType: null, // UG | PG | PHD — decides which academic pages exist profilePhoto: "", // student's own photo, shown on the sidebar avatar - pluggedNavigation: [], // menus contributed by modules another service serves + pluggedNavigation: {}, // Format---> {role: [section]}, from other services + currentPluggedNavigation: [], // the acting role's share of the above }, reducers: { setProgrammeType: (state, action) => { @@ -43,7 +44,7 @@ const userSlice = createSlice({ state.role = action.payload; }, setPluggedNavigation: (state, action) => { - state.pluggedNavigation = action.payload ?? []; + state.pluggedNavigation = action.payload ?? {}; }, setAccessibleModules: (state, action) => { state.accessibleModules = action.payload; @@ -51,6 +52,8 @@ const userSlice = createSlice({ setCurrentAccessibleModules: (state) => { state.currentAccessibleModules = state.accessibleModules[state.role] || {}; + state.currentPluggedNavigation = + state.pluggedNavigation[state.role] || []; }, clearUserName: (state) => { state.username = "User"; diff --git a/src/redux/userslice.test.jsx b/src/redux/userslice.test.jsx new file mode 100644 index 000000000..9f07f4ddf --- /dev/null +++ b/src/redux/userslice.test.jsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import reducer, { + setAccessibleModules, + setCurrentAccessibleModules, + setPluggedNavigation, + setRole, +} from "./userslice"; + +const placement = [ + { section: "Placement", items: [{ code: "placement_cell", links: [] }] }, +]; +const leave = [{ section: "Leave", items: [{ code: "leave", links: [] }] }]; + +const stateFor = (role) => + [ + setAccessibleModules({ + student: { placement_cell: true }, + acadadmin: { leave: true }, + }), + setPluggedNavigation({ student: placement, acadadmin: leave }), + setRole(role), + setCurrentAccessibleModules(), + ].reduce(reducer, undefined); + +describe("the acting role's own sidebar", () => { + it("gives a role only the menus granted to it", () => { + expect(stateFor("student").currentPluggedNavigation).toEqual(placement); + expect(stateFor("acadadmin").currentPluggedNavigation).toEqual(leave); + }); + + it("draws nothing for a role that was granted nothing", () => { + expect(stateFor("faculty").currentPluggedNavigation).toEqual([]); + expect(stateFor("faculty").currentAccessibleModules).toEqual({}); + }); +}); diff --git a/src/ui/routing/PluggedModule.jsx b/src/ui/routing/PluggedModule.jsx index b4e6834c4..f2880ae91 100644 --- a/src/ui/routing/PluggedModule.jsx +++ b/src/ui/routing/PluggedModule.jsx @@ -26,7 +26,10 @@ export function PluggedModule({ title }) { className={classes.frame} src={src} title={title} - // Same institute, same session; it needs its cookie and nothing beyond. + // Not a boundary: same-origin plus scripts lets the frame drop its own + // sandbox, and it needs both. The frame is trusted because it is ours and + // the origin is ours; what keeps that true is that nothing else — an + // upload above all — is ever served from this origin as a page. sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads" /> );