The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftNuxt Module

Authentication

The auto-provided auth pages, $cwa.auth state, protecting routes, and building a custom registration flow.

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/:

PathPurpose
/loginEmail + password login form
/forgot-passwordRequest 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
Only 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.

The target is checked so the login page can't be used as an open redirect. Only a same-site path starting with a single / 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.

If the API is on a different origin from the site, add 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:

MiddlewareLets throughOtherwise
cwa-authAny signed-in userSends the visitor to /login?redirect=<where they were going>
cwa-adminUsers whose roles include ROLE_ADMINSigned-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.

The names carry a 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 } })
})
Treat route middleware as UX rather than security. The API enforces access on every request regardless of what the front end does.

Signing Out

await cwa.auth.signOut()
navigateTo('/login')

User Registration

There is no built-in /register page. Build it yourself.

The template app locks 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>