Si tu frontend está hecho con React, en este tutorial verás cómo añadirle a una aplicación de Laravel un acceso sin contraseña. El funcionamiento es sencillo: el usuario indica su email, le llega un código de seis cifras y, al escribirlo, entra en su cuenta, que se crea de forma automática la primera vez.

Para conectar React con Laravel usaremos Inertia, que nos permite escribir las pantallas como componentes de React mientras las rutas, la validación y la sesión siguen en manos de Laravel.

Introducción

Esta es la versión para React de una serie de tutoriales sobre el mismo login. Toda la lógica del código de acceso, desde cómo se genera y se guarda hasta los límites de envío, está explicada en el tutorial de cómo crear un login sin contraseña en Laravel, y la configuración de Inertia en el servidor coincide con la del tutorial de cómo crear un login sin contraseña con Laravel, Inertia y Vue. Aquí nos centraremos en la parte de React.

La protección frente a bots corre a cargo de Cloudflare Turnstile. Para mostrarlo en React usaremos el componente que incluye Laracaptcha, que se encarga de cargar el script de Cloudflare, dibujar el widget y entregarnos el token.

Si es la primera vez que trabajas con React, te recomiendo empezar por aquí:

Todo el código está en el repositorio del proyecto en GitHub. Así se ve la pantalla de acceso una vez terminada:

Pantalla de acceso sin contraseña con Inertia, React y Cloudflare Turnstile

Preparación del proyecto

Comprueba antes que tienes PHP 8.3 o una versión posterior, composer y Node.js en tu equipo.

Instala Laravel, Inertia y React

Partimos de un proyecto de Laravel nuevo, sin kit de inicio, ya que los kits traen un login con contraseña que no nos sirve:

composer create-project laravel/laravel login-react
cd login-react
composer require inertiajs/inertia-laravel edulazaro/laracaptcha
npm install react react-dom @inertiajs/react @vitejs/plugin-react

Con el primer composer require añadimos el adaptador de Inertia para Laravel y Laracaptcha, y con npm install instalamos React, su adaptador de Inertia y el plugin que permite a Vite compilar JSX.

Configura el servidor

En la parte de Laravel, Inertia necesita su middleware y una vista raíz. Genera el middleware con php artisan inertia:middleware, regístralo en bootstrap/app.php y comparte en él el usuario y los mensajes de estado. La vista raíz resources/views/app.blade.php es igual que en la versión con Vue, salvo por una línea:

@viteReactRefresh
@vite('resources/js/app.jsx')
@inertiaHead

La directiva @viteReactRefresh activa la recarga en caliente de React mientras trabajas con npm run dev, de modo que los cambios en los componentes se ven al instante sin perder su estado. Debe ir antes de @vite.

Configura Vite

En el archivo vite.config.js, el punto de entrada pasa a ser un archivo .jsx y el plugin es el de React. El alias @laracaptcha apunta a los componentes que Laracaptcha incluye dentro del paquete de composer:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.jsx'], refresh: true }),
        react(),
    ],
    resolve: {
        alias: {
            '@laracaptcha': new URL('./vendor/edulazaro/laracaptcha/resources/js', import.meta.url).pathname,
        },
    },
});

Arranca React con Inertia

Elimina el archivo resources/js/app.js y crea en su lugar resources/js/app.jsx:

import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';

const appName = import.meta.env.VITE_APP_NAME;

createInertiaApp({
    title: (title) => (title ? `${title} · ${appName}` : appName),
    resolve: (name) => {
        const pages = import.meta.glob('./pages/**/*.jsx', { eager: true });

        return pages[`./pages/${name}.jsx`];
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />);
    },
});

En la función setup, createRoot crea la raíz de React sobre el elemento que genera la directiva @inertia, y a partir de ahí Inertia se ocupa de cambiar de página. Cada vez que el servidor renderiza una página, por ejemplo auth/Login, la función resolve carga el componente resources/js/pages/auth/Login.jsx.

El controlador

El controlador LoginController es exactamente el mismo que en la versión con Vue. Recuerda los dos puntos que lo diferencian de la versión con Blade: devuelve páginas con Inertia::render y le pasa a la página del login el resultado de Captcha::props():

return Inertia::render('auth/Login', ['captcha' => Captcha::props()]);

El token llega en un campo llamado captcha, que se valida con la regla CaptchaRule de Laracaptcha. Gracias a que el proveedor viaja en esas props, el mismo componente de React funcionará tanto con Turnstile como con reCAPTCHA v2 o v3, según lo que indique el .env.

Los componentes de React

Las páginas de la aplicación son componentes de React que reciben como props los datos que les envía el controlador.

Crea la página del email

La página resources/js/pages/auth/Login.jsx recibe la prop captcha y muestra el formulario:

import { Head, useForm } from '@inertiajs/react';
import { useRef } from 'react';
import { Captcha } from '@laracaptcha/react';

export default function Login({ captcha }) {
    const { data, setData, post, processing, errors } = useForm({ email: '', captcha: '' });
    const captchaWidget = useRef(null);

    function submit(event) {
        event.preventDefault();

        post('/login', {
            onError: () => captchaWidget.current?.reset(),
        });
    }

    return (
        <>
            <Head title="Entrar" />

            <main className="card">
                <h1>Entrar</h1>

                <form onSubmit={submit}>
                    <label htmlFor="email">Email</label>
                    <input id="email" type="email" value={data.email} onChange={(event) => setData('email', event.target.value)} required autoFocus />
                    {errors.email && <p className="error">{errors.email}</p>}

                    <Captcha ref={captchaWidget} {...captcha} language="es" onToken={(token) => setData('captcha', token)} />
                    {errors.captcha && <p className="error">{errors.captcha}</p>}

                    <button type="submit" disabled={processing}>Enviarme el código</button>
                </form>
            </main>
        </>
    );
}

Veamos qué hace cada parte:

  • useForm: El hook de formularios de Inertia nos devuelve los datos del formulario, la función setData para modificarlos, post para enviarlos y los errores de validación del servidor. Más detalles en la guía de formularios de Inertia.
  • {...captcha}: Al expandir la prop, el componente recibe el proveedor, la clave pública y la URL del script.
  • onToken: React no tiene un equivalente a v-model, así que el componente nos avisa con esta función cada vez que cambia el token. Al resolver el desafío recibimos el token, y si caduca o falla recibimos una cadena vacía, por lo que el formulario nunca envía un token inválido.
  • ref: Nos da acceso al método reset del componente. Como cada token solo se puede verificar una vez, cuando el servidor rechaza el envío pedimos otro desde onError.

Con reCAPTCHA v3 no se muestra ningún desafío: el componente obtiene el token al montarse y lo renueva antes de que caduque, y onToken funciona igual.

Crea la página del código

En resources/js/pages/auth/Code.jsx usamos dos instancias de useForm, una para comprobar el código y otra para pedir uno nuevo. Esta vez no desestructuramos el resultado, ya que necesitamos distinguir los datos de cada formulario:

export default function Code({ email }) {
    const { status } = usePage().props;
    const form = useForm({ code: '' });
    const resend = useForm({});

    function submit(event) {
        event.preventDefault();

        form.post('/login/codigo');
    }

    function sendAnother() {
        resend.post('/login/codigo/reenviar', {
            onSuccess: () => form.reset('code'),
        });
    }

    const error = form.errors.code || resend.errors.code;

    // ...
}

El hook usePage da acceso a las props compartidas por el middleware, como el mensaje que el controlador guarda en la sesión al reenviar el código. En el campo del código usamos inputMode="numeric" y autoComplete="one-time-code", para que el móvil muestre el teclado numérico y sugiera el código que acaba de llegar:

Pantalla para introducir el código con Inertia y React

Crea el panel

El panel lee el usuario de las props compartidas y cierra la sesión enviando una petición con el router de Inertia:

import { Head, router, usePage } from '@inertiajs/react';

export default function Dashboard() {
    const { user } = usePage().props.auth;

    return (
        <main className="card">
            <h1>Hola, {user.name}</h1>
            <button type="button" onClick={() => router.post('/logout')}>Cerrar sesión</button>
        </main>
    );
}

Compila los componentes con npm run build y arranca el servidor con php artisan serve para probar la aplicación.

Tests

Los tests se escriben en PHP, con las aserciones de Inertia para comprobar qué componente de React renderiza cada ruta y con qué props. Así nos aseguramos, por ejemplo, de que el panel recibe el usuario correcto:

$this->actingAs($user)->get('/panel')->assertInertia(fn (Assert $page) => $page
    ->component('Dashboard')
    ->where('auth.user.email', $user->email));

En los tests, Captcha::fake() hace que las props del captcha no lleven script, de modo que el componente no se dibuja y no se contacta con Cloudflare. El repositorio incluye once tests y puedes ampliar información en la documentación de testing de Inertia.

Finalizando

Con esto tendrás tu login sin contraseña funcionando con Laravel, Inertia y React. Para publicarlo necesitarás un servidor de correo real, tus propias claves de Turnstile y los registros SPF de tu dominio, además de ejecutar npm run build al desplegar.

Si quieres comparar enfoques, estas son las demás versiones de este login:

Puedes consultar el código del proyecto en GitHub, y las demás opciones del paquete en la documentación de Laracaptcha.

Esto ha sido todo. ¡Espero que te resulte útil!