Vue SPA + Laravel API autenticada con Passport – Parte 2 Vue SPA

En esta sección, tenemos dos opciones, crear una SPA desde Vue CLI totalmente independiente de Laravel o crear una SPA dentro de Laravel. Lo haremos dentro de Laravel.

Herramientas que vamos a utilizar:

  • Vue.js
  • VueRouter
  • Vuex
  • Bulma

No vamos a detenernos demasiado en explicar cada herramienta, este artículo supone que tienes un conocimiento básico y has trabajado antes con Vue.js.
Bulma es simplemente un framework puro de CSS.

Lo que sucede al crear una Vue SPA con Laravel como backend es:

  • El primer request llega al servidor, por lo tanto es procesado por el router de Laravel.
  • Laravel renderiza la SPA.
  • Los siguientes requests utilizan la API history.pushState para la navegación sin recargar la página (Vue router).

Configuraremos Vue router para utilizar history mode, lo que significa que necesitamos crear una ruta en Laravel que sea capaz de responder a todas las posibles URL y dirigirlas a la SPA.
Por ejemplo si el usuario quiere ingresar a /escritorio necesitamos responder a esa ruta con la SPA, luego Vue router será el encargado de renderizar el componente necesario.

1- Configurar el backend (Laravel)

Lo primero es definir en el archivo routes/web.php la ruta encargada de responder a todas las solicitudes que se realicen:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/{any}', '[email protected]')->where('any', '.*');

Crearemos el controlador SpaController:

php artisan make:controller SpaController

Una vez creado el controlador, añadiremos un método index:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class SpaController extends Controller
{
    public function index()
    {
        return view('spa');
    }
}

La instalación de Laravel, crea una vista predeterminada en resources/views/welcome.blade.php. La eliminaremos y crearemos una nueva vista en resources/views/spa.blade.php y añadiremos el siguiente código:

<!DOCTYPE html>
<html class="has-aside-left has-aside-expanded">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Vue SPA Demo</title>
    <link href="{{ mix('/css/app.css') }}" rel="stylesheet" />
</head>

<body>
    <div id="app">
        <app></app>
    </div>
    <script src="{{ mix('js/app.js') }}"></script>
</body>

</html>

2- Instalación de paquetes

Continuando con el proyecto de Laravel creado en la parte 1:

npm install vue vue-router vuex bulma

3- Añadir Bulma.io

Para añadir este framework CSS, lo que haremos es abrir el archivo resources/scss/app.scss:

//load bulma
@import "~bulma/bulma";

4- Configuración de los archivos JS

En la carpeta resources/js encontraremos dos archivos app.js y bootstrap.js.

app.js será nuestro punto de entrada a la SPA, por lo que allí realizaremos toda la configuración necesaria incluyendo la configuración de Vue router y de Vuex.

El siguiente paso sera crear una carpeta y archivos:

  • resources/router.js – encargado de las rutas (Vue router)
  • resources/store.js – encargado del estado de la aplicación (Vuex)
  • resources/views – carpeta que contendrá las vistas (páginas)
  • resources/views/App.vue
  • resources/views/NotFound.vue
  • resources/views/Login.vue
  • resources/views/Logout.vue
  • resources/views/Dashboard.vue

router.js

import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

// Pages
import NotFound from './views/NotFound'
import Login from './views/Login'
import Logout from './views/Logout'
import Dashboard from './views/Dashboard'

// Routes
const router = new VueRouter({
    mode: 'history',
    linkActiveClass: 'is-active',
    routes: [
        {
            path: '/login',
            name: 'login',
            component: Login,
        },
        {
            path: '/logout',
            name: 'logout',
            component: Logout,
            meta: {
                requiresAuth: true,
            }
        },
        {
            path: '/dashboard',
            name: 'dashboard',
            component: Dashboard,
            meta: {
                requiresAuth: true,
            }
        },
        { 
            path: '/404', 
            name: '404', 
            component: NotFound,
        },
        { 
            path: '*', 
            redirect: '/404', 
        },
    ],
});

