create-nuxt-base 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/nuxt-base-template/app/composables/use-better-auth.ts +14 -9
- package/nuxt-base-template/app/layouts/default.vue +17 -0
- package/nuxt-base-template/app/pages/auth/register.vue +78 -24
- package/nuxt-base-template/app/plugins/auth-interceptor.client.ts +143 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.1.2](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.1...v1.1.2) (2026-01-22)
|
|
6
|
+
|
|
5
7
|
### [1.1.1](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.0...v1.1.1) (2026-01-20)
|
|
6
8
|
|
|
7
9
|
|
|
@@ -205,12 +205,14 @@ export function useBetterAuth() {
|
|
|
205
205
|
isLoading.value = true;
|
|
206
206
|
|
|
207
207
|
try {
|
|
208
|
-
//
|
|
208
|
+
// In development, use the Nuxt proxy to ensure cookies are sent correctly
|
|
209
|
+
// In production, use the direct API URL
|
|
210
|
+
const isDev = import.meta.dev;
|
|
209
211
|
const runtimeConfig = useRuntimeConfig();
|
|
210
|
-
const
|
|
212
|
+
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
211
213
|
|
|
212
214
|
// Step 1: Get authentication options from server
|
|
213
|
-
const optionsResponse = await fetch(`${
|
|
215
|
+
const optionsResponse = await fetch(`${apiBase}/passkey/generate-authenticate-options`, {
|
|
214
216
|
method: 'GET',
|
|
215
217
|
credentials: 'include',
|
|
216
218
|
});
|
|
@@ -255,11 +257,12 @@ export function useBetterAuth() {
|
|
|
255
257
|
};
|
|
256
258
|
|
|
257
259
|
// Step 5: Verify with server
|
|
258
|
-
|
|
260
|
+
// Note: The server expects { response: credentialData } format (matching @simplewebauthn/browser output)
|
|
261
|
+
const authResponse = await fetch(`${apiBase}/passkey/verify-authentication`, {
|
|
259
262
|
method: 'POST',
|
|
260
263
|
credentials: 'include',
|
|
261
264
|
headers: { 'Content-Type': 'application/json' },
|
|
262
|
-
body: JSON.stringify(credentialBody),
|
|
265
|
+
body: JSON.stringify({ response: credentialBody }),
|
|
263
266
|
});
|
|
264
267
|
|
|
265
268
|
const result = await authResponse.json();
|
|
@@ -300,12 +303,14 @@ export function useBetterAuth() {
|
|
|
300
303
|
isLoading.value = true;
|
|
301
304
|
|
|
302
305
|
try {
|
|
303
|
-
//
|
|
306
|
+
// In development, use the Nuxt proxy to ensure cookies are sent correctly
|
|
307
|
+
// In production, use the direct API URL
|
|
308
|
+
const isDev = import.meta.dev;
|
|
304
309
|
const runtimeConfig = useRuntimeConfig();
|
|
305
|
-
const
|
|
310
|
+
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
306
311
|
|
|
307
312
|
// Step 1: Get registration options from server
|
|
308
|
-
const optionsResponse = await fetch(`${
|
|
313
|
+
const optionsResponse = await fetch(`${apiBase}/passkey/generate-register-options`, {
|
|
309
314
|
method: 'GET',
|
|
310
315
|
credentials: 'include',
|
|
311
316
|
});
|
|
@@ -365,7 +370,7 @@ export function useBetterAuth() {
|
|
|
365
370
|
};
|
|
366
371
|
|
|
367
372
|
// Step 6: Send to server for verification and storage
|
|
368
|
-
const registerResponse = await fetch(`${
|
|
373
|
+
const registerResponse = await fetch(`${apiBase}/passkey/verify-registration`, {
|
|
369
374
|
method: 'POST',
|
|
370
375
|
credentials: 'include',
|
|
371
376
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
3
3
|
|
|
4
|
+
const { isAuthenticated, signOut, user } = useBetterAuth();
|
|
5
|
+
|
|
6
|
+
async function handleLogout() {
|
|
7
|
+
await signOut();
|
|
8
|
+
await navigateTo('/auth/login');
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
const headerItems = computed<NavigationMenuItem[]>(() => [
|
|
5
12
|
{
|
|
6
13
|
label: 'Docs',
|
|
@@ -51,6 +58,16 @@ const footerItems: NavigationMenuItem[] = [
|
|
|
51
58
|
<UNavigationMenu :items="headerItems" />
|
|
52
59
|
|
|
53
60
|
<template #right>
|
|
61
|
+
<template v-if="isAuthenticated">
|
|
62
|
+
<span class="text-sm text-muted hidden sm:inline">{{ user?.email }}</span>
|
|
63
|
+
<UTooltip text="Logout">
|
|
64
|
+
<UButton color="neutral" variant="ghost" icon="i-lucide-log-out" aria-label="Logout" @click="handleLogout" />
|
|
65
|
+
</UTooltip>
|
|
66
|
+
</template>
|
|
67
|
+
<template v-else>
|
|
68
|
+
<UButton color="primary" variant="soft" to="/auth/login" icon="i-lucide-log-in" label="Login" />
|
|
69
|
+
</template>
|
|
70
|
+
|
|
54
71
|
<UColorModeButton />
|
|
55
72
|
|
|
56
73
|
<UTooltip text="Open on GitHub" :kbds="['meta', 'G']">
|
|
@@ -7,6 +7,8 @@ import type { InferOutput } from 'valibot';
|
|
|
7
7
|
|
|
8
8
|
import * as v from 'valibot';
|
|
9
9
|
|
|
10
|
+
import { authClient } from '~/lib/auth-client';
|
|
11
|
+
|
|
10
12
|
// ============================================================================
|
|
11
13
|
// Composables
|
|
12
14
|
// ============================================================================
|
|
@@ -24,6 +26,8 @@ definePageMeta({
|
|
|
24
26
|
// Variables
|
|
25
27
|
// ============================================================================
|
|
26
28
|
const loading = ref<boolean>(false);
|
|
29
|
+
const showPasskeyPrompt = ref<boolean>(false);
|
|
30
|
+
const passkeyLoading = ref<boolean>(false);
|
|
27
31
|
|
|
28
32
|
const fields: AuthFormField[] = [
|
|
29
33
|
{
|
|
@@ -117,40 +121,90 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
117
121
|
|
|
118
122
|
toast.add({
|
|
119
123
|
color: 'success',
|
|
120
|
-
description: 'Dein Konto wurde erfolgreich erstellt
|
|
124
|
+
description: 'Dein Konto wurde erfolgreich erstellt',
|
|
121
125
|
title: 'Willkommen!',
|
|
122
126
|
});
|
|
123
127
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
// due to session handling differences between nest-server and Better Auth
|
|
127
|
-
await navigateTo('/app', { external: true });
|
|
128
|
+
// Show passkey prompt after successful registration + login
|
|
129
|
+
showPasskeyPrompt.value = true;
|
|
128
130
|
} finally {
|
|
129
131
|
loading.value = false;
|
|
130
132
|
}
|
|
131
133
|
}
|
|
134
|
+
|
|
135
|
+
async function addPasskey(): Promise<void> {
|
|
136
|
+
passkeyLoading.value = true;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const { error } = await authClient.passkey.addPasskey({
|
|
140
|
+
name: 'Mein Gerät',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (error) {
|
|
144
|
+
toast.add({
|
|
145
|
+
color: 'error',
|
|
146
|
+
description: error.message || 'Passkey konnte nicht hinzugefügt werden',
|
|
147
|
+
title: 'Fehler',
|
|
148
|
+
});
|
|
149
|
+
// Still navigate to app even if passkey failed
|
|
150
|
+
await navigateTo('/app');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
toast.add({
|
|
155
|
+
color: 'success',
|
|
156
|
+
description: 'Passkey wurde erfolgreich hinzugefügt',
|
|
157
|
+
title: 'Erfolg',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await navigateTo('/app');
|
|
161
|
+
} finally {
|
|
162
|
+
passkeyLoading.value = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function skipPasskey(): Promise<void> {
|
|
167
|
+
await navigateTo('/app');
|
|
168
|
+
}
|
|
132
169
|
</script>
|
|
133
170
|
|
|
134
171
|
<template>
|
|
135
172
|
<UPageCard class="w-md" variant="naked">
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
<template v-if="!showPasskeyPrompt">
|
|
174
|
+
<UAuthForm
|
|
175
|
+
:schema="schema"
|
|
176
|
+
title="Registrieren"
|
|
177
|
+
icon="i-lucide-user-plus"
|
|
178
|
+
:fields="fields"
|
|
179
|
+
:loading="loading"
|
|
180
|
+
:submit="{
|
|
181
|
+
label: 'Konto erstellen',
|
|
182
|
+
block: true,
|
|
183
|
+
}"
|
|
184
|
+
@submit="onSubmit"
|
|
185
|
+
>
|
|
186
|
+
<template #footer>
|
|
187
|
+
<p class="text-center text-sm text-muted">
|
|
188
|
+
Bereits ein Konto?
|
|
189
|
+
<ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
|
|
190
|
+
</p>
|
|
191
|
+
</template>
|
|
192
|
+
</UAuthForm>
|
|
193
|
+
</template>
|
|
194
|
+
|
|
195
|
+
<template v-else>
|
|
196
|
+
<div class="flex flex-col items-center gap-6">
|
|
197
|
+
<UIcon name="i-lucide-key" class="size-16 text-primary" />
|
|
198
|
+
<div class="text-center">
|
|
199
|
+
<h2 class="text-xl font-semibold">Passkey hinzufügen?</h2>
|
|
200
|
+
<p class="mt-2 text-sm text-muted">Mit einem Passkey kannst du dich schnell und sicher ohne Passwort anmelden.</p>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div class="flex w-full flex-col gap-3">
|
|
204
|
+
<UButton block :loading="passkeyLoading" @click="addPasskey"> Passkey hinzufügen </UButton>
|
|
205
|
+
<UButton block variant="outline" color="neutral" @click="skipPasskey"> Später einrichten </UButton>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</template>
|
|
155
209
|
</UPageCard>
|
|
156
210
|
</template>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Interceptor Plugin
|
|
3
|
+
*
|
|
4
|
+
* This plugin intercepts all API responses and handles session expiration.
|
|
5
|
+
* When a 401 (Unauthorized) response is received, it automatically:
|
|
6
|
+
* 1. Clears the user session state
|
|
7
|
+
* 2. Redirects to the login page
|
|
8
|
+
*
|
|
9
|
+
* Note: This is a client-only plugin (.client.ts) since auth state
|
|
10
|
+
* management only makes sense in the browser context.
|
|
11
|
+
*/
|
|
12
|
+
export default defineNuxtPlugin(() => {
|
|
13
|
+
const { clearUser, isAuthenticated } = useBetterAuth();
|
|
14
|
+
const route = useRoute();
|
|
15
|
+
|
|
16
|
+
// Track if we're already handling a 401 to prevent multiple redirects
|
|
17
|
+
let isHandling401 = false;
|
|
18
|
+
|
|
19
|
+
// Paths that should not trigger auto-logout on 401
|
|
20
|
+
// (public auth endpoints where 401 is expected)
|
|
21
|
+
const publicAuthPaths = [
|
|
22
|
+
'/auth/login',
|
|
23
|
+
'/auth/register',
|
|
24
|
+
'/auth/forgot-password',
|
|
25
|
+
'/auth/reset-password',
|
|
26
|
+
'/auth/2fa',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if current route is a public auth route
|
|
31
|
+
*/
|
|
32
|
+
function isPublicAuthRoute(): boolean {
|
|
33
|
+
return publicAuthPaths.some((path) => route.path.startsWith(path));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if URL is an auth-related endpoint that shouldn't trigger logout
|
|
38
|
+
* (e.g., login, register, password reset endpoints)
|
|
39
|
+
*/
|
|
40
|
+
function isAuthEndpoint(url: string): boolean {
|
|
41
|
+
const authEndpoints = [
|
|
42
|
+
'/sign-in',
|
|
43
|
+
'/sign-up',
|
|
44
|
+
'/sign-out',
|
|
45
|
+
'/forgot-password',
|
|
46
|
+
'/reset-password',
|
|
47
|
+
'/verify-email',
|
|
48
|
+
'/session',
|
|
49
|
+
];
|
|
50
|
+
return authEndpoints.some((endpoint) => url.includes(endpoint));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle 401 Unauthorized responses
|
|
55
|
+
* Clears user state and redirects to login page
|
|
56
|
+
*/
|
|
57
|
+
async function handleUnauthorized(requestUrl?: string): Promise<void> {
|
|
58
|
+
// Prevent multiple simultaneous 401 handling
|
|
59
|
+
if (isHandling401) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Don't handle 401 for auth endpoints (expected behavior)
|
|
64
|
+
if (requestUrl && isAuthEndpoint(requestUrl)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Don't handle 401 on public auth pages
|
|
69
|
+
if (isPublicAuthRoute()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isHandling401 = true;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// Only handle if user was authenticated (prevents redirect loops)
|
|
77
|
+
if (isAuthenticated.value) {
|
|
78
|
+
console.debug('[Auth Interceptor] Session expired, logging out...');
|
|
79
|
+
|
|
80
|
+
// Clear user state
|
|
81
|
+
clearUser();
|
|
82
|
+
|
|
83
|
+
// Redirect to login page with return URL
|
|
84
|
+
await navigateTo({
|
|
85
|
+
path: '/auth/login',
|
|
86
|
+
query: {
|
|
87
|
+
redirect: route.fullPath !== '/auth/login' ? route.fullPath : undefined,
|
|
88
|
+
},
|
|
89
|
+
}, {
|
|
90
|
+
replace: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
// Reset flag after a short delay to allow navigation to complete
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
isHandling401 = false;
|
|
97
|
+
}, 1000);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Override the default $fetch to add response error handling
|
|
102
|
+
const originalFetch = globalThis.$fetch;
|
|
103
|
+
|
|
104
|
+
// Use a wrapper to intercept responses
|
|
105
|
+
globalThis.$fetch = ((url: string, options?: any) => {
|
|
106
|
+
return originalFetch(url, {
|
|
107
|
+
...options,
|
|
108
|
+
onResponseError: (context: any) => {
|
|
109
|
+
// Call original onResponseError if provided
|
|
110
|
+
if (options?.onResponseError) {
|
|
111
|
+
options.onResponseError(context);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle 401 errors
|
|
115
|
+
if (context.response?.status === 401) {
|
|
116
|
+
handleUnauthorized(url);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}) as typeof globalThis.$fetch;
|
|
121
|
+
|
|
122
|
+
// Also intercept native fetch for manual API calls
|
|
123
|
+
const originalNativeFetch = globalThis.fetch;
|
|
124
|
+
|
|
125
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
126
|
+
const response = await originalNativeFetch(input, init);
|
|
127
|
+
|
|
128
|
+
// Handle 401 errors from native fetch
|
|
129
|
+
if (response.status === 401) {
|
|
130
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
131
|
+
handleUnauthorized(url);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return response;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Provide a manual method to trigger logout on 401
|
|
138
|
+
return {
|
|
139
|
+
provide: {
|
|
140
|
+
handleUnauthorized,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
});
|
package/package.json
CHANGED