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 — 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
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.

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
The 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.

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
const { credentials, signIn, submitting, error } = useLogin()
</script>