export default router

Hemos configurado 4 rutas en el router:

  • /404 – para errores 404
  • /login – encargada de realizar el login y contendrá el formulario de ingreso
  • /logout – encargada de desconectarnos
  • /dashboard – nuestra ruta protegida

Hemos incluido también un campo meta dentro de alguna rutas, este será nuestro guardián. Lo definiremos luego en el archivo app.js.

store.js

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    token: localStorage.getItem('access_token') || null
  },
  getters: {
    loggedIn(state) {
      return state.token !== null
    }
  },
  mutations: {
    retrieveToken(state, token) {
      state.token = token
    },
    destroyToken(state) {
      state.token = null
    }
  },
  actions: {
    retrieveToken(context, credentials) {

      return new Promise((resolve, reject) => {
        axios.post('/api/login', {
          username: credentials.username,
          password: credentials.password,
        })
          .then(response => {
            //console.log(response)
            const token = response.data.access_token
            localStorage.setItem('access_token', token)
            context.commit('retrieveToken', token)

            resolve(response)
          })
          .catch(error => {
            //console.log(error)
            reject(error)
          })
      })

    },
    destroyToken(context) {

      axios.defaults.headers.common['Authorization'] = 'Bearer ' + context.state.token
      
      if (context.getters.loggedIn){
        
        return new Promise((resolve, reject) => {
          axios.post('/api/logout')
            .then(response => {
              //console.log(response)
              localStorage.removeItem('access_token')
              context.commit('destroyToken')
  
              resolve(response)
            })
            .catch(error => {
              //console.log(error)
              localStorage.removeItem('access_token')
              context.commit('destroyToken')

              reject(error)
            })
        })

      }
    }
  }
})

export default store

Vamos a explicar un poco que hace este archivo store.js.

Como verán, hemos definido state, getters, mutations y actions. Basicamente definiremos:

  • state es el contenedor donde se almacena el estado de la aplicación.
  • getters aquí ingresaremos funciones que se ejecutarán en varias partes de nuestra aplicación.
  • mutations definiremos funciones que modificarán el estado de la aplicación.
  • actions las acciones son similares a las mutaciones, la diferencia es que pueden contener operaciones asincrónicas, como por ejemplo una consulta a una API. Las acciones cometen mutaciones.

app.js

require('./bootstrap');

import Vue from 'vue'
import router from './router'
import store from './store'
import App from './views/App'

router.beforeEach((to, from, next) => {
    if (to.matched.some(record => record.meta.requiresAuth)) {
      // this route requires auth, check if logged in
      // if not, redirect to login page.
      if (!store.getters.loggedIn) {
        next({
          name: 'login',
        })
      } else {
        next()
      }
    } else {
      next() // make sure to always call next()!
    }
  })

const app = new Vue({
    el: '#app',
    components: { App },
    router,
    store
});

En nuestro archivo app.js cargaremos lo necesario para que nuestra SPA funcione correctamente, Vue, Vue router, Vuex y crearemos la instancia de la aplicación.

5- Creación de las vistas

App.vue

<template>
  <div>
    <section class="section">
      <main>
        <router-view></router-view>
      </main>
    </section>
  </div>
</template>

<script>
export default {};
</script>

App.vue es el contenedor de la aplicación, solo tenemos dentro el componente <router-view> de Vue router.

Dashboard.vue

<template>
  <h1 class="title">This is your Dashboard, you are logged in :)</h1>
</template>

<script>
export default {};
</script>

Dashboard.vue es una vista protegida a la que queremos llegar una vez realizado el login.

Login.vue

