Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/1.getting-started/2.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default defineNuxtConfig({
redirects: {
login: '/login',
guest: '/',
// logout: '/goodbye', // optional
},
preserveRedirect: true,
redirectQueryKey: 'redirect',
Expand Down Expand Up @@ -50,12 +51,13 @@ export default defineNuxtConfig({
Path to the client auth config file, relative to the project root.
::

::field{name="redirects" type="{ login?: string, guest?: string }"}
::field{name="redirects" type="{ login?: string, guest?: string, logout?: string }"}
Default: `{ login: '/login', guest: '/' }`

Global redirect fallbacks:
- `login`: where to redirect unauthenticated users
- `guest`: where to redirect authenticated users trying to access guest-only routes
- `logout`: where to navigate after logout (no default)

Per-route `redirectTo` takes precedence when set.
::
Expand Down
2 changes: 2 additions & 0 deletions docs/content/5.api/1.composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Signs the user out and clears the local session state.
await signOut()
```

If `auth.redirects.logout` is configured, `signOut()` will navigate there automatically (client-side), unless you provide `onSuccess`.

**Options**

```ts
Expand Down
6 changes: 5 additions & 1 deletion src/module/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export function setupRuntimeConfig(input: SetupRuntimeConfigInput): { secondaryS
nuxt.options.runtimeConfig.public.siteUrl = process.env.NUXT_PUBLIC_SITE_URL

nuxt.options.runtimeConfig.public.auth = defu(nuxt.options.runtimeConfig.public.auth as Record<string, unknown>, {
redirects: { login: options.redirects?.login ?? '/login', guest: options.redirects?.guest ?? '/' },
redirects: {
login: options.redirects?.login ?? '/login',
guest: options.redirects?.guest ?? '/',
logout: options.redirects?.logout,
},
preserveRedirect: options.preserveRedirect ?? true,
redirectQueryKey: options.redirectQueryKey ?? 'redirect',
useDatabase: databaseProvider !== 'none',
Expand Down
11 changes: 9 additions & 2 deletions src/runtime/app/composables/useUserSession.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AppAuthClient, AuthSession, AuthUser } from '#nuxt-better-auth'
import type { ComputedRef, Ref } from 'vue'
import createAppAuthClient from '#auth/client'
import { computed, nextTick, useNuxtApp, useRequestHeaders, useRequestURL, useRuntimeConfig, useState, watch } from '#imports'
import { computed, navigateTo, nextTick, useNuxtApp, useRequestHeaders, useRequestURL, useRuntimeConfig, useState, watch } from '#imports'
import { normalizeAuthActionError } from '../internal/auth-action-error'

export interface SignOutOptions { onSuccess?: () => void | Promise<void> }
Expand Down Expand Up @@ -311,8 +311,15 @@ export function useUserSession(): UseUserSessionReturn {
throw new Error('signOut can only be called on client-side')
await client.signOut()
clearSession()
if (options?.onSuccess)
if (options?.onSuccess) {
await options.onSuccess()
return
}

const authConfig = runtimeConfig.public.auth as { redirects?: { logout?: string } } | undefined
const logoutRedirect = authConfig?.redirects?.logout
if (logoutRedirect)
await navigateTo(logoutRedirect)
}

return {
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface BetterAuthModuleOptions {
login?: string
/** Where to redirect authenticated users on guest-only routes. Default: '/' */
guest?: string
/** Where to navigate after logout. Default: no automatic navigation */
logout?: string
}
/**
* When redirecting unauthenticated users to the login route, append a query param
Expand Down Expand Up @@ -71,7 +73,7 @@ export interface BetterAuthModuleOptions {

// Runtime config type for public.auth
export interface AuthRuntimeConfig {
redirects: { login: string, guest: string }
redirects: { login: string, guest: string, logout?: string }
preserveRedirect: boolean
redirectQueryKey: string
useDatabase: boolean
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/server/api/_better-auth/config.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
const authContext = await ((auth as { $context?: Promise<{ trustedOrigins?: string[] }> | { trustedOrigins?: string[] } }).$context)
const runtimeConfig = useRuntimeConfig()
const publicAuth = runtimeConfig.public?.auth as {
redirects?: { login?: string, guest?: string }
redirects?: { login?: string, guest?: string, logout?: string }
preserveRedirect?: boolean
redirectQueryKey?: string
useDatabase?: boolean
Expand All @@ -28,7 +28,11 @@ export default defineEventHandler(async (event) => {
config: {
// Module config (nuxt.config.ts)
module: {
redirects: { login: publicAuth?.redirects?.login ?? '/login', guest: publicAuth?.redirects?.guest ?? '/' },
redirects: {
login: publicAuth?.redirects?.login ?? '/login',
guest: publicAuth?.redirects?.guest ?? '/',
logout: publicAuth?.redirects?.logout,
},
preserveRedirect: publicAuth?.preserveRedirect ?? true,
redirectQueryKey: publicAuth?.redirectQueryKey ?? 'redirect',
secondaryStorage: privateAuth?.secondaryStorage ?? false,
Expand Down
38 changes: 38 additions & 0 deletions test/use-user-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ const runtimeConfig = {
session: {
skipHydratedSsrGetSession: false,
},
redirects: {} as Record<string, unknown>,
},
},
}

const requestURL = { origin: 'http://localhost:3000' }
let requestHeaders: HeadersInit | undefined = { cookie: 'session=test' }
const state = new Map<string, ReturnType<typeof ref>>()
const navigateTo = vi.fn(async () => {})

const sessionAtom = ref<SessionState>({
data: null,
Expand Down Expand Up @@ -55,6 +57,7 @@ vi.mock('#imports', async () => {
const vue = await import('vue')
return {
computed: vue.computed,
navigateTo,
nextTick: vue.nextTick,
watch: vue.watch,
useNuxtApp: () => ({ payload }),
Expand Down Expand Up @@ -96,6 +99,8 @@ describe('useUserSession hydration bootstrap', () => {
requestURL.origin = 'http://localhost:3000'
runtimeConfig.public.siteUrl = 'http://localhost:3000'
runtimeConfig.public.auth.session.skipHydratedSsrGetSession = false
runtimeConfig.public.auth.redirects = {}
navigateTo.mockClear()

sessionAtom.value = {
data: null,
Expand Down Expand Up @@ -280,4 +285,37 @@ describe('useUserSession hydration bootstrap', () => {
expect(auth.session.value).toEqual({ id: 'session-3', ipAddress: '127.0.0.1' })
expect(auth.user.value).toEqual({ id: 'user-3', email: 'user3@example.com' })
})

it('signOut navigates to redirects.logout when configured (and no onSuccess)', async () => {
runtimeConfig.public.auth.redirects = { logout: '/logged-out' }

const useUserSession = await loadUseUserSession()
const auth = useUserSession()
await auth.signOut()

expect(navigateTo).toHaveBeenCalledWith('/logged-out')
})

it('signOut does not auto-navigate when onSuccess is provided', async () => {
runtimeConfig.public.auth.redirects = { logout: '/logged-out' }

const useUserSession = await loadUseUserSession()
const auth = useUserSession()

const onSuccess = vi.fn()
await auth.signOut({ onSuccess })

expect(onSuccess).toHaveBeenCalledOnce()
expect(navigateTo).not.toHaveBeenCalled()
})

it('signOut does not auto-navigate when redirects.logout is not configured', async () => {
runtimeConfig.public.auth.redirects = {}

const useUserSession = await loadUseUserSession()
const auth = useUserSession()
await auth.signOut()

expect(navigateTo).not.toHaveBeenCalled()
})
})
Loading