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 — roles include ROLE_ADMIN (exact match, no hierarchy)
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.signedIn and status come from the cwa_auth cookie, so they are available during server rendering. user, roles and isAdmin are only filled in once the user has been loaded on the client (or by the cwa-auth / cwa-admin middleware), so wrap UI that reads those in <ClientOnly>:
<UserMenu v-if="$cwa.auth.signedIn.value" />
<NuxtLink v-else to="/login">Sign in</NuxtLink>
<ClientOnly>
<AdminBar v-if="$cwa.auth.isAdmin.value" />
</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, on success, navigates to the redirect target (default '/')
// 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>
Where the user goes after signing in
By default signIn() sends the user to the path in the current route's redirect query parameter — /login?redirect=/account returns them to /account — and to / when there isn't one. The cwa-auth and cwa-admin middleware set that parameter for you, so the round trip needs no code of your own.
Pass redirect to choose the target yourself. It takes a string, a ref or a getter, and is read at the moment of sign-in:
useLogin({ redirect: '/dashboard' }) // a fixed path
useLogin({ redirect: () => returnTo.value }) // or a getter, resolved at sign-in
An explicit redirect wins over the query parameter; undefined falls back to it.
/ is followed. An external URL, a protocol-relative //host, /\host, a relative path, or a repeated redirect query parameter all send the user to / instead.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
If the user asked for a reset recently, error reads "A reset email was already sent recently. Please check your inbox and spam folder." There's no countdown, because the API's wait defaults to 24 hours (password_reset.repeat_ttl_seconds).
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, retryIn } = useResendVerifyEmail()
resendVerifyEmail(username) // defaults to 'current'
resendVerifyEmail(username, 'new') // resends for a pending email change
Use confirmEmail() on the /confirm-new-email/[username]/[newEmail]/[token] page and verifyEmail() on /verify-email/[username]/[token].
Verification emails can be resent every 5 minutes by default (email_verification.repeat_ttl_seconds and new_email_confirmation.repeat_ttl_seconds). A request inside that wait sets error to a message such as "A confirmation email was already sent. You can send another in 4 minutes." and retryIn to the seconds left, counting down to 0. Use it to disable your resend button:
<button type="button" :disabled="submitting || retryIn > 0" @click="resendVerifyEmail(username)">
Resend email<template v-if="retryIn"> ({{ retryIn }}s)</template>
</button>
When the email couldn't be sent (a 503), both useResendVerifyEmail() and useForgotPassword() set error to "The email couldn't be sent. Please try again." Nothing was saved, so the previous link still works and the user can retry straight away. When the API refuses to send the email (a 400, usually because the site's origin isn't in the API's user.email_links.allowed_origins and no default_origin is set), error reads "The email couldn't be sent. Please contact the site administrator." and the console logs a warning naming those settings. A new request also resets success, so a throttled or failed resend doesn't show alongside an earlier success. Both of these need @cwa/nuxt 2.0.0-alpha.3 or later (cwa-nuxt-module#355, #356); 2.0.0-alpha.2 shows the status text instead. See Throttled and Failed Email Requests for every status.
Retry-After to the CORS expose_headers in the API's nelmio_cors.yaml. Without it the browser can't read the wait, so the message says "shortly" and retryIn stays 0. The template exposes it. These messages need @cwa/nuxt2.0.0-alpha.2 and bundle 2.0.0-alpha.5 or later. Before it, a throttled request returned success and sent nothing.Protecting Pages
The CWA layer ships two route middleware you opt into per page:
| Middleware | Lets through | Otherwise |
|---|---|---|
cwa-auth | Any signed-in user | Sends the visitor to /login?redirect=<where they were going> |
cwa-admin | Users whose roles include ROLE_ADMIN | Signed-out visitors go to /login?redirect=…; signed-in non-admins go to / |
// app/pages/account.vue
definePageMeta({ middleware: 'cwa-auth' }) Both wait for the session to be resolved before deciding, so a signed-in user is never bounced to the login page by mistake. After sign-in, the built-in login page (and any page using useLogin()) follows the redirect parameter back, so the whole round trip works without any code of your own.
cwa- prefix so they can't collide with an auth or admin middleware your app already has.For any other rule, such as a custom role, write your own middleware in the same shape:
// app/middleware/editor.ts
export default defineNuxtRouteMiddleware(async (to) => {
const cwa = useCwa()
await cwa.auth.init()
if (cwa.auth.hasRole('ROLE_EDITOR')) {
return
}
if (cwa.auth.signedIn.value) {
return navigateTo('/')
}
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
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
cwa: { disabled: true }, // stop CWA resolving this path as a content route
})
const { credentials, signIn, submitting, error } = useLogin()
</script>