Skip to content

Commit

Permalink
feat: admin app - restrict access to pages based on role (#559)
Browse files Browse the repository at this point in the history
Co-authored-by: Sukanya Rath <98050194+sukanya-rath@users.noreply.github.com>
  • Loading branch information
banders and sukanya-rath authored Jun 29, 2024
1 parent 0943610 commit 815c765
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 69 deletions.
23 changes: 16 additions & 7 deletions admin-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@

<script>
import { appStore } from './store/modules/app';
import { mapState } from 'pinia';
import { mapActions, mapState } from 'pinia';
import Header from './components/Header.vue';
import SideBar from './components/SideBar.vue';
import MsieBanner from './components/MsieBanner.vue';
import SnackBar from './components/util/SnackBar.vue';
import BreadcrumbTrail from './components/BreadcrumbTrail.vue';
import { NotificationService } from './services/notificationService';
import { authStore } from './store/modules/auth';
import { USER_ROLE_NAME } from './constants';
export default {
name: 'App',
Expand All @@ -61,21 +62,29 @@ export default {
},
},
watch: {
$route(to, from) {
$route: {
handler(to, from) {
this.onRouteChanged(to, from);
},
},
},
async created() {},
methods: {
appStore,
...mapActions(authStore, ['doesUserHaveRole']),
onRouteChanged(to, from) {
this.activeRoute = to;
if (to.fullPath != '/error') {
//Reset error page message back to the default
NotificationService.setErrorPageMessage();
}
this.areHeaderAndSidebarVisible = to.meta.requiresAuth;
this.isTitleVisible = to?.meta?.isTitleVisible && to?.meta?.pageTitle;
this.isBreadcrumbTrailVisible = to?.meta?.isBreadcrumbTrailVisible;
this.isBreadcrumbTrailVisible =
to?.meta?.isBreadcrumbTrailVisible &&
this.doesUserHaveRole(USER_ROLE_NAME);
},
},
async created() {},
methods: {
appStore,
},
};
</script>
Expand Down
164 changes: 113 additions & 51 deletions admin-frontend/src/__tests__/App.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { createTestingPinia } from '@pinia/testing';
import { flushPromises, mount } from '@vue/test-utils';
import { getActivePinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouter, createWebHistory } from 'vue-router';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import App from '../App.vue';
import router from '../router';
import { authStore } from '../store/modules/auth';

// Mock the ResizeObserver
const ResizeObserverMock = vi.fn(() => ({
Expand All @@ -16,73 +20,131 @@ const ResizeObserverMock = vi.fn(() => ({
// Stub the global ResizeObserver
vi.stubGlobal('ResizeObserver', ResizeObserverMock);

describe('App', () => {
let wrapper;
let pinia;
let app;
const setupComponentEnvironment = async (options: any = {}) => {
const vuetify = createVuetify({
components,
directives,
});

const initWrapper = async (options: any = {}) => {
const vuetify = createVuetify({
components,
directives,
});
const pinia = getActivePinia();

pinia = createTestingPinia({
initialState: {
code: {},
config: {},
},
});
const auth = authStore();
auth.getJwtToken = vi.fn().mockResolvedValue(null);
auth.doesUserHaveRole = vi.fn().mockReturnValue(true);

wrapper = mount(
{
//SideBar depends on vuetify components, so it must be mounted within a v-layout.
//It is also an async component because one of its dependencies (v-navigation-drawer)
//is async, so it must be mounted with a Suspense component. Some
//additional info about using the Suspense component are in the vue-test-utils docs
//here:
//https://test-utils.vuejs.org/guide/advanced/async-suspense.html#Testing-asynchronous-setup
template: '<Suspense><App/></Suspense>',
},
{
props: {},
global: {
components: {
App,
},
plugins: [vuetify, pinia],
const mockRouter = createRouter({
history: createWebHistory(),
routes: router.getRoutes(),
});

const wrapper = mount(
{
//SideBar depends on vuetify components, so it must be mounted within a v-layout.
//It is also an async component because one of its dependencies (v-navigation-drawer)
//is async, so it must be mounted with a Suspense component. Some
//additional info about using the Suspense component are in the vue-test-utils docs
//here:
//https://test-utils.vuejs.org/guide/advanced/async-suspense.html#Testing-asynchronous-setup
template: '<Suspense><App/></Suspense>',
},
{
props: {},
global: {
components: {
App,
},
plugins: [vuetify, pinia as any, mockRouter],
},
);
},
);

//wait for the async App component to load
await flushPromises();
//wait for the async App component to load
await flushPromises();

app = await wrapper.findComponent(App);
const app = await wrapper.findComponent(App);

return {
wrapper: wrapper,
pinia: pinia,
app: app,
router: mockRouter,
auth: auth,
};
};

beforeEach(async () => {
await initWrapper();
beforeEach(async () => {
const pinia = createTestingPinia({
initialState: {
code: {},
config: {},
},
});
setActivePinia(pinia);
});

afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
afterEach(() => {
vi.clearAllMocks();
});

describe('App', () => {
describe('if the header and sidebar are supposed to be visible', () => {
it('they are both actually visible', () => {
app.vm.areHeaderAndSidebarVisible = true;
expect(app.find('header')).toBeTruthy();
expect(app.find('SideBar')).toBeTruthy();
it('they are both actually visible', async () => {
const componentEnv = await setupComponentEnvironment();
componentEnv.app.vm.areHeaderAndSidebarVisible = true;
await componentEnv.app.vm.$nextTick();
expect(componentEnv.app.find('header')).toBeTruthy();
expect(componentEnv.app.find('SideBar')).toBeTruthy();
});
});
describe('if the header and sidebar are supposed to be hidden', () => {
it('they are both actually hidden', () => {
app.vm.areHeaderAndSidebarVisible = false;
expect(app.find('header').exists()).toBeFalsy();
expect(app.find('SideBar').exists()).toBeFalsy();
it('they are both actually hidden', async () => {
const componentEnv = await setupComponentEnvironment();
componentEnv.app.vm.areHeaderAndSidebarVisible = false;
await componentEnv.app.vm.$nextTick();
expect(componentEnv.app.find('header').exists()).toBeFalsy();
expect(componentEnv.app.find('SideBar').exists()).toBeFalsy();
});
});

describe('when the route changes to /user-management, and the user has permission to view the breadbrumb trail', async () => {
it('breadcrumb trail is visible', async () => {
const componentEnv = await setupComponentEnvironment();
componentEnv.auth.doesUserHaveRole.mockReturnValue(true);
componentEnv.router.push('/user-management');
await componentEnv.router.isReady();
await componentEnv.app.vm.$nextTick();
expect(componentEnv.app.vm.isBreadcrumbTrailVisible).toBeTruthy();
});
});
describe("when the route changes to /user-management, and the user doesn't have permission to view the breadbrumb trail", async () => {
it('breadcrumb trail is hidden', async () => {
const componentEnv = await setupComponentEnvironment();
componentEnv.auth.doesUserHaveRole.mockReturnValue(false);
componentEnv.router.push('/user-management');
await componentEnv.router.isReady();
await componentEnv.app.vm.$nextTick();
expect(componentEnv.app.vm.isBreadcrumbTrailVisible).toBeFalsy();

Check failure on line 126 in admin-frontend/src/__tests__/App.spec.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (admin-frontend)

src/__tests__/App.spec.ts > App > when the route changes to /user-management, and the user doesn't have permission to view the breadbrumb trail > breadcrumb trail is hidden

AssertionError: expected true to be falsy - Expected + Received - true + false ❯ src/__tests__/App.spec.ts:126:60
});
});
describe('onRouteChanged', () => {
describe('when the route changes', () => {
it('various state variables are updated', async () => {
const componentEnv = await setupComponentEnvironment();
const to = {
meta: {
requiresAuth: false,
isBreadcrumbTrailVisible: true,
pageTitle: 'sample route',
},
};
componentEnv.auth.doesUserHaveRole.mockReturnValue(false);
componentEnv.app.vm.onRouteChanged(to, null);
expect(componentEnv.app.vm.activeRoute).toStrictEqual(to);
expect(componentEnv.app.vm.areHeaderAndSidebarVisible).toBe(
to.meta.requiresAuth,
);
expect(componentEnv.app.vm.isBreadcrumbTrailVisible).toBe(false);
});
});
});
});
10 changes: 7 additions & 3 deletions admin-frontend/src/components/SideBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
to="dashboard"
title="Dashboard"
:class="{ active: activeRoute == 'dashboard' }"
v-if="auth.doesUserHaveRole(USER_ROLE_NAME)"
>
<template v-slot:prepend>
<v-icon icon="mdi-home"></v-icon>
Expand All @@ -40,6 +41,7 @@
to="reports"
title="Reports"
:class="{ active: activeRoute == 'reports' }"
v-if="auth.doesUserHaveRole(USER_ROLE_NAME)"
>
<template v-slot:prepend>
<v-icon icon="mdi-magnify"></v-icon>
Expand All @@ -50,6 +52,7 @@
to="announcements"
title="Announcements"
:class="{ active: activeRoute == 'announcements' }"
v-if="auth.doesUserHaveRole(USER_ROLE_NAME)"
>
<template v-slot:prepend>
<v-icon icon="mdi-bullhorn"></v-icon>
Expand All @@ -60,7 +63,7 @@
to="user-management"
title="User Management"
:class="{ active: activeRoute == 'user-management' }"
v-if="userInfo?.role == ADMIN_ROLE_NAME"
v-if="auth.doesUserHaveRole(ADMIN_ROLE_NAME)"
>
<template v-slot:prepend>
<v-icon icon="mdi-account-multiple"></v-icon>
Expand All @@ -71,6 +74,7 @@
to="analytics"
title="Analytics"
:class="{ active: activeRoute == 'analytics' }"
v-if="auth.doesUserHaveRole(USER_ROLE_NAME)"
>
<template v-slot:prepend>
<v-icon icon="mdi-chart-bar"></v-icon>
Expand All @@ -90,9 +94,9 @@ export default {
import { watch, ref } from 'vue';
import { useRoute } from 'vue-router';
import { authStore } from '../store/modules/auth';
import { ADMIN_ROLE_NAME } from '../constants';
import { ADMIN_ROLE_NAME, USER_ROLE_NAME } from '../constants';
const { userInfo } = authStore();
const auth = authStore();
const route = useRoute();
const activeRoute = ref();
const isExpanded = ref(true);
Expand Down
19 changes: 12 additions & 7 deletions admin-frontend/src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { PAGE_TITLES } from './utils/constant';
import Login from './components/Login.vue';
import { authStore } from './store/modules/auth';
import Logout from './components/Logout.vue';
import { ADMIN_ROLE_NAME } from './constants';
import { ADMIN_ROLE_NAME, USER_ROLE_NAME } from './constants';

const router = createRouter({
history: createWebHistory(),
Expand All @@ -37,6 +37,7 @@ const router = createRouter({
meta: {
pageTitle: PAGE_TITLES.DASHBOARD,
requiresAuth: true,
requiresRole: USER_ROLE_NAME,
isTitleVisible: true,
},
},
Expand All @@ -47,6 +48,7 @@ const router = createRouter({
meta: {
pageTitle: PAGE_TITLES.REPORTS,
requiresAuth: true,
requiresRole: USER_ROLE_NAME,
isTitleVisible: true,
isBreadcrumbTrailVisible: true,
},
Expand All @@ -58,6 +60,7 @@ const router = createRouter({
meta: {
pageTitle: PAGE_TITLES.ANNOUNCEMENTS,
requiresAuth: true,
requiresRole: USER_ROLE_NAME,
isTitleVisible: true,
isBreadcrumbTrailVisible: true,
},
Expand All @@ -69,7 +72,7 @@ const router = createRouter({
meta: {
pageTitle: PAGE_TITLES.USER_MANAGEMENT,
requiresAuth: true,
role: ADMIN_ROLE_NAME,
requiresRole: ADMIN_ROLE_NAME,
isTitleVisible: true,
isBreadcrumbTrailVisible: true,
},
Expand All @@ -81,6 +84,7 @@ const router = createRouter({
meta: {
pageTitle: PAGE_TITLES.ANALYTICS,
requiresAuth: true,
requiresRole: USER_ROLE_NAME,
isTitleVisible: true,
isBreadcrumbTrailVisible: true,
},
Expand Down Expand Up @@ -125,14 +129,13 @@ const router = createRouter({
name: 'NotFound',
component: NotFoundPage,
meta: {
requiresAuth: true,
requiresAuth: false,
},
},
],
});

router.beforeEach((to, _from, next) => {
// this section is to set page title in vue store
if (!to.meta.requiresAuth) {
//Proceed normally to the requested route
const apStore = appStore();
Expand All @@ -141,7 +144,6 @@ router.beforeEach((to, _from, next) => {
return;
}

// requires bceid info
const aStore = authStore();

aStore
Expand All @@ -155,8 +157,11 @@ router.beforeEach((to, _from, next) => {
aStore
.getUserInfo()
.then(() => {
if (to.meta.role && aStore.userInfo?.role !== to.meta.role) {
next('notfound')
if (
to.meta.requiresRole &&
!aStore.doesUserHaveRole(to.meta.requiresRole)
) {
next('notfound');
}
next();
})
Expand Down
Loading

0 comments on commit 815c765

Please sign in to comment.