How to Implement Laravel Sanctum Authentication in a Nuxt.js Application?
Posted: August 16, 2024 • 11 min read 2125 word count
By: Ormel Flores
apinuxtjsvuejsMaking sure only the right folks can get into your web app isn't always a walk in the park, but picking the right gear can make it a whole lot easier.When mixing Laravel and Nuxt.js, Laravel Sanctum gives you a solid way to handle API login stuff.
Last time, we created a simple web-based API for IP address management. This time, we will implement that using Nuxt.js.
Step 1: Install Nuxt.js
Create a new Nuxt.js project:
npx nuxi@latest init ip-address-frontend
Change directory into your new project from your terminal:
cd ip-address-frontend
Now you'll be able to start your Nuxt app in development mode:
npm run dev -- -o
Step 2: Install TailwindCSS
We will be using tailwind css to make it easier for building front-end designs. Install tailwindcss via npm, and create your tailwind.config.js file.
npm install -D tailwindcss
npx tailwindcss init
Step 3: Configure your template paths
Add the paths to all of your template files in your tailwind.config.js file.
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue",
],
theme: {
extend: {},
},
plugins: [],
}
Step 4: Add the Tailwind directives to your CSS
Add the @tailwind directives for each of Tailwind’s layers to your main CSS file. I put my css inside assets folder.
/* /assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Step 5: Add your css file to nuxt configuration
Include your css file inside nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
devtools: { enabled: true },
css: ['~/assets/css/tailwind.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
runtimeConfig: {
public: {
host: process.env.API_HOST,
},
},
})
Step 6: Build user interface design
Build your own user interface design for the following pages: Login, Dashboard and Audit Logs.
Step 7: Implement Laravel API
Since we did not configure the stateful request on our laravel API, we will integrate this using Nuxt Server API.
- Create a
.envfile and put the following code inside:
API_HOST=http://your-api-domain
- We will be implement first all the API endpoints inside
server/api. This is how you create the file:login.[method].ts. Create alogin.post.tsand put the following code:
// server/api/login.post.ts
import { ref } from 'vue';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const body = await readBody(event)
const url = `${host}/api/v1/login`;
const status = ref(422);
const userAgent = event.node.req.headers['user-agent'] || 'Unknown';
const { data, pending }: any = await $fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': userAgent },
body: body,
});
const cookieOption: any = {
path: '/',
sameSite: 'strict',
};
if(typeof data !== 'undefined')
{
setCookie(event, 'token', data.accessToken, cookieOption)
setCookie(event, 'user', JSON.stringify(data.user), cookieOption)
status.value = 201;
}
return {status: status.value};
})
I store token and basic user information details inside the cookie browser. Additionally, I add user agent on the request header to make it easy for the API to trackdown which device is used.
- Create a
logout.post.tsfile and put the following code:
// server/api/logout.post.ts
import { ref } from 'vue';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const url = `${host}/api/v1/logout`;
const status = ref(422);
const accessToken = getCookie(event, 'token');
const userAgent = event.node.req.headers['user-agent'] || 'Unknown';
const data = await $fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
});
if(typeof data !== 'undefined')
{
setCookie(event, 'token', '', {
maxAge: 0,
path: '/',
sameSite: 'strict',
})
setCookie(event, 'user', '', {
maxAge: 0,
path: '/',
sameSite: 'strict',
})
status.value = 201;
}
return {status: status.value};
})
We need to make sure that the cookie will also be deleted once the user logged out.
- Create an
ip-addressfolder insideserver/apiand add the following files:list.get.ts,store.post.tsandupdate\[id].patch.ts
// list.get.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const url = `${host}/api/v1/ip-address`;
const query = await getQuery(event);
const accessToken = getCookie(event, 'token');
const data = await $fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
query: query
});
return data;
})
// store.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const body = await readBody(event)
const url = `${host}/api/v1/ip-address`;
const accessToken = getCookie(event, 'token');
const response = await $fetch.raw(url, {
method: 'POST',
baseURL: host,
headers: {
'Authorization': `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: body,
})
.then((response) => ({
data: response._data,
status: response.status
}))
return response;
})
// [id].patch.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const body = await readBody(event)
const id = event.context.params?.id;
const url = `${host}/api/v1/ip-address/${id}`;
const accessToken = getCookie(event, 'token');
const response = await $fetch.raw(url, {
method: 'PATCH',
baseURL: host,
headers: {
'Authorization': `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: body,
})
.then((response) => ({
data: response._data,
status: response.status
}))
return response;
})
- Create a file for
audit-logs.get.tsunderserver/apidirectory
// audit-logs.get.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const host = config.public.host;
const url = `${host}/api/v1/audit-logs`;
const query = await getQuery(event);
const accessToken = getCookie(event, 'token');
const data = await $fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
query: query
});
return data;
})
Now that we already implemented the backend API endpoints, let's try to apply this to our front end.
Step 8: Apply Nuxt server API
Let's try it first on our login page.
Login
<!-- pages/login.vue -->
<script setup >
import InputError from '~/components/InputError.vue';
import InputLabel from '~/components/InputLabel.vue';
import TextInput from '~/components/TextInput.vue';
useHead({
titleTemplate: ' Login'
})
definePageMeta({
middleware: 'guest',
})
const isProcessing = ref(false);
const form = ref({
email: '',
password: '',
});
const errors = ref({
email: null,
password: null,
general: null,
});
const submit = async () => {
isProcessing.value = true;
errors.value.email = null;
errors.value.password = null;
errors.value.general = null;
await $fetch('/api/login', {
method: 'POST',
body: form.value,
}).catch((error) => {
errors.value.email = error.data.data.errors?.email ? error.data.data.errors?.email[0] : '';
errors.value.password = error.data.data.errors?.password ? error.data.data.errors?.password[0] : '';
errors.value.general = error.data.data.errors?.details;
isProcessing.value = false;
}).then((res) => {
if(typeof res !== 'undefined' && res.status == 201)
{
navigateTo('/dashboard');
isProcessing.value = false;
}
else
{
isProcessing.value = false;
}
})
}
</script>
<template>
<div class="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8 mt-28">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-10 w-auto" src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company" />
<h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">Sign in to your account</h2>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<div class="mt-2">
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
autofocus
:class="{ 'border-2 border-red-500': errors.email }"
/>
</div>
<InputError class="mt-2" v-if="errors.email" :message="errors.email"></InputError>
</div>
<div>
<InputLabel for="password" value="Password" />
<div class="mt-2">
<TextInput
id="password"
type="password"
v-model="form.password"
class="mt-1 block w-full"
required
:class="{ 'border-2 border-red-600': errors.password }"
/>
</div>
<InputError v-if="errors.password" :message="errors.password"></InputError>
</div>
<InputError v-if="errors.general" :message="errors.general"></InputError>
<div>
<button type="submit" :class="{ 'opacity-25': isProcessing }" :disabled="isProcessing" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
</div>
</form>
</div>
</div>
</template>
In this code, you'll notice that we have a middleware. This is to make sure that the authenticated user will not have an access to login. Additionally, it will prevent the user to login multiple times. I also added displaying error messages from our API.
Dashboard
<!-- pages/dashboard.vue -->
<script setup>
import swal from 'sweetalert';
import AuthenticatedLayout from '~/components/AuthenticatedLayout.vue';
import InputError from '~/components/InputError.vue';
import InputLabel from '~/components/InputLabel.vue';
import IpAddress from '~/components/IpAddress.vue';
import Modal from '~/components/Modal.vue';
import PrimaryButton from '~/components/PrimaryButton.vue';
import Table from '~/components/Table.vue';
import TextInput from '~/components/TextInput.vue';
useHead({
titleTemplate: ' Dashboard'
})
definePageMeta({
middleware: 'auth',
})
const isProcessing = ref(false);
const modalActive = ref(false);
const ipAddressList = ref([]);
const addForm = ref({
ip_address: '',
label: '',
});
const errors = ref({
data: {
ip_address: [],
label: [],
},
status: 422,
message: ''
})
onMounted(async () => {
ipAddressList.value = await $fetch('/api/ip-address/list')
});
const nextPage = async (value) => {
ipAddressList.value= await $fetch('/api/ip-address/list', {
query: {ip_addresses: value}
})
}
const saveIpAddress = async () => {
isProcessing.value = true;
clearErrors
await $fetch('/api/ip-address/store', {
method: 'POST',
body: addForm.value,
})
.catch((error) => {
if(error.data.statusCode === 422)
{
errors.value.message = error.data.statusMessage;
errors.value.status = error.data.statusCode;
errors.value.data = error.data.data.errors;
}
else
{
errors.value.message = error.data.data.message;
errors.value.status = error.data.statusCode;
}
isProcessing.value = false;
})
.then((res) => {
if(typeof res !== 'undefined' && res.status == 201)
{
swal({
icon: 'success',
title: 'Success',
text: 'The IP address has been saved successfully.',
buttons: false,
timer: 3000
}).then((rers) => {
modalActive.value = false;
location.reload();
isProcessing.value = false;
});
}
else
{
isProcessing.value = false;
}
})
}
const clearErrors = () => {
errors.value.data = {
ip_address: [],
label: [],
};
errors.value.status = 422;
errors.value.message = '';
}
</script>
<template>
<AuthenticatedLayout title="">
<Table :title="'IP Addresses'" :description="'Showing IP Address'">
<template #action>
<PrimaryButton type="button" class="ml-4" @click="modalActive = true">
Add IP
</PrimaryButton>
</template>
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b bg-gray-50">
<th class="px-4 py-3">IP Address</th>
<th class="px-4 py-3">Label</th>
<th class="px-4 py-3">Created At</th>
<th class="px-4 py-3">Updated At</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="bg-white divide-y" v-if="ipAddressList.data && ipAddressList.data.length > 0">
<IpAddress v-for="ip_address in ipAddressList.data"
:key="ip_address.id"
:ip_address="ip_address"
>
</IpAddress>
</tbody>
<tbody class="bg-white divide-y" v-else>
<tr class="text-gray-700">
<td class="px-4 py-3 text-sm font-bold" colspan="2">
There are no records as of this moment.
</td>
</tr>
</tbody>
</table>
</div>
<!-- PAGINATION -->
<div
class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t bg-gray-50 sm:grid-cols-9"
v-if="ipAddressList.meta && ipAddressList.meta.links && ipAddressList.meta.links.length > 3">
<span class="flex items-center col-span-3"></span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li v-for="(link, k) in ipAddressList.meta.links" :key="k">
<a class="px-3 py-1 rounded-md focus:outline-hidden focus:shadow-outline-gray cursor-pointer"
v-html="link.label" @click="nextPage(link.url.split('ip_addresses=')[1])"
:class="{'px-3 py-1 text-white transition-colors duration-150 bg-gray-800 border border-r-0 border-gray-800 rounded-md focus:outline-hidden focus:shadow-outline-gray': link.active}">
</a>
</li>
</ul>
</nav>
</span>
</div>
</Table>
<!-- ADD MODAL -->
<Modal v-show="modalActive" @keydown.escape.prevent.stop="modalActive = false" @click="modalActive = false">
<template v-slot:header>
<h3 class="text-xl font-semibold text-gray-900 ">
Add IP Address
</h3>
</template>
<template v-slot:button>
<button type="button" @click="modalActive = false"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center"
data-modal-hide="default-modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close modal</span>
</button>
</template>
<template v-slot:form>
<form @submit.prevent="saveIpAddress">
<div
class="mt-6 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-1 xl:grid-cols-1 gap-2 items-center">
<div class="relative mb-2">
<InputLabel for="ip-address" :value="'IP Address'" />
<TextInput id="ip-address" type="text"
class="mt-1 block w-full"
v-model="addForm.ip_address"
autofocus
:class="errors.data.ip_address && errors.data.ip_address.length > 0 ? 'border-2 border-red-600' : ''"
/>
<div v-if="errors.data.ip_address && errors.data.ip_address.length > 0">
<InputError :message="errors.data.ip_address[0]"></InputError>
</div>
</div>
<div class="relative mb-2">
<InputLabel for="label" value="Label" />
<TextInput id="label" type="text"
class="mt-1 block w-full"
v-model="addForm.label"
autofocus
:class="errors.data.label && errors.data.label.length > 0 ? 'border-2 border-red-600' : ''"
/>
<div v-if="errors.data.label && errors.data.label.length > 0">
<InputError :message="errors.data.label[0]"></InputError>
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-end p-4 md:p-5 border-t border-gray-200 rounded-b">
<PrimaryButton type="submit"
:class="{ 'opacity-25': isProcessing }"
:disabled="isProcessing"
class="ml-4">
Create
</PrimaryButton>
<InfoButton type="button" class="ml-4" @click="modalActive = false">Cancel</InfoButton>
</div>
</form>
</template>
</Modal>
</AuthenticatedLayout>
</template>
On this page, we need to display the records of IP addresses and manage it within the table. That's why I apply modal component. Additionally, we have to make sure that this page is only accessible by authenticated users. Here's how we do it:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('token');
if (!token.value) {
abortNavigation();
return navigateTo('/login');
}
})
To check if the user was already logged in, we need to track if the cookie for token has a value. If it does not have any value, it will be redirected to login page. The same thing goes for our Audit Logs:
Audit Logs
<!-- pages/audit-logs.vue -->
<script setup>
import AuditLog from '~/components/AuditLog.vue';
import AuthenticatedLayout from '~/components/AuthenticatedLayout.vue';
import Table from '~/components/Table.vue';
useHead({
titleTemplate: ' Audit Logs'
})
definePageMeta({
middleware: 'auth',
})
const auditLogs = ref([]);
onMounted(async () => {
auditLogs.value = await $fetch('/api/audit-logs')
});
const nextPage = async (value) => {
auditLogs.value= await $fetch('/api/audit-logs', {
query: {audit_logs: value}
})
}
</script>
<template>
<AuthenticatedLayout title="">
<Table :title="'Audit Logs'" :description="'Showing Audit Logs'">
<template #action>
</template>
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b bg-gray-50">
<th class="px-4 py-3">Action</th>
<th class="px-4 py-3">User</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Timestamp</th>
</tr>
</thead>
<tbody class="bg-white divide-y" v-if="auditLogs.data && auditLogs.data.length > 0">
<AuditLog v-for="audit_log in auditLogs.data"
:key="audit_log.id"
:audit_log="audit_log"
>
</AuditLog>
</tbody>
<tbody class="bg-white divide-y" v-else>
<tr class="text-gray-700">
<td class="px-4 py-3 text-sm font-bold" colspan="2">
There are no records as of this moment.
</td>
</tr>
</tbody>
</table>
</div>
<!-- PAGINATION -->
<div
class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t bg-gray-50 sm:grid-cols-9"
v-if="auditLogs.meta && auditLogs.meta.links && auditLogs.meta.links.length > 3">
<span class="flex items-center col-span-3"></span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li v-for="(link, k) in auditLogs.meta.links" :key="k">
<a class="px-3 py-1 rounded-md focus:outline-hidden focus:shadow-outline-gray cursor-pointer"
v-html="link.label" @click="nextPage(link.url.split('audit_logs=')[1])"
:class="{'px-3 py-1 text-white transition-colors duration-150 bg-gray-800 border border-r-0 border-gray-800 rounded-md focus:outline-hidden focus:shadow-outline-gray': link.active}">
</a>
</li>
</ul>
</nav>
</span>
</div>
</Table>
</AuthenticatedLayout>
</template>
Conclusion
By following the steps outlined above, you can ensure that your Laravel backend and Nuxt.js frontend work seamlessly together, providing a smooth and secure user experience.
Yay!! We have successfully implemented our back end API. You are now one step ahead of becoming a Full stack developer.
Feel free to adapt and extend these instructions based on your specific requirements and project structure. Happy coding!
You can check the repository here: ormelflores/ip-address-front-end
This post is licensed under CC BY 4.0 by the author.
Share this post
Related Topics
More from the blog
August 30, 2024
Deploying Nuxt.js to Cloudflare for Free
Learn how to easily deploy your Nuxt.js app on Cloudflare for better performance and reliability.
August 14, 2024
How to Set Up a Laravel REST API: A Step-by-Step Tutorial
Learn the step-by-step process of setting up a robust REST API using Laravel.