Courses

Vue.js 3 + Laravel 11 API + Vite: SPA CRUD

Permissions Front-end: Vue CASL & v-if

Summary of this lesson:
- Implementing CASL for Vue.js permissions
- Setting up frontend ability management
- Conditional rendering based on permissions
- Managing user abilities through API

Now that we have authorization for the back-end, let's add it for the front-end, too. If the user cannot delete the post, then he shouldn't even see the delete button.

delete action doesn't show


For front-end permissions, we will use the package CASL Vue. First, let's install it.

npm install @casl/vue @casl/ability

To get started, we need to import abilitiesPlugin and ability from the services in the main app.js.

resources/js/app.js:

import './bootstrap';
 
import { createApp, onMounted } from 'vue'
 
import router from './routes/index'
import VueSweetalert2 from 'vue-sweetalert2';
import useAuth from './composables/auth';
import { abilitiesPlugin } from '@casl/vue';
import ability from './services/ability';
 
createApp({
setup() {
const { getUser } = useAuth()
onMounted(getUser)
}
})
.use(router)
.use(VueSweetalert2)
.use(abilitiesPlugin, ability)
.mount('#app')

And what is inside this /services/ability? You define the abilities there, and one of the sections in the documentation is about ability builder. And we can copy the code in that services file.

resources/js/services/ability.js:

import { AbilityBuilder, Ability } from '@casl/ability'
 
const { can, cannot, build } = new AbilityBuilder(Ability);
 
export default build();

But instead of defining can and cannot here, we will define them based on the API call to the /abilities API endpoint. Now let's build the /abilities API route.

routes/api.php:

Route::group(['middleware' => 'auth:sanctum'], function() {
Route::apiResource('posts', PostController::class);
Route::get('categories', [CategoryController::class, 'index']);
Route::get('/user', function (Request $request) {
return $request->user();
});
 
Route::get('abilities', function(Request $request) {
return $request->user()->roles()->with('permissions')
->get()
->pluck('permissions')
->flatten()
->pluck('name')
->unique()
->values()
->toArray();
});
});

We get the authenticated user's roles with permissions. Then we pluck to have only permissions, and using other Collection methods, we get the unique permissions in the array list.

Now we need to use this API call in the auth Composable. For this, we will create a specific method getAbilities().

resources/js/composables/auth.js:

import { ref, reactive, inject } from 'vue'
import { useRouter } from 'vue-router';
import { AbilityBuilder, Ability } from '@casl/ability';
import { ABILITY_TOKEN } from '@casl/vue';
 
const user = reactive({
name: '',
email: '',
})
 
export default function useAuth() {
const processing = ref(false)
const validationErrors = ref({})
const router = useRouter()
const swal = inject('$swal')
const ability = inject(ABILITY_TOKEN)
const loginForm = reactive({
email: '',
password: '',
remember: false
})
 
// ...
 
const getAbilities = async() => {
axios.get('/api/abilities')
.then(response => {
const permissions = response.data
const { can, rules } = new AbilityBuilder(Ability)
 
can(permissions)
 
ability.update(rules)
})
}
 
return {
loginForm,
validationErrors,
processing,
submitLogin,
user,
getUser,
logout,
getAbilities
}
}

After a successful HTTP GET request, we assign all the permissions to a variable from the response. Then we add all the permissions into a can method and update the abilities.

To understand more about how this package works refer to the documentation.

And we call that getAbilities in the loginUser before router.push. And because we did await for the getAbilities now the loginUser becomes async and also we need to add await for the router.push.

import { ref, reactive, inject } from 'vue'
import { useRouter } from 'vue-router';
import { AbilityBuilder, Ability } from '@casl/ability';
import { ABILITY_TOKEN } from '@casl/vue';
 
const user = reactive({
name: '',
email: '',
})
 
export default function useAuth() {
// ...
 
const loginUser = (response) => {
const loginUser = async (response) => {
user.name = response.data.name
user.email = response.data.email
 
localStorage.setItem('loggedIn', JSON.stringify(true))
await getAbilities()
router.push({ name: 'posts.index' })
await router.push({ name: 'posts.index' })
}
 
// ...
}

And finally, we can get to the PostsIndex Vue component to hide the Edit and Delete actions if the user doesn't have permission for that action.

The syntax for the v-if directive is v-if="can('permission name here')".

resources/js/components/Posts/Index.vue:

<template>
<div class="overflow-hidden overflow-x-auto p-6 bg-white border-gray-200">
<div class="min-w-full align-middle">
<div class="mb-4 grid lg:grid-cols-4">
<input v-model="search_global" type="text" placeholder="Search..." class="inline-block mt-1 w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
</div>
 
<table class="min-w-full divide-y divide-gray-200 border">
// ...
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
<tr v-for="post in posts.data">
// ...
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900">
<router-link :to="{ name: 'posts.edit', params: { id: post.id } }">Edit</router-link>
<a href="#" @click.prevent="deletePost(post.id)" class="ml-2">Delete</a>
<router-link v-if="can('posts.update')" :to="{ name: 'posts.edit', params: { id: post.id } }">Edit</router-link>
<a href="#" v-if="can('posts.delete')" @click.prevent="deletePost(post.id)" class="ml-2">Delete</a>
</td>
</tr>
</tbody>
</table>
 
<TailwindPagination :data="posts" @pagination-change-page="page => getPosts(page, search_category)" class="mt-4" />
</div>
</div>
</template>
 
<script setup>
// ...
</script>

But we don't have that can defined yet! Let's add that.

resources/js/components/Posts/Index.vue:

<script setup>
import { onMounted, ref, watch } from "vue";
import { TailwindPagination } from 'laravel-vue-pagination';
import usePosts from "@/composables/posts";
import useCategories from "@/composables/categories";
import { useAbility } from '@casl/vue'
 
const search_category = ref('')
const search_id = ref('')
const search_title = ref('')
const search_content = ref('')
const search_global = ref('')
const orderColumn = ref('created_at')
const orderDirection = ref('desc')
const { posts, getPosts, deletePost } = usePosts()
const { categories, getCategories } = useCategories()
const { can } = useAbility()
 
// ...
</script>

Now, if you log in with the editor role, you should see that the Delete action isn't showed anymore.

delete action doesn't show

So yeah, that's it for this course!

Repository is available on GitHub here.

avatar

Hi Laravel daily, We got vue, react with laravel... Great effort by you. It will be really great if you have something related to typescript now. Thank you in advance

avatar

Sorry, not in plans, as our team doesn't actively work with typescript.

avatar
You can use Markdown
avatar

Now, I am able to understand how Vue.js+Laravel of QuickAdmin panel has been build. Really helpful.

avatar
You can use Markdown
avatar

"Ant what is inside..." should probably be "And what is inside..."

avatar

it should :D

avatar
You can use Markdown
avatar

great course, now can you help me about how I can make it ready for server/deployment?

avatar

In short build assets and deploy as you would regularly

avatar

I build the assests using "npm run build", now when i deploy on server i get wrning messages in console

Loading failed for the module with source "http://[::1]:5174/resources/js/app.js" Loading failed for the module with source “http://[::1]:5174/@vite/client”

and page keeps loading, nothing shows up. I know its not in the scope of the course, but if you could have written a lesson on how to deploy, then it would have been really helpful for newbies like me.

avatar

I guess you build assets locally which are git ignored by default. We have a tutorial how to build assets and deploy them. If you really need help come to discord where it will be easier to

avatar
You can use Markdown
avatar

I honestly don't understand why we need to complicate things, make another request, and install additional packages?

const user = reactive({
	name: '',
	email: '',
	abilities: []
})

export default function useAuth() {

	//...

	const loginUser = (response) => {
		user.name = response.data.name
		user.email = response.data.email
		user.abilities = response.data.abilities

		localStorage.setItem('loggedIn', JSON.stringify(true))
		router.push({ name: 'posts.index' })
	}

	//...

	const can = (ability) => {
		return user.abilities[ability] ?? false;
	}

	return { loginForm, validationErrors, processing, submitLogin, user, getUser, logout, can }
}
avatar

Thats a great point. Even if more permissions to check were added later that solution would still work.

avatar
You can use Markdown
avatar

This was a great lesson.

avatar
You can use Markdown
avatar

can anyone help on how to prevent direct url? and also example for menu navigation tab like using canany

avatar

What do you mean?

avatar
You can use Markdown
avatar
You can use Markdown