Authentication
The CWA Nuxt module wires authentication into your app automatically. The API sets HttpOnly JWT cookies on login; the module reads auth state from those cookies and exposes everything through $cwa.auth.
Pages Provided Automatically
You don't need to create these — the module ships them. Override any by creating the same path in app/pages/:
| Path | Purpose |
|---|---|
/login | Email + password login form |
/forgot-password | Request a password reset email |
/reset-password/[username]/[token] | Set a new password from an email link |
/verify-email/[username]/[token] | Verify email address on registration |
/confirm-new-email/[username]/[newEmail]/[token] | Confirm email address change |
Reading Auth State
const cwa = useCwa()
cwa.auth.signedIn.value // boolean
cwa.auth.isAdmin.value // boolean — ROLE_ADMIN or higher
cwa.auth.status.value // 0=SIGNED_OUT, 1=LOADING, 2=SIGNED_IN (CwaAuthStatus enum)
cwa.auth.user // { '@id', username, emailAddress, roles, enabled } or undefined
cwa.auth.roles // string[] | undefined
cwa.auth.hasRole('ROLE_EDITOR') // boolean
signedIn, isAdmin and status are refs. user and roles are plain getters that hand back the value directly — reading .value on them gives you undefined (or throws, when no user is loaded). The user object is a CWA resource, so its identifier is @id, not id.Auth state is only available after client-side hydration. Always wrap auth-dependent UI in <ClientOnly>:
<ClientOnly>
<UserMenu v-if="$cwa.auth.signedIn.value" />
<NuxtLink v-else to="/login">Sign in</NuxtLink>
</ClientOnly>
Signing In Programmatically
const result = await cwa.auth.signIn({ username: 'alice@example.com', password: 'secret' })
if (result instanceof FetchError) {
// result.statusCode === 401 → invalid credentials
error.value = 'Invalid email or password'
} else {
navigateTo('/')
}
Using the Login Composable
For custom login forms, useLogin wraps the full login flow:
import { useLogin } from '#imports'
const { credentials, signIn, submitting, error } = useLogin()
// credentials.username and credentials.password are reactive
// signIn() calls the API and navigates to '/' on success
// error.value is the error message string, or undefined
<form @submit.prevent="signIn">
<input v-model="credentials.username" type="email" placeholder="Email" />
<input v-model="credentials.password" type="password" placeholder="Password" />
<p v-if="error" class="text-red-500">{{ error }}</p>
<button :disabled="submitting" type="submit">Sign in</button>
</form>
signIn() always navigates to / on success. If you want to return the user to where they came from, call cwa.auth.signIn() yourself instead and handle the navigation.Password Reset
// Step 1: request the reset email
import { useForgotPassword } from '#imports'
const { credentials, doSubmit, submitting, success, error } = useForgotPassword()
// credentials.username — the user's email or username
// doSubmit() → GET /password/reset/request/{username}
// Step 2: submit the new password (on the page the email links to)
import { useResetPassword } from '#imports'
const { passwords, resetPassword, submitting, success, error, inputErrors } = useResetPassword()
// passwords.first / passwords.second — the new password and its confirmation
// username and token are read from the route params automatically
// resetPassword() → POST /component/forms/password_reset/submit
The reset itself goes through the API's Form component, so a 422 comes back as a form resource rather than a plain message — inputErrors exposes the per-field violations (inputErrors.form, inputErrors.password) for rendering next to the inputs.
Email Verification
// Verify email (from the link)
import { useVerifyEmail } from '#imports'
const { verifyEmail, confirmEmail, submitting, success, error } = useVerifyEmail()
// Both read username/token (and newEmail for confirmEmail) from the route params
onMounted(() => verifyEmail())
// Resend the verification email
import { useResendVerifyEmail } from '#imports'
const { resendVerifyEmail, submitting, success, error } = useResendVerifyEmail()
resendVerifyEmail(username, 'current') // 'new' resends for a pending email change
type argument on resendVerifyEmail() is required. Omitting it silently falls through to the pending-email-change endpoint rather than resending for the current address.Use confirmEmail() on the /confirm-new-email/[username]/[newEmail]/[token] page and verifyEmail() on /verify-email/[username]/[token].
Protecting Pages
The module registers one global route middleware of its own (cwa-route-middleware, which resolves the CWA route for the current URL). It does not ship an auth or admin middleware — write your own:
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const cwa = useCwa()
if (!cwa.auth.signedIn.value) {
return navigateTo(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
})
// app/pages/account.vue
definePageMeta({ middleware: 'auth' })
For admin-only areas, check cwa.auth.isAdmin.value (or cwa.auth.hasRole('ROLE_EDITOR') for a custom role) in the same way.
signedIn reads a client-side cookie, so it is only meaningful after hydration. Treat middleware like this as UX rather than security — the API enforces access on every request regardless of what the front end does.Honouring a ?redirect= parameter after login means driving the sign-in yourself — useLogin()'s signIn() always navigates to /. Call cwa.auth.signIn() directly in your own login page and navigate to the stored path.
Signing Out
await cwa.auth.signOut()
navigateTo('/login')
User Registration
There is no built-in /register page. Build it yourself.
POST /users to ROLE_SUPER_ADMIN, so an anonymous registration request gets a 403. Open the operation up on your own User entity first, e.g. new Post(security: "is_granted('PUBLIC_ACCESS')", validationContext: ['groups' => ['Default', 'User:password:create']]). See Users & Security.<script setup lang="ts">
const cwa = useCwa()
const form = reactive({ username: '', emailAddress: '', plainPassword: '' })
const loading = ref(false)
const error = ref<string | null>(null)
async function register() {
loading.value = true
error.value = null
try {
await $fetch(`${cwa.apiUrlBase}/users`, {
method: 'POST',
body: form,
credentials: 'include'
})
navigateTo('/login?registered=1')
} catch (e: any) {
error.value = e?.data?.description ?? e?.data?.detail ?? 'Registration failed'
} finally {
loading.value = false
}
}
</script>
After successful registration, the API sends a verification email automatically (if verify_on_register: true in the bundle config).
Customising the Login Page
Create app/pages/login.vue — it takes precedence over the module's built-in page. Use useLogin() inside it to keep the same API wiring:
<!-- app/pages/login.vue -->
<template>
<div class="max-w-sm mx-auto mt-16">
<h1 class="text-2xl font-bold mb-8">Welcome back</h1>
<form @submit.prevent="signIn" class="space-y-4">
<input v-model="credentials.username" type="email" class="input w-full" placeholder="Email" />
<input v-model="credentials.password" type="password" class="input w-full" placeholder="Password" />
<p v-if="error" class="text-red-500 text-sm">{{ error }}</p>
<button type="submit" :disabled="submitting" class="btn-primary w-full">
{{ submitting ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<NuxtLink to="/forgot-password" class="text-sm text-gray-500 mt-4 block">
Forgot your password?
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { useLogin } from '#imports'
definePageMeta({ layout: false }) // or use a minimal layout
const { credentials, signIn, submitting, error } = useLogin()
</script>