UNPKG

@xsprtd/nuxt-api

Version:

Nuxt API Authentication and Http Client

2,096 lines (1,735 loc) 67.8 kB
# Nuxt API Authentication and Http Client A comprehensive Nuxt 3 module providing seamless Laravel Sanctum authentication and HTTP client functionality. This module supports both cookie-based (SPA) and token-based authentication with built-in error handling, TypeScript support, and automatic CSRF protection. This module is based on [@qirolab/nuxt-sanctum-authentication](https://github.com/qirolab/nuxt-sanctum-authentication) with significant enhancements and additional functionality. ## Features - **Dual Authentication Modes**: Cookie-based (SPA) and Token-based authentication - **Laravel Sanctum Integration**: Built specifically for Laravel Sanctum with automatic CSRF handling - **Type-Safe HTTP Client**: Full TypeScript support with generics for requests and responses - **Auto-imported Composables**: Ready-to-use authentication and HTTP methods - **Error Handling**: Comprehensive error bag system for validation and API errors - **Route Protection**: Built-in middleware for authenticated and guest-only routes - **Flexible Storage**: Token storage in cookies or localStorage - **Processing States**: Built-in loading states for all async operations - **Custom Headers**: Global and per-request custom header support - **Retry Logic**: Configurable retry attempts for failed requests - **SSR Compatible**: Full server-side rendering support --- ## Installation Install the module using npm: ```shell npm i @xsprtd/nuxt-api ``` --- ## Configuration Within your `nuxt.config.ts` add the `@xsprtd/nuxt-api` to the list of modules and the `nuxtApi` entry and overwrite the relevant options. ```javascript export default defineNuxtConfig({ modules: [ //... '@xsprtd/nuxt-api' //... ], //... nuxtApi: { apiBaseURL: process.env.API_BASE_URL, authMode: 'cookie', userStateKey: 'user', headers: {}, token: { storageKey: 'AUTH_TOKEN', storageType: 'cookie', responseKey: 'token', }, fetchOptions: { retryAttempts: false, }, csrf: { cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN', }, endpoints: { csrf: '/sanctum/csrf-cookie', login: '/api/login', logout: '/api/logout', user: '/api/user', }, redirect: { intendedEnabled: false, login: '/login', postLogin: '/dashboard', postLogout: '/login', }, middlewareNames: { auth: false, guest: false, }, errorMessages: { default: 'Whoops - something went wrong', csrf: 'CSRF token mismatch', unauthenticated: 'Unauthenticated', }, }, //... }) ``` <details> <summary>The list of all currently available options</summary> ```typescript interface ModuleOptions { /** * The base URL of the API server. * @example http://localhost:8000 */ apiBaseURL: string /** * The current application base URL for the Referrer and Origin header. * @example 'http://localhost:3000' */ originUrl?: string /** * The authentication mode. */ authMode: 'cookie' | 'token' /** * The key to use to store the authenticated user in the `useState` variable. */ userStateKey: string /** * Defines the key used to extract user data from the `endpoints.user` API response. * * Example usage: for response `{ user: { ... } }` it would be `user` */ userResponseKey?: null | string /** * The token specific options. */ token: { /** * The key to store the token in the storage. */ storageKey: string /** * The storage type to use for the token. */ storageType: 'cookie' | 'localStorage' /** * Defines the key used to extract user data from the `endpoints.login` API response. * * Example usage: for response `{ auth_token: { ... } }` it would be `auth_token` */ responseKey: string } /** * Fetch options. */ fetchOptions: { /** * The number of times to retry a request when it fails. */ retryAttempts: number | false } /** * CSRF token options. */ csrf: { /** * Name of the CSRF cookie to extract from server response. */ cookieName: string /** * Name of the CSRF header to pass from client to server. */ headerName: string } /** * API endpoints. */ endpoints: { /** * The endpoint to obtain a new CSRF token. */ csrf: string /** * The authentication endpoint. */ login: string /** * The logout endpoint. */ logout: string /** * The endpoint to fetch current user data. */ user: string } /** * Redirect specific settings. */ redirect: { /** * Specifies whether to retain the requested route when redirecting after login. */ intendedEnabled: boolean /** * Redirect path when access requires user authentication. * Throws a 403 error if set to false. */ login: string | false /** * Redirect path after a successful login. * No redirection if set to false. */ postLogin: string | false /** * Redirect path after a logout. * No redirection if set to false. */ postLogout: string | false } middlewareNames: { /** * Middleware name for authenticated users. * Set to a string to register the middleware with that name. * Set to `false` to disable automatic registration (default). */ auth: string | false /** * Middleware name for guest users. * Set to a string to register the middleware with that name. * Set to `false` to disable automatic registration (default). */ guest: string | false } errorMessages: { /** * A default error message. */ default: string /** * Error message to display when csrf token isn't valid. */ csrf: string /** * Error message to display when user is not-authenticated. */ unauthenticated: string } } ``` </details> --- ## Laravel Sanctum Backend Setup (Laravel 11+) Before using this module, you need to configure Laravel Sanctum on your backend. Below are complete setup instructions for Laravel 11 and newer. > **Note**: Laravel 11+ includes Sanctum by default. If you're using Laravel 10 or older, you'll need to install Sanctum manually with `composer require laravel/sanctum`. ### 1. Install & Publish Sanctum (if needed) For fresh Laravel 11+ installations, Sanctum is already installed. Just publish the configuration: ```bash php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate ``` For older Laravel versions: ```bash composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate ``` ### 2. Configure CORS Update `config/cors.php`: ```php return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => explode(',', env('FRONTEND_URL', 'http://localhost:3000')), 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => true, ]; ``` ### 3. Configure Sanctum Update `config/sanctum.php`: ```php return [ /* |-------------------------------------------------------------------------- | Stateful Domains |-------------------------------------------------------------------------- */ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s%s', 'localhost,localhost:3000,localhost:8000,127.0.0.1,127.0.0.1:8000,::1', env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '', env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : '' ))), /* |-------------------------------------------------------------------------- | Sanctum Guards |-------------------------------------------------------------------------- */ 'guard' => ['web'], /* |-------------------------------------------------------------------------- | Expiration Minutes |-------------------------------------------------------------------------- */ 'expiration' => null, /* |-------------------------------------------------------------------------- | Token Prefix |-------------------------------------------------------------------------- */ 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), /* |-------------------------------------------------------------------------- | Sanctum Middleware |-------------------------------------------------------------------------- */ 'middleware' => [ 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, ], ]; ``` ### 4. Configure Middleware (Laravel 11+) In Laravel 11+, middleware is configured in `bootstrap/app.php` instead of `app/Http/Kernel.php`: ```php <?php use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { $middleware->api(prepend: [ \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, ]); $middleware->alias([ 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]); // For cookie-based authentication, ensure stateful API $middleware->statefulApi(); }) ->withExceptions(function (Exceptions $exceptions) { // })->create(); ``` **For Laravel 10 and older**, configure in `app/Http/Kernel.php`: ```php 'api' => [ \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ``` ### 5. Update Environment Variables Add to `.env`: ```env # Application URLs APP_URL=http://localhost:8000 FRONTEND_URL=http://localhost:3000 # Sanctum Configuration SANCTUM_STATEFUL_DOMAINS=localhost:3000,localhost # Session Configuration SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_DOMAIN=localhost SESSION_SECURE_COOKIE=false SESSION_SAME_SITE=lax # For production, use these settings: # SESSION_DRIVER=database # SESSION_SECURE_COOKIE=true # SESSION_SAME_SITE=strict ``` **Important**: For cookie-based authentication, ensure your session driver is set to `database`, `redis`, or `memcached` (not `file`) for better reliability in API contexts. Create the sessions table if using database driver: ```bash php artisan make:session-table php artisan migrate ``` ### 6. API Routes Create authentication routes in `routes/api.php`: ```php <?php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; /* |-------------------------------------------------------------------------- | Cookie-Based Authentication (SPA Mode) |-------------------------------------------------------------------------- */ // Public routes Route::post('/login', function (Request $request) { $credentials = $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); if (!Auth::attempt($credentials, $request->boolean('remember'))) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); } $request->session()->regenerate(); return response()->json([ 'user' => Auth::user(), 'message' => 'Logged in successfully', ]); }); // Protected routes Route::middleware('auth:sanctum')->group(function () { Route::get('/user', function (Request $request) { return $request->user(); }); Route::post('/logout', function (Request $request) { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return response()->json([ 'message' => 'Logged out successfully' ]); }); // Your protected routes here... Route::apiResource('products', ProductController::class); }); /* |-------------------------------------------------------------------------- | Token-Based Authentication (API Mode) |-------------------------------------------------------------------------- */ Route::post('/auth/login', function (Request $request) { $credentials = $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); if (!Auth::attempt($credentials)) { return response()->json([ 'message' => 'Invalid credentials', 'errors' => [ 'email' => ['The provided credentials are incorrect.'] ] ], 422); } $user = Auth::user(); // Create token with abilities/scopes if needed $token = $user->createToken( $request->input('device_name', 'api-token'), ['*'], // Abilities now()->addDays(30) // Expiration )->plainTextToken; return response()->json([ 'token' => $token, 'user' => $user, ]); }); Route::middleware('auth:sanctum')->post('/auth/logout', function (Request $request) { // Revoke current token $request->user()->currentAccessToken()->delete(); // Or revoke all tokens: // $request->user()->tokens()->delete(); return response()->json([ 'message' => 'Logged out successfully' ]); }); ``` ### 7. User Model Configuration Ensure your `User` model uses the `HasApiTokens` trait (required for token-based auth): ```php <?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { use HasApiTokens, Notifiable; protected $fillable = [ 'name', 'email', 'password', ]; protected $hidden = [ 'password', 'remember_token', ]; protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; } } ``` ### 8. Testing Your Setup Test that CSRF cookies are working: ```bash curl -X GET http://localhost:8000/sanctum/csrf-cookie \ -H "Accept: application/json" \ -H "Origin: http://localhost:3000" \ -c cookies.txt -v ``` Test login: ```bash curl -X POST http://localhost:8000/api/login \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Origin: http://localhost:3000" \ -d '{"email":"user@example.com","password":"password"}' \ -b cookies.txt -c cookies.txt -v ``` --- ## Usage The module comes with two main composables `useAuth`, which takes care of the authentication and `useHttp` to handle http calls to your api gateway using the most common request methods: `get`, `post`, `put`, `patch` and `destroy` (since `delete` is a reserved keyword). ### Authentication (useAuth) To authenticate the user against laravel sanctum, use the `login` method of the `useAuth` composable - here's an example of how this can be achieved in combination with [NuxtUiPro Form](https://ui.nuxt.com/components/form). ```vue <template> <UForm :state="form" class="space-y-4" @submit="submit"> <UFormField :error="errorFor('email')" label="Email" name="email"> <UInput v-model="form.email" /> </UFormField> <UFormField :error="errorFor('password')" label="Password" name="password"> <UInput v-model="form.password" type="password" /> </UFormField> <UButton type="submit"> <span v-if="processing">Processing</span> <span v-else>Submit</span> </UButton> </UForm> </template> <script lang="ts" setup> import { Reactive } from 'vue'; const { login, processing, errorBag: { message, get: errorFor, } } = useAuth(); interface Login { email: string password: string } const form: Reactive<Login> = reactive({ email: '', password: '', }) const toast = useToast() const submit = async () => { try { await login(form); } catch (error: Error) { toast.add({ title: 'Error', description: message, color: 'error' }) } } </script> ``` To log user out you can use `logout` method of the `useAuth` composable: ```vue <template> <button type="button" @click="logMeOut">Logout</button> </template> <script lang="ts" setup> const { logout } = useAuth() const logMeOut = () => { logout(); } </script> ``` You can also pass through a callback function to the `logout` method should you want to overwrite a default redirect behaviour: ```javascript const logMeOut = () => { logout(() => { console.log('I have logged out!') navigateTo('/') }); } ``` You can check if user is authenticated by using `isLoggedIn` and to obtain an instance of the currently authenticated user use the `user` property: ```vue <template> <p v-if="isLoggedIn"> {{ user.name }} <span class="text-xs">{{ user.email }}</span> </p> </template> <script lang="ts" setup> const { user, isLoggedIn } = useAuth() </script> ``` ### Http Client (useHttp) You can use `useHttp` composable to perform requests to the api gateway using the most common request methods, represented by the corresponding method: #### get / delete Both `get` and `destroy` (`delete` is a reserved keyword so we had to come up with something close enough) share the same interface - here's an example how you can use `get` method: ```typescript const { get, errorBag: { message } } = useHttp() const getProducts = async () => { try { const products = await get('/products') } catch (error: Error) { toast.add({ title: 'Error', description: message, color: 'error' }) } } ``` If you would like to append some query parameters to your url simply pass it as a second argument: ```typescript const products = await get('/products', { page: 2 }) ``` You can also pass any additional options as a third argument, which will be merged and passed through to the underlying `$Fetch` instance. ```typescript const products = await get( '/products', { page: 2 }, { retry: 3, retryDelay: 300 } ) ``` #### post / put / patch Again, all three share the same interface - an example below shows how you can use the `post` method to make a call: ```typescript import type { Reactive } from 'vue'; const { post, errorBag: { message } } = useHttp() interface Product { name: string, price?: number } const form: Reactive<Product> = reactive({ name: '', price: null, }) const createProduct = async () => { try { await post('/products', form) } catch (error: Error) { toast.add({ title: 'Error', description: message, color: 'error' }) } } ``` The same as with the `get` method, you can pass any additional options to the call as a third argument: ```typescript await post('/products', form, { retry: 3, retryDelay: 300 }) ``` ### Error bag To simplify parsing of the errors, both `useAuth` and `useHttp` provide access to the underlying instance of the `useErrorBag` composable by the means of the `errorBag` property. You can use this property to access the returned error `message` and, in case of the request that validates input `errors` property consisting of all validation errors. The property also provides helper methods such as: * `has`: to determine whether there is an error message available for a field * `get`: to obtain the error message for a field Here's a simple example of the above in action: ```vue <template> <UForm :state="form" class="space-y-4" @submit="createProduct"> <UFormField :error="errorFor('name')" label="Product name" name="name"> <UInput v-model="form.name" :class="{ 'border-red-700': hasError('name') }" /> </UFormField> <UFormField :error="errorFor('price')" label="Price" name="price"> <UInput v-model="form.price" type="number" :class="{ 'border-red-700': hasError('price') }" /> </UFormField> <UButton type="submit"> Submit </UButton> </UForm> </template> <script lang="ts" setup> import type { Reactive } from "vue"; const { post, errorBag: { message, get: errorFor, has: hasError } } = useHttp(); interface Product { name: string, price?: number } const form: Reactive<Product> = reactive({ name: '', price: null, }) const toast = useToast() const createProduct = async () => { try { await post('/products', form) } catch (error: Error) { toast.add({ title: 'Error', description: message, color: 'error' }) } } </script> ``` ### Processing status Both `useAuth` and `useHttp` also come with the `processing` property, which indicates when the request is in progress by setting its value to boolean `true` or `false` - you can use it in a following way: ```vue <template> <UForm :state="form" class="space-y-4" @submit="submit"> //... <UButton type="submit"> <span v-if="processing">Processing</span> <span v-else>Submit</span> </UButton> </UForm> </template> <script lang="ts" setup> const { post, processing, errorBag: { message, //... } } = useHttp(); const form = reactive({ //... }) const toast = useToast() const submit = async () => { try { await post(form); } catch (error: Error) { toast.add({ title: 'Error', description: message, color: 'error' }) } } </script> ``` --- ## Comprehensive Laravel Sanctum Examples This section provides exhaustive examples for integrating your Nuxt 3 application with Laravel Sanctum. ### Cookie-Based Authentication (SPA Mode) Cookie-based authentication is recommended for same-domain or subdomain setups. It's more secure and handles CSRF protection automatically. #### Configuration ```typescript // nuxt.config.ts export default defineNuxtConfig({ modules: ['@xsprtd/nuxt-api'], nuxtApi: { apiBaseURL: 'http://localhost:8000', originUrl: 'http://localhost:3000', authMode: 'cookie', // Cookie-based auth endpoints: { csrf: '/sanctum/csrf-cookie', login: '/api/login', logout: '/api/logout', user: '/api/user', }, redirect: { intendedEnabled: true, // Remember requested page login: '/login', postLogin: '/dashboard', postLogout: '/login', }, }, }) ``` #### Complete Login Page Example ```vue <script lang="ts" setup> // pages/login.vue definePageMeta({ middleware: 'guest' // Only accessible when not logged in }) interface LoginForm { email: string password: string remember?: boolean } const { login, processing, errorBag: { message, get: errorFor, has: hasError, reset } } = useAuth() const form = reactive<LoginForm>({ email: '', password: '', remember: false }) const toast = useToast() const handleLogin = async () => { reset() // Clear previous errors try { await login(form) // Automatically redirects to dashboard or intended route toast.add({ title: 'Success', description: 'Logged in successfully', color: 'green' }) } catch (exception) { toast.add({ title: 'Login Failed', description: message.value || 'Invalid credentials', color: 'red' }) } } </script> <template> <div class="max-w-md mx-auto mt-10"> <h1 class="text-2xl font-bold mb-6">Login</h1> <form @submit.prevent="handleLogin" class="space-y-4"> <div> <label for="email" class="block mb-2">Email</label> <input id="email" v-model="form.email" type="email" :class="{ 'border-red-500': hasError('email') }" class="w-full px-4 py-2 border rounded" :disabled="processing" required /> <span v-if="hasError('email')" class="text-red-500 text-sm"> {{ errorFor('email') }} </span> </div> <div> <label for="password" class="block mb-2">Password</label> <input id="password" v-model="form.password" type="password" :class="{ 'border-red-500': hasError('password') }" class="w-full px-4 py-2 border rounded" :disabled="processing" required /> <span v-if="hasError('password')" class="text-red-500 text-sm"> {{ errorFor('password') }} </span> </div> <div class="flex items-center"> <input id="remember" v-model="form.remember" type="checkbox" class="mr-2" /> <label for="remember">Remember me</label> </div> <button type="submit" :disabled="processing" class="w-full px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50" > {{ processing ? 'Logging in...' : 'Login' }} </button> </form> </div> </template> ``` #### Protected Dashboard with User Profile ```vue <script lang="ts" setup> // pages/dashboard.vue definePageMeta({ middleware: 'auth' // Only accessible when logged in }) interface User { id: number name: string email: string created_at: string email_verified_at?: string } const { user, isLoggedIn, logout, refreshUser } = useAuth<User>() const handleLogout = async () => { try { await logout() // Automatically redirects to login page } catch (error) { console.error('Logout failed:', error) } } // Refresh user data const handleRefresh = async () => { try { await refreshUser() } catch (error) { console.error('Failed to refresh user:', error) } } </script> <template> <div class="p-6"> <div class="max-w-4xl mx-auto"> <div class="flex justify-between items-center mb-8"> <h1 class="text-3xl font-bold">Dashboard</h1> <button @click="handleLogout" class="px-4 py-2 bg-red-500 text-white rounded" > Logout </button> </div> <div v-if="isLoggedIn && user" class="bg-white shadow rounded-lg p-6"> <h2 class="text-xl font-semibold mb-4">Profile Information</h2> <div class="space-y-2"> <p><strong>ID:</strong> {{ user.id }}</p> <p><strong>Name:</strong> {{ user.name }}</p> <p><strong>Email:</strong> {{ user.email }}</p> <p><strong>Member Since:</strong> {{ new Date(user.created_at).toLocaleDateString() }}</p> <p v-if="user.email_verified_at"> <strong>Email Verified:</strong> {{ new Date(user.email_verified_at).toLocaleDateString() }} </p> </div> <button @click="handleRefresh" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded" > Refresh Data </button> </div> </div> </div> </template> ``` #### Custom Login Callback ```typescript <script lang="ts" setup> // pages/login-custom.vue interface LoginResponse { user: User token?: string two_factor: boolean } const { login } = useAuth() const handleLoginWithCallback = async () => { await login<LoginResponse>( { email: 'user@example.com', password: 'password' }, {}, // Fetch options (responseData, user) => { // Custom callback overrides default redirect behavior if (responseData.two_factor) { // Redirect to 2FA page return navigateTo('/auth/two-factor') } // Store additional data if (responseData.token) { localStorage.setItem('additional_token', responseData.token) } // Custom redirect based on user role if (user?.role === 'admin') { return navigateTo('/admin') } return navigateTo('/dashboard') } ) } </script> ``` ### Token-Based Authentication (API Mode) Token-based authentication is ideal for mobile apps, third-party integrations, or when your frontend and backend are on different domains. #### Configuration ```typescript // nuxt.config.ts export default defineNuxtConfig({ modules: ['@xsprtd/nuxt-api'], nuxtApi: { apiBaseURL: 'https://api.example.com', authMode: 'token', // Token-based auth token: { storageKey: 'AUTH_TOKEN', storageType: 'cookie', // or 'localStorage' responseKey: 'token', // Key in login response: { token: "..." } }, endpoints: { login: '/api/auth/login', logout: '/api/auth/logout', user: '/api/auth/user', }, }, }) ``` #### Laravel Backend for Token Mode ```php // routes/api.php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Auth; use App\Models\User; Route::post('/auth/login', function (Request $request) { $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); if (!Auth::attempt($request->only('email', 'password'))) { return response()->json([ 'message' => 'Invalid credentials', 'errors' => [ 'email' => ['The provided credentials are incorrect.'] ] ], 422); } $user = Auth::user(); $token = $user->createToken('auth-token')->plainTextToken; return response()->json([ 'token' => $token, 'user' => $user ]); }); Route::middleware('auth:sanctum')->group(function () { Route::get('/auth/user', function (Request $request) { return $request->user(); }); Route::post('/auth/logout', function (Request $request) { $request->user()->currentAccessToken()->delete(); return response()->json(['message' => 'Logged out']); }); }); ``` #### Token Login Example ```vue <script lang="ts" setup> // pages/token-login.vue interface TokenLoginResponse { token: string user: User expires_at?: string } const { login, processing, errorBag } = useAuth() const form = reactive({ email: '', password: '' }) const handleTokenLogin = async () => { try { await login<TokenLoginResponse>(form) // Token is automatically stored and attached to future requests } catch (error) { console.error('Login failed:', error) } } </script> <template> <form @submit.prevent="handleTokenLogin"> <input v-model="form.email" type="email" placeholder="Email" /> <input v-model="form.password" type="password" placeholder="Password" /> <button type="submit" :disabled="processing">Login</button> </form> </template> ``` #### Nested Token Response If your API returns token in a nested structure: ```typescript // nuxt.config.ts - for response like { data: { auth_token: "..." } } export default defineNuxtConfig({ nuxtApi: { authMode: 'token', token: { responseKey: 'data.auth_token', // Supports dot notation // ... }, }, }) ``` ### Complete CRUD Operations #### Resource List with Pagination ```vue <script lang="ts" setup> // pages/products/index.vue definePageMeta({ middleware: 'auth' }) interface Product { id: number name: string price: number description: string stock: number } interface PaginatedResponse { data: Product[] current_page: number last_page: number per_page: number total: number } const { get, processing, errorBag } = useHttp() const products = ref<Product[]>([]) const pagination = ref({ currentPage: 1, lastPage: 1, total: 0 }) const loadProducts = async (page: number = 1) => { try { const response = await get<PaginatedResponse>('/api/products', { page, per_page: 15, sort: 'name', order: 'asc', search: '' // Optional search term }) products.value = response.data pagination.value = { currentPage: response.current_page, lastPage: response.last_page, total: response.total } } catch (error) { console.error('Failed to load products:', errorBag.message.value) } } const deleteProduct = async (id: number) => { if (!confirm('Are you sure?')) return try { await useHttp().destroy(`/api/products/${id}`) await loadProducts(pagination.value.currentPage) } catch (error) { console.error('Failed to delete product') } } onMounted(() => loadProducts()) </script> <template> <div class="p-6"> <div class="flex justify-between items-center mb-6"> <h1 class="text-2xl font-bold">Products</h1> <NuxtLink to="/products/create" class="btn-primary"> Add Product </NuxtLink> </div> <div v-if="processing" class="text-center py-8"> Loading... </div> <div v-else-if="products.length"> <table class="w-full"> <thead> <tr> <th>ID</th> <th>Name</th> <th>Price</th> <th>Stock</th> <th>Actions</th> </tr> </thead> <tbody> <tr v-for="product in products" :key="product.id"> <td>{{ product.id }}</td> <td>{{ product.name }}</td> <td>${{ product.price.toFixed(2) }}</td> <td>{{ product.stock }}</td> <td class="space-x-2"> <NuxtLink :to="`/products/${product.id}`">View</NuxtLink> <NuxtLink :to="`/products/${product.id}/edit`">Edit</NuxtLink> <button @click="deleteProduct(product.id)" class="text-red-500"> Delete </button> </td> </tr> </tbody> </table> <!-- Pagination --> <div class="flex justify-center space-x-2 mt-6"> <button v-for="page in pagination.lastPage" :key="page" @click="loadProducts(page)" :class="{ 'bg-blue-500 text-white': page === pagination.currentPage }" class="px-3 py-1 border rounded" > {{ page }} </button> </div> </div> <div v-else class="text-center py-8 text-gray-500"> No products found </div> </div> </template> ``` #### Create Resource ```vue <script lang="ts" setup> // pages/products/create.vue definePageMeta({ middleware: 'auth' }) interface ProductForm { name: string price: number | null description: string stock: number | null category_id: number | null } const { post, processing, errorBag } = useHttp() const router = useRouter() const toast = useToast() const form = reactive<ProductForm>({ name: '', price: null, description: '', stock: null, category_id: null }) const createProduct = async () => { errorBag.reset() try { const product = await post<{ data: Product }>('/api/products', form) toast.add({ title: 'Success', description: 'Product created successfully', color: 'green' }) router.push(`/products/${product.data.id}`) } catch (error) { toast.add({ title: 'Validation Error', description: errorBag.message.value, color: 'red' }) } } </script> <template> <div class="max-w-2xl mx-auto p-6"> <h1 class="text-2xl font-bold mb-6">Create Product</h1> <form @submit.prevent="createProduct" class="space-y-4"> <div> <label for="name" class="block mb-2">Product Name</label> <input id="name" v-model="form.name" type="text" :class="{ 'border-red-500': errorBag.has('name') }" class="w-full px-4 py-2 border rounded" required /> <span v-if="errorBag.has('name')" class="text-red-500 text-sm"> {{ errorBag.get('name') }} </span> </div> <div> <label for="price" class="block mb-2">Price</label> <input id="price" v-model.number="form.price" type="number" step="0.01" :class="{ 'border-red-500': errorBag.has('price') }" class="w-full px-4 py-2 border rounded" required /> <span v-if="errorBag.has('price')" class="text-red-500 text-sm"> {{ errorBag.get('price') }} </span> </div> <div> <label for="description" class="block mb-2">Description</label> <textarea id="description" v-model="form.description" :class="{ 'border-red-500': errorBag.has('description') }" class="w-full px-4 py-2 border rounded" rows="4" /> <span v-if="errorBag.has('description')" class="text-red-500 text-sm"> {{ errorBag.get('description') }} </span> </div> <div> <label for="stock" class="block mb-2">Stock</label> <input id="stock" v-model.number="form.stock" type="number" :class="{ 'border-red-500': errorBag.has('stock') }" class="w-full px-4 py-2 border rounded" required /> <span v-if="errorBag.has('stock')" class="text-red-500 text-sm"> {{ errorBag.get('stock') }} </span> </div> <div class="flex space-x-4"> <button type="submit" :disabled="processing" class="px-6 py-2 bg-blue-500 text-white rounded disabled:opacity-50" > {{ processing ? 'Creating...' : 'Create Product' }} </button> <NuxtLink to="/products" class="px-6 py-2 border rounded"> Cancel </NuxtLink> </div> </form> </div> </template> ``` #### Update Resource ```vue <script lang="ts" setup> // pages/products/[id]/edit.vue definePageMeta({ middleware: 'auth' }) const route = useRoute() const router = useRouter() const { get, put, processing, errorBag } = useHttp() const toast = useToast() const productId = computed(() => route.params.id as string) interface ProductForm { name: string price: number description: string stock: number } const form = reactive<ProductForm>({ name: '', price: 0, description: '', stock: 0 }) const loading = ref(true) // Load existing product const loadProduct = async () => { try { const response = await get<{ data: Product }>(`/api/products/${productId.value}`) Object.assign(form, response.data) } catch (error) { toast.add({ title: 'Error', description: 'Failed to load product', color: 'red' }) router.push('/products') } finally { loading.value = false } } const updateProduct = async () => { errorBag.reset() try { await put(`/api/products/${productId.value}`, form) toast.add({ title: 'Success', description: 'Product updated successfully', color: 'green' }) router.push(`/products/${productId.value}`) } catch (error) { toast.add({ title: 'Error', description: errorBag.message.value, color: 'red' }) } } onMounted(loadProduct) </script> <template> <div class="max-w-2xl mx-auto p-6"> <h1 class="text-2xl font-bold mb-6">Edit Product</h1> <div v-if="loading" class="text-center py-8">Loading...</div> <form v-else @submit.prevent="updateProduct" class="space-y-4"> <!-- Same form fields as create --> <div> <label for="name" class="block mb-2">Product Name</label> <input id="name" v-model="form.name" type="text" :class="{ 'border-red-500': errorBag.has('name') }" class="w-full px-4 py-2 border rounded" /> <span v-if="errorBag.has('name')" class="text-red-500 text-sm"> {{ errorBag.get('name') }} </span> </div> <!-- Other fields... --> <div class="flex space-x-4"> <button type="submit" :disabled="processing" class="px-6 py-2 bg-blue-500 text-white rounded disabled:opacity-50" > {{ processing ? 'Updating...' : 'Update Product' }} </button> <NuxtLink :to="`/products/${productId}`" class="px-6 py-2 border rounded"> Cancel </NuxtLink> </div> </form> </div> </template> ``` #### View Single Resource ```vue <script lang="ts" setup> // pages/products/[id]/index.vue definePageMeta({ middleware: 'auth' }) const route = useRoute() const { get, destroy } = useHttp() const router = useRouter() const productId = computed(() => route.params.id as string) const product = ref<Product | null>(null) const loading = ref(true) const loadProduct = async () => { try { const response = await get<{ data: Product }>(`/api/products/${productId.value}`) product.value = response.data } catch (error) { router.push('/products') } finally { loading.value = false } } const deleteProduct = async () => { if (!confirm('Are you sure you want to delete this product?')) return try { await destroy(`/api/products/${productId.value}`) router.push('/products') } catch (error) { console.error('Failed to delete product') } } onMounted(loadProduct) </script> <template> <div class="max-w-4xl mx-auto p-6"> <div v-if="loading" class="text-center py-8">Loading...</div> <div v-else-if="product"> <div class="flex justify-between items-start mb-6"> <h1 class="text-3xl font-bold">{{ product.name }}</h1> <div class="space-x-2"> <NuxtLink :to="`/products/${productId}/edit`" class="px-4 py-2 bg-blue-500 text-white rounded" > Edit </NuxtLink> <button @click="deleteProduct" class="px-4 py-2 bg-red-500 text-white rounded" > Delete </button> </div> </div> <div class="bg-white shadow rounded-lg p-6 space-y-4"> <div> <h2 class="text-lg font-semibold text-gray-700">Price</h2> <p class="text-2xl font-bold">${{ product.price.toFixed(2) }}</p> </div> <div> <h2 class="text-lg font-semibold text-gray-700">Stock</h2> <p>{{ product.stock }} units available</p> </div> <div> <h2 class="text-lg font-semibold text-gray-700">Description</h2> <p class="text-gray-600">{{ product.description }}</p> </div> </div> <NuxtLink to="/products" class="inline-block mt-6 text-blue-500"> ← Back to Products </NuxtLink> </div> </div> </template> ``` ### Advanced Use Cases #### File Upload ```vue <script lang="ts" setup> // pages/profile/avatar.vue const { post, processing, errorBag } = useHttp() const { user } = useAuth() const fileInput = ref<HTMLInputElement | null>(null) const selectedFile = ref<File | null>(null) const handleFileSelect = (event: Event) => { const target = event.target as HTMLInputElement if (target.files && target.files[0]) { selectedFile.value = target.files[0] } } const uploadAvatar = async () => { if (!selectedFile.value) return const formData = new FormData() formData.append('avatar', selectedFile.value) formData.append('user_id', user.value.id.toString()) try { const response = await post('/api/profile/avatar', formData as any, { headers: { 'Content-Type': 'multipart/form-data' } }) console.log('Avatar uploaded:', response) } catch (error) { console.error('Upload failed:', errorBag.message.value) } } </script> <template> <div class="p-6"> <h2 class="text-xl font-bold mb-4">Upload Avatar</h2> <input ref="fileInput" type="file" accept="image/*" @change="handleFileSelect" class="mb-4" /> <button @click="uploadAvatar" :disabled="!selectedFile || processing" class="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50" > {{ processing ? 'Uploading...' : 'Upload' }} </button> <p v-if="errorBag.has('avatar')" class="text-red-500 mt-2"> {{ errorBag.get('avatar') }} </p> </div> </template> ``` #### Laravel Backend for File Upload ```php // routes/api.php Route::middleware('auth:sanctum')->group(function () { Route::post('/profile/avatar', function (Request $request) { $request->validate([ 'avatar' => 'required|image|max:2048', // 2MB max ]); $path = $request->file('avatar')->store('avatars', 'public'); $request->user()->update([ 'avatar_path' => $path ]); return response()->json([ 'message' => 'Avatar uploaded successfully', 'path' => $path, 'url' => Storage::url($path) ]); }); }); ``` #### Custom Headers ```typescript // nuxt.config.ts - Global headers for all requests export default defineNuxtConfig({ nuxtApi: { apiBaseURL: 'https://api.example.com', headers: { 'X-Custom-Header': 'value', 'X-API-Version': 'v1', 'Accept-Language': 'en-US', }, }, }) ``` ```vue <script lang="ts" setup> // Per-request custom headers const { get } = useHttp() const fetchWithCustomHeaders = async () => { const data = await get('/api/data', {}, { headers: { 'X-Request-ID': crypto.randomUUID(), 'X-Client-Version': '1.0.0', } }) } </script> ``` #### Retry Failed Requests ```typescript // nuxt.config.ts - Global retry configuration export default defineNuxtConfig({ nuxtApi: { fetchOptions: { retryAttempts: 3, // Retry failed requests 3 times }, }, }) ``` ```vue <script lang="ts" setup> // Per-request retry configuration const { get } = useHttp() const fetchWithRetry = async () => { try { const data = await get('/api/unreliable-endpoint', {}, { retry: 5, // Override global config retryDelay: 1000, // Wait 1 second between retries retryStatusCodes: [408, 500, 502, 503, 504], }) } catch (error) { console.error('Failed after retries:', error) } } </script> ``` #### Composable for Shared Logic ```typescript // composables/useProducts.ts export const useProducts = () => { const { get, post, put, destroy, processing, errorBag } = useHttp() const products = ref<Product[]>([]) const currentProduct = ref<Product | null>(null) const fetchProducts = async (filters?: Record<string, any>) => { try { const response = await get<{ data: Product[] }>('/api/products', filters) products.value = response.data return response } catch (error) { console.error('Failed to fetch products:', errorBag.message.value) throw error } } const fetchProduct = async (id: number | string) => { try { const response = await get<{ data: Product }>(`/api/products/${id}`) currentProduct.value = response.data return response.data } catch (error) { console.error('Failed to fetch product:', errorBag.message.value) throw error } } const createProduct = async (data: Partial<Product>) => { try { const response = await post<{ data: Product }>('/api/products', data) products.value.push(response.data) return response.data } catch (error) { throw error } } const updateProduct = async (id: number | string, data: Partial<Product>) => { try { const response = await put<{ data: Product }>(`/api/products/${id}`, data) const index = products.value.findIndex(p => p.id === id) if (index !== -1) { products.value[index] = response.data } return response.data } catch (error) { throw error } } const deleteProduct = async (id: number | string) => { try { await destroy(`/api/products/${id}`) products.value = products.value.filter(p => p.id !== id) } catch (error) { throw error } } return { products, currentProduct, fetchProducts, fetchProduct, createProduct, updateProduct, deleteProduct, processing, errorBag, } } ``` ```vue <script lang="ts" setup> // Usage in component const { products, fetchProducts, deleteProduct, processing, errorBag } = useProducts() onMounted(() => fetchProducts({ status: 'active' })) </script> ``` #### App-wide Authentication Layout ```vue <script lang="ts" setup> // layouts/auth.vue const { user, isLoggedIn } = useAuth() // Redirect if not authenticated watchEffect(() => { if (!isLoggedIn.value) { navigateTo('/login') } }) </script> <template> <div v-if="isLoggedIn" class="min-h-screen flex"> <!-- Sidebar --> <aside class="w-64 bg-gray-800 text-white p-6"> <div class="mb-8"> <h2 class="text-xl font-bold">{{ user?.name }}</h2> <p class="text-sm text-gray-400">{{ user?.email }}</p> </div> <nav class="space-y-2"> <NuxtLink to="/dashboard" class="block py-2 px-4 rounded hover:bg-gray-700"> Dashboard </NuxtLink> <NuxtLink to="/products" class="block py-2 px-4 rounded hover:bg-gray-700"> Products </NuxtLink> <NuxtLink to="/orders" class="block py-2 px-4 rounded hover:bg-gray-700"> Orders </NuxtLink> <NuxtLink to="/profile" class="block py-2 px-4 rounded hover:bg-gray-700"> Profile </NuxtLink> </nav> </aside> <!-- Main content --> <main class="flex-1 bg-gray-100"> <slot /> </main> </div> </template> ``` ```vue <script lang="ts" setup>