<template>
  <div>
    <div class="container">
      <div class="column is-4 is-offset-4">
        <div class="box">
          <h1 class="title">Login</h1>
          <div class="notification is-danger" v-if="error">
            <p>{{error}}</p>
          </div>
          <form autocomplete="off" @submit.prevent="login" method="post">
            <div class="field">
              <div class="control">
                <input type="email" class="input" placeholder="[email protected]" v-model="username" />
              </div>
            </div>
            <div class="field">
              <div class="control">
                <input type="password" class="input" v-model="password" />
              </div>
            </div>
            <button type="submit" class="button is-primary">Sign in</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      username: null,
      password: null,
      error: null
    };
  },
  methods: {
    login() {
      this.$store
        .dispatch("retrieveToken", {
          username: this.username,
          password: this.password
        })
        .then(response => {
          this.$router.push({ name: "dashboard" });
        })
        .catch(error => {
          this.error = error.response.data;
        });
    }
  }
};
</script>

Login.vue es una vista un poco más extensa, encargada de renderizar el formulario para ingresar el usuario y password. Tiene una responsabilidad importante que es enviar la solicitud a nuestra API hecha previamente en la parte 1 y recuperar un access-token de acceso. Una vez recuperado el access-token lo almacenaremos en el state (estado) de la aplicación, para tener ese dato en todo momento y acceder a nuestras vistas protegidas.

El método login() es el encargado de ejecutar una acción retrieveToken de Vuex, declarada en el archivo store.js.
Si la respuesta a la promesa es satisfactoria, ejecutaremos this.$router.push({ name: "dashboard" }); que nos llevará a la ruta que seleccionemos luego de realizar el login, en este caso a /dashboard.

Logout.vue

<template></template>

<script>
export default {
  created() {
    this.$store.dispatch("destroyToken").then(response => {
      this.$router.push({ name: "login" });
    });
  }
};
</script>

Logout.vue tiene como única finalidad, enviar una solicitud para destruir el access-token del estado de la aplicación. Por lo tanto ya no podremos ingresar a vistas protegidas. Una vez destruido el access-token redirigiros al usuario a /login.

NotFound.vue

<template>
  <div>
    <h1 class="title">Not Found</h1>
    <p>Woops! Looks like the page you requested cannot be found.</p>
  </div>
</template>

<script>
export default {};
</script>

NotFound.vue es la vista que mostraremos cuando un request devuelva un error 404.

6- Probando la SPA

Para realizar la prueba vamos a recordar los datos de usuario y password que estábamos utilizando en la parte 1:

Antes que nada, vamos a ejecutar en el terminal:

npm run watch

Si todo sale bien, se crearán los archivos CSS y JS.

El siguiente paso es abrir el navegador e ir a http://spa-auth.test/login. Ahí se nos mostrará la vista Login.vue definida previamente con el formulario.

Si por alguna razón ingresamos a http://spa-auth.test nos dará un error 404 ya que no tenemos definida ninguna ruta para /.

7- Conclusión: Parte 2 Vue SPA

Hemos creado una Single Page Application (SPA) con Vue.js dentro de Laravel. A su vez configuramos el router de Laravel para que responda a cualquier petición enviándola a la SPA, donde actúa el Vue router.

Hemos creado vistas con Vue, algunas protegidas, otras públicas.
Almacenamos en el estado de la aplicación, el access-token que necesitaremos para realizar consultas a nuestra API en un futuro.
No solo estamos autenticados en nuestra SPA sino que tambien haremos solicitudes de manera autenticada.

Por ejemplo, en un futuro podríamos crear las vistas:

/clients para listar clientes
/clients/create para crear un cliente
/clients/:id para mostrar los datos de un cliente
/clients/:id/edit para editar un cliente

Cualquiera de esas vistas deberá comunicarse con nuestra API, recuperando y enviando datos. Para cualquier intercambio de datos, lo haremos pasando nuestro access-token.

Este trabajo más la parte 1, la hemos incluido en un repo para que puedas ver y experimentar: https://github.com/danielcharrua/laravelvue-boilerplate

Si te he ayudado, comparte! 🤓


Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Nombre *