Roles View

Roles View

To start this project, let’s add a new view and a new component to explore the roles available in our application.

First, let’s create a simple component skeleton in a new src/components/roles/ folder. We’ll name it the RolesList component:

<script setup>
/**
 * @file Roles List Component
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Import Libraries
import { ref } from 'vue'
import { api } from '@/configs/api'
import { Card } from 'primevue'

// Create Reactive State
const roles = ref([])

// Load Roles
api
  .get('/api/v1/roles')
  .then(function (response) {
    roles.value = response.data
  })
  .catch(function (error) {
    console.log(error)
  })
</script>

<template>
  <div>
    <Card v-for="role in roles" :key="role.id">
      <template #title>Role: {{ role.role }}</template>
    </Card>
  </div>
</template>

This component should look very familiar - it is based on the TestUser component we developed in the previous tutorial.

Next, we should create a RolesView.vue component in the src/views folder to load that component on a page:

<script setup>
import RolesList from '../components/roles/RolesList.vue'
</script>

<template>
  <RolesList />
</template>

After that, we should add this page to our router:

// -=-=- other code omitted here -=-=-

const router = createRouter({
  // Configure History Mode
  history: createWebHistory(import.meta.env.BASE_URL),

  // Configure routes
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue'),
    },
    {
      path: '/profile',
      name: 'profile',
      component: () => import('../views/ProfileView.vue'),
    },
    {
      path: '/roles',
      name: 'roles',
      component: () => import('../views/RolesView.vue'),
    },
  ],
})

// -=-=- other code omitted here -=-=-

Finally, let’s also add it to our list of menu options in the TopMenu component:

<script setup>
// -=-=- other code omitted here -=-=-

// Declare State
const items = ref([
  {
    label: 'Home',
    icon: 'pi pi-home',
    command: () => {
      router.push({ name: 'home' })
    },
  },
  {
    label: 'About',
    icon: 'pi pi-info-circle',
    command: () => {
      router.push({ name: 'about' })
    },
  },
  {
    label: 'Roles',
    icon: 'pi pi-id-card',
    command: () => {
      router.push({ name: 'roles' })
    },
  },
])
</script>

With those changes in place, we should be able to view the list of available roles in our application by clicking the new Roles link in the top menu bar:

Roles View Roles View

Our application will even redirect users to the CAS server to authenticate if they aren’t already logged in!

However, what if we log in using the user username instead of admin? Will this page still work? Unfortunately, because the /api/v1/roles API route requires a user to have the manage_users role, it will respond with an HTTP 401 error. We can see these errors in the console of our web browser:

Bad Connection Bad Connection

So, we need to add some additional code to our application to make sure that users only see the pages and links the are actually able to access.

Hiding Menu Items

First, let’s explore how we can hide various menu items from our top menu bar based on the roles assigned to our users. To enable this, we can tag each item in the menu that has restricted access with a list of roles that are able to access that page:

<script setup>
// -=-=- other code omitted here -=-=-

// Declare State
const items = ref([
  {
    label: 'Home',
    icon: 'pi pi-home',
    command: () => {
      router.push({ name: 'home' })
    },
  },
  {
    label: 'About',
    icon: 'pi pi-info-circle',
    command: () => {
      router.push({ name: 'about' })
    },
  },
  {
    label: 'Roles',
    icon: 'pi pi-id-card',
    command: () => {
      router.push({ name: 'roles' })
    },
    roles: ['manage_users']
  },
])

// -=-=- other code omitted here -=-=-
</script>

Then, we can create a Vue Computed Property to filter the list of items used in the template:

<script setup>
/**
 * @file Top menu bar of the entire application
 * @author Russell Feldhausen <russfeld@ksu.edu>
 */

// Import Libraries
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()

// Import Components
import Menubar from 'primevue/menubar'
import ThemeToggle from './ThemeToggle.vue'
import UserProfile from './UserProfile.vue'

// Stores
import { useTokenStore } from '@/stores/Token'
const tokenStore = useTokenStore()

// -=-=- other code omitted here -=-=-

const visible_items = computed(() => {
  return items.value.filter((item) => {
    // If the item lists any roles
    if (item.roles) {
      // Assume the user must be logged in to view it
      if (tokenStore.token.length > 0) {
        // If the roles is a string containing an asterisk
        if (item.roles == "*") {
          // Allow all roles to view
          return true;
        } else {
          // Otherwise, check if any role matches a role the user has
          return item.roles.some((r) => tokenStore.has_role(r))
        }
      } else {
        // If not logged in, hide item
        return false;
      }
    } else {
      // If no roles listed, show item even if not logged in
      return true;
    }
  })
})
</script>

In this function, we are filtering the the menu items based on the roles. If they have a set of roles listed, we check to see if it is an asterisk - if so, all roles are allowed. Otherwise, we assume that it is a list of roles, and check to see if at least one role matches a role that the user has by checking the token store’s has_role getter method. Finally, if no roles are listed, we assume that the item should be visible to users even without logging in.

To use this new computed property, we just replace the items entry in the template with the new computed_items property:

<template>
  <div>
    <Menubar :model="visible_items">
      <template #start>
        <img src="https://placehold.co/40x40" alt="Placeholder Logo" />
      </template>
      <template #end>
        <div class="flex items-center gap-1">
          <ThemeToggle />
          <UserProfile />
        </div>
      </template>
    </Menubar>
  </div>
</template>

That should properly hide menu items from the user based on their roles. Feel free to try it out!

Protecting Routes

Of course, hiding the item from the menu does not prevent the user from manually typing in the route path in the URL and trying to access the page that way. So, we must also add some additional logic to our router to ensure that user’s can’t access. For that, we can add a Per-Route Guard following a very similar approach. In our src/router/index.js file, we can add a new generator function to create a route guard based on roles, and then apply that guard as the beforeEnter property for a route:

// -=-=- other code omitted here -=-=-

/**
 * Router Guard Function to check for role before entering route
 * 
 * @param roles a list of roles permitted to enter the route
 * @return boolean true if the navigation is permitted, else returns to the home page
 */
const requireRoles = (...roles) => {
  return () => {
    const tokenStore = useTokenStore()
    const allow = roles.some((r) => tokenStore.has_role(r))
    if (allow) {
      // allow navigation
      return true;
    } else {
      // redirect to home
      return { name: 'home'}
    }
  }
}

const router = createRouter({
  // Configure History Mode
  history: createWebHistory(import.meta.env.BASE_URL),

  // Configure routes
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue'),
    },
    {
      path: '/profile',
      name: 'profile',
      component: () => import('../views/ProfileView.vue'),
    },
    {
      path: '/roles',
      name: 'roles',
      component: () => import('../views/RolesView.vue'),
      beforeEnter: requireRoles("manage_users")
    },
  ],
})

// -=-=- other code omitted here -=-=-

Now, even if we try to type the /roles path into the address bar in our web browser, it won’t allow us to reach that page unless we are logged in to a user account that has the correct role.

Reactive Style

We can also use Tailwind to add some reactive style to our components. For example, we can use the Grid layout options to place the components in a grid view:

<template>
  <div class="grid grid-cols-1 xl:grid-cols-4 lg:grid-cols-3 sm:grid-cols-2 gap-2">
    <Card v-for="role in roles" :key="role.id">
      <template #title>Role: {{ role.role }}</template>
    </Card>
  </div>
</template>

This will give us a responsive layout that adjusts the number of columns based on the width of the screen:

Responsive Layout Responsive Layout