How to Implement Laravel Sanctum Authentication in a Nuxt.js Application?

Posted: August 16, 202411 min read 2125 word count

By: Ormel Flores

Website Development

apinuxtjsvuejs

Making 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.

  1. Create a .env file and put the following code inside:
API_HOST=http://your-api-domain
  1. We will be implement first all the API endpoints inside server/api. This is how you create the file: login.[method].ts. Create a login.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.

  1. 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.

  1. Create an ip-address folder inside server/api and add the following files: list.get.ts, store.post.ts and update\[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;
})
  1. Create a file for audit-logs.get.ts under server/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.