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
.env
file 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.ts
and 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.ts
file 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-address
folder insideserver/api
and add the following files:list.get.ts
,store.post.ts
andupdate\[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.ts
underserver/api
directory
// 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-sm 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-none 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-none 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-none 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-none 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.
Related Topics