create-nuxt-base 1.1.0 → 1.1.1

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 CHANGED
@@ -2,6 +2,14 @@
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.1](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.0...v1.1.1) (2026-01-20)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **auth:** auto-login after registration and improved passkey handling ([625128b](https://github.com/lenneTech/nuxt-base-starter/commit/625128b18fe812c141859946c196f8efb0738dca))
11
+ * **auth:** improve 2FA UX and document dev-mode proxy requirements ([4c4b1f4](https://github.com/lenneTech/nuxt-base-starter/commit/4c4b1f4d8b77fa93469ccc1a31d4f3292cc7c724))
12
+
5
13
  ## [1.1.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.1.0) (2026-01-20)
6
14
 
7
15
 
@@ -1,4 +1,5 @@
1
1
  import { authClient } from '~/lib/auth-client';
2
+ import { arrayBufferToBase64Url, base64UrlToUint8Array } from '~/utils/crypto';
2
3
 
3
4
  /**
4
5
  * User type for Better Auth session
@@ -19,6 +20,15 @@ interface StoredAuthState {
19
20
  user: BetterAuthUser | null;
20
21
  }
21
22
 
23
+ /**
24
+ * Result of passkey authentication
25
+ */
26
+ interface PasskeyAuthResult {
27
+ error?: string;
28
+ success: boolean;
29
+ user?: BetterAuthUser;
30
+ }
31
+
22
32
  /**
23
33
  * Better Auth composable with client-side state management
24
34
  *
@@ -36,6 +46,13 @@ export function useBetterAuth() {
36
46
  sameSite: 'lax',
37
47
  });
38
48
 
49
+ // Auth token cookie (for 2FA sessions where no session cookie is set)
50
+ const authToken = useCookie<string | null>('auth-token', {
51
+ default: () => null,
52
+ maxAge: 60 * 60 * 24 * 7, // 7 days
53
+ sameSite: 'lax',
54
+ });
55
+
39
56
  // Loading state
40
57
  const isLoading = ref<boolean>(false);
41
58
 
@@ -173,7 +190,207 @@ export function useBetterAuth() {
173
190
  }
174
191
  };
175
192
 
193
+ /**
194
+ * Authenticate with a passkey (WebAuthn)
195
+ *
196
+ * This function handles the complete WebAuthn authentication flow:
197
+ * 1. Fetches authentication options from the server
198
+ * 2. Prompts the user to select a passkey via the browser's WebAuthn API
199
+ * 3. Sends the signed credential to the server for verification
200
+ * 4. Stores user data on successful authentication
201
+ *
202
+ * @returns Result with success status, user data, or error message
203
+ */
204
+ async function authenticateWithPasskey(): Promise<PasskeyAuthResult> {
205
+ isLoading.value = true;
206
+
207
+ try {
208
+ // Direct API access (CORS is configured on the server for trusted origins)
209
+ const runtimeConfig = useRuntimeConfig();
210
+ const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
211
+
212
+ // Step 1: Get authentication options from server
213
+ const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-authenticate-options`, {
214
+ method: 'GET',
215
+ credentials: 'include',
216
+ });
217
+
218
+ if (!optionsResponse.ok) {
219
+ return { success: false, error: 'Konnte Passkey-Optionen nicht laden' };
220
+ }
221
+
222
+ const options = await optionsResponse.json();
223
+
224
+ // Step 2: Convert challenge from base64url to ArrayBuffer
225
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
226
+
227
+ // Step 3: Get credential from browser's WebAuthn API
228
+ const credential = (await navigator.credentials.get({
229
+ publicKey: {
230
+ challenge: challengeBuffer,
231
+ rpId: options.rpId,
232
+ allowCredentials: options.allowCredentials || [],
233
+ userVerification: options.userVerification,
234
+ timeout: options.timeout,
235
+ },
236
+ })) as PublicKeyCredential | null;
237
+
238
+ if (!credential) {
239
+ return { success: false, error: 'Kein Passkey ausgewählt' };
240
+ }
241
+
242
+ // Step 4: Convert credential response to base64url format
243
+ const response = credential.response as AuthenticatorAssertionResponse;
244
+ const credentialBody = {
245
+ id: credential.id,
246
+ rawId: arrayBufferToBase64Url(credential.rawId),
247
+ type: credential.type,
248
+ response: {
249
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
250
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
251
+ signature: arrayBufferToBase64Url(response.signature),
252
+ userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
253
+ },
254
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
255
+ };
256
+
257
+ // Step 5: Verify with server
258
+ const authResponse = await fetch(`${apiUrl}/iam/passkey/verify-authentication`, {
259
+ method: 'POST',
260
+ credentials: 'include',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify(credentialBody),
263
+ });
264
+
265
+ const result = await authResponse.json();
266
+
267
+ if (!authResponse.ok) {
268
+ return { success: false, error: result.message || 'Passkey-Anmeldung fehlgeschlagen' };
269
+ }
270
+
271
+ // Store user data after successful passkey login
272
+ if (result.user) {
273
+ setUser(result.user as BetterAuthUser);
274
+ }
275
+
276
+ return { success: true, user: result.user as BetterAuthUser };
277
+ } catch (err: unknown) {
278
+ // Handle WebAuthn-specific errors
279
+ if (err instanceof Error && err.name === 'NotAllowedError') {
280
+ return { success: false, error: 'Passkey-Authentifizierung wurde abgebrochen' };
281
+ }
282
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen' };
283
+ } finally {
284
+ isLoading.value = false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Register a new passkey for the current user
290
+ *
291
+ * This function handles the complete WebAuthn registration flow:
292
+ * 1. Fetches registration options from the server
293
+ * 2. Prompts the user to create a passkey via the browser's WebAuthn API
294
+ * 3. Sends the credential to the server for storage
295
+ *
296
+ * @param name - Optional name for the passkey
297
+ * @returns Result with success status or error message
298
+ */
299
+ async function registerPasskey(name?: string): Promise<{ success: boolean; error?: string; passkey?: any }> {
300
+ isLoading.value = true;
301
+
302
+ try {
303
+ // Direct API access (CORS is configured on the server for trusted origins)
304
+ const runtimeConfig = useRuntimeConfig();
305
+ const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
306
+
307
+ // Step 1: Get registration options from server
308
+ const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-register-options`, {
309
+ method: 'GET',
310
+ credentials: 'include',
311
+ });
312
+
313
+ if (!optionsResponse.ok) {
314
+ const error = await optionsResponse.json().catch(() => ({}));
315
+ return { success: false, error: error.message || 'Konnte Registrierungsoptionen nicht laden' };
316
+ }
317
+
318
+ const options = await optionsResponse.json();
319
+
320
+ // Step 2: Convert challenge from base64url to ArrayBuffer
321
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
322
+
323
+ // Step 3: Convert user.id from base64url to ArrayBuffer
324
+ const userIdBuffer = base64UrlToUint8Array(options.user.id).buffer as ArrayBuffer;
325
+
326
+ // Step 4: Create credential via browser's WebAuthn API
327
+ const credential = (await navigator.credentials.create({
328
+ publicKey: {
329
+ challenge: challengeBuffer,
330
+ rp: options.rp,
331
+ user: {
332
+ ...options.user,
333
+ id: userIdBuffer,
334
+ },
335
+ pubKeyCredParams: options.pubKeyCredParams,
336
+ timeout: options.timeout,
337
+ attestation: options.attestation,
338
+ authenticatorSelection: options.authenticatorSelection,
339
+ excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
340
+ ...cred,
341
+ id: base64UrlToUint8Array(cred.id).buffer as ArrayBuffer,
342
+ })),
343
+ },
344
+ })) as PublicKeyCredential | null;
345
+
346
+ if (!credential) {
347
+ return { success: false, error: 'Passkey-Erstellung abgebrochen' };
348
+ }
349
+
350
+ // Step 5: Convert credential response to base64url format
351
+ const attestationResponse = credential.response as AuthenticatorAttestationResponse;
352
+ const credentialBody = {
353
+ name,
354
+ response: {
355
+ id: credential.id,
356
+ rawId: arrayBufferToBase64Url(credential.rawId),
357
+ type: credential.type,
358
+ response: {
359
+ attestationObject: arrayBufferToBase64Url(attestationResponse.attestationObject),
360
+ clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON),
361
+ transports: attestationResponse.getTransports?.() || [],
362
+ },
363
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
364
+ },
365
+ };
366
+
367
+ // Step 6: Send to server for verification and storage
368
+ const registerResponse = await fetch(`${apiUrl}/iam/passkey/verify-registration`, {
369
+ method: 'POST',
370
+ credentials: 'include',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify(credentialBody),
373
+ });
374
+
375
+ const result = await registerResponse.json();
376
+
377
+ if (!registerResponse.ok) {
378
+ return { success: false, error: result.message || 'Passkey-Registrierung fehlgeschlagen' };
379
+ }
380
+
381
+ return { success: true, passkey: result };
382
+ } catch (err: unknown) {
383
+ if (err instanceof Error && err.name === 'NotAllowedError') {
384
+ return { success: false, error: 'Passkey-Erstellung wurde abgebrochen' };
385
+ }
386
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Registrierung fehlgeschlagen' };
387
+ } finally {
388
+ isLoading.value = false;
389
+ }
390
+ }
391
+
176
392
  return {
393
+ authenticateWithPasskey,
177
394
  changePassword: authClient.changePassword,
178
395
  clearUser,
179
396
  is2FAEnabled,
@@ -181,6 +398,7 @@ export function useBetterAuth() {
181
398
  isAuthenticated,
182
399
  isLoading: computed(() => isLoading.value),
183
400
  passkey: authClient.passkey,
401
+ registerPasskey,
184
402
  setUser,
185
403
  signIn,
186
404
  signOut,
@@ -80,9 +80,18 @@ export interface AuthClientConfig {
80
80
  * plain text password transmission over the network.
81
81
  */
82
82
  export function createBetterAuthClient(config: AuthClientConfig = {}) {
83
+ // In development, use empty baseURL and /api/iam path to leverage Nuxt server proxy
84
+ // This is REQUIRED for WebAuthn/Passkey to work correctly because:
85
+ // - Frontend runs on localhost:3002, API on localhost:3000
86
+ // - WebAuthn validates the origin, which must be consistent
87
+ // - The Nuxt server proxy ensures requests come from the frontend origin
88
+ const isDev = import.meta.env?.DEV || process.env.NODE_ENV === 'development';
89
+ const defaultBaseURL = isDev ? '' : (import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000');
90
+ const defaultBasePath = isDev ? '/api/iam' : '/iam';
91
+
83
92
  const {
84
- baseURL = import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000',
85
- basePath = '/iam',
93
+ baseURL = defaultBaseURL,
94
+ basePath = defaultBasePath,
86
95
  twoFactorRedirectPath = '/auth/2fa',
87
96
  enableAdmin = true,
88
97
  enableTwoFactor = true,
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ // ============================================================================
3
+ // Composables
4
+ // ============================================================================
5
+ const { user, signOut } = useBetterAuth();
6
+
7
+ // ============================================================================
8
+ // Variables
9
+ // ============================================================================
10
+ const pages = [
11
+ {
12
+ title: 'Sicherheit',
13
+ description: 'Verwalte 2FA, Passkeys und Kontosicherheit',
14
+ icon: 'i-lucide-shield-check',
15
+ to: '/app/settings/security',
16
+ },
17
+ ];
18
+
19
+ // ============================================================================
20
+ // Functions
21
+ // ============================================================================
22
+ async function handleSignOut(): Promise<void> {
23
+ await signOut();
24
+ await navigateTo('/auth/login');
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="mx-auto max-w-4xl px-4 py-8">
30
+ <!-- Welcome Header -->
31
+ <div class="mb-8">
32
+ <h1 class="text-3xl font-bold">
33
+ Willkommen{{ user?.name ? `, ${user.name}` : '' }}!
34
+ </h1>
35
+ <p class="mt-2 text-muted">
36
+ {{ user?.email }}
37
+ </p>
38
+ </div>
39
+
40
+ <!-- Quick Actions -->
41
+ <div class="mb-8">
42
+ <h2 class="mb-4 text-xl font-semibold">Schnellzugriff</h2>
43
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
44
+ <UCard
45
+ v-for="page in pages"
46
+ :key="page.to"
47
+ class="cursor-pointer transition-shadow hover:shadow-lg"
48
+ @click="navigateTo(page.to)"
49
+ >
50
+ <div class="flex items-start gap-4">
51
+ <div class="rounded-lg bg-primary/10 p-3">
52
+ <UIcon :name="page.icon" class="size-6 text-primary" />
53
+ </div>
54
+ <div>
55
+ <h3 class="font-semibold">{{ page.title }}</h3>
56
+ <p class="mt-1 text-sm text-muted">{{ page.description }}</p>
57
+ </div>
58
+ </div>
59
+ </UCard>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- User Info Card -->
64
+ <UCard>
65
+ <template #header>
66
+ <div class="flex items-center gap-3">
67
+ <UIcon name="i-lucide-user" class="size-6 text-primary" />
68
+ <h2 class="font-semibold">Dein Konto</h2>
69
+ </div>
70
+ </template>
71
+
72
+ <div class="space-y-3">
73
+ <div class="flex justify-between">
74
+ <span class="text-muted">Name</span>
75
+ <span class="font-medium">{{ user?.name || '-' }}</span>
76
+ </div>
77
+ <div class="flex justify-between">
78
+ <span class="text-muted">E-Mail</span>
79
+ <span class="font-medium">{{ user?.email || '-' }}</span>
80
+ </div>
81
+ <div class="flex justify-between">
82
+ <span class="text-muted">E-Mail verifiziert</span>
83
+ <UBadge :color="user?.emailVerified ? 'success' : 'warning'">
84
+ {{ user?.emailVerified ? 'Ja' : 'Nein' }}
85
+ </UBadge>
86
+ </div>
87
+ </div>
88
+
89
+ <template #footer>
90
+ <UButton
91
+ color="error"
92
+ variant="outline"
93
+ icon="i-lucide-log-out"
94
+ @click="handleSignOut"
95
+ >
96
+ Abmelden
97
+ </UButton>
98
+ </template>
99
+ </UCard>
100
+ </div>
101
+ </template>
@@ -24,7 +24,7 @@ interface Passkey {
24
24
  // ============================================================================
25
25
  const toast = useToast();
26
26
  const overlay = useOverlay();
27
- const { is2FAEnabled, user } = useBetterAuth();
27
+ const { is2FAEnabled, registerPasskey, setUser, user } = useBetterAuth();
28
28
 
29
29
  // ============================================================================
30
30
  // Variables
@@ -39,6 +39,11 @@ const passkeyLoading = ref<boolean>(false);
39
39
  const newPasskeyName = ref<string>('');
40
40
  const showAddPasskey = ref<boolean>(false);
41
41
 
42
+ // Form states for UForm (required for proper data binding)
43
+ const enable2FAForm = reactive({ password: '' });
44
+ const disable2FAForm = reactive({ password: '' });
45
+ const totpForm = reactive({ code: '' });
46
+
42
47
  const passwordSchema = v.object({
43
48
  password: v.pipe(v.string('Passwort ist erforderlich'), v.minLength(1, 'Passwort ist erforderlich')),
44
49
  });
@@ -70,14 +75,13 @@ async function addPasskey(): Promise<void> {
70
75
  passkeyLoading.value = true;
71
76
 
72
77
  try {
73
- const { error } = await authClient.passkey.addPasskey({
74
- name: newPasskeyName.value,
75
- });
78
+ // Use custom registerPasskey method with direct API calls
79
+ const result = await registerPasskey(newPasskeyName.value);
76
80
 
77
- if (error) {
81
+ if (!result.success) {
78
82
  toast.add({
79
83
  color: 'error',
80
- description: error.message || 'Passkey konnte nicht hinzugefügt werden',
84
+ description: result.error || 'Passkey konnte nicht hinzugefügt werden',
81
85
  title: 'Fehler',
82
86
  });
83
87
  return;
@@ -143,6 +147,11 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
143
147
  return;
144
148
  }
145
149
 
150
+ // Update user state to reflect 2FA disabled
151
+ if (user.value) {
152
+ setUser({ ...user.value, twoFactorEnabled: false });
153
+ }
154
+
146
155
  toast.add({
147
156
  color: 'success',
148
157
  description: '2FA wurde deaktiviert',
@@ -150,6 +159,7 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
150
159
  });
151
160
 
152
161
  show2FADisable.value = false;
162
+ disable2FAForm.password = '';
153
163
  } finally {
154
164
  loading.value = false;
155
165
  }
@@ -175,6 +185,7 @@ async function enable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<void
175
185
  totpUri.value = data?.totpURI ?? '';
176
186
  backupCodes.value = data?.backupCodes ?? [];
177
187
  showTotpSetup.value = true;
188
+ enable2FAForm.password = '';
178
189
  } finally {
179
190
  loading.value = false;
180
191
  }
@@ -224,6 +235,11 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
224
235
  return;
225
236
  }
226
237
 
238
+ // Update user state to reflect 2FA enabled
239
+ if (user.value) {
240
+ setUser({ ...user.value, twoFactorEnabled: true });
241
+ }
242
+
227
243
  toast.add({
228
244
  color: 'success',
229
245
  description: '2FA wurde erfolgreich aktiviert',
@@ -231,6 +247,7 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
231
247
  });
232
248
 
233
249
  showTotpSetup.value = false;
250
+ totpForm.code = '';
234
251
  await openBackupCodesModal(backupCodes.value);
235
252
  } finally {
236
253
  loading.value = false;
@@ -271,9 +288,9 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
271
288
  </div>
272
289
 
273
290
  <template v-if="!is2FAEnabled && !showTotpSetup">
274
- <UForm :schema="passwordSchema" class="space-y-4" @submit="enable2FA">
291
+ <UForm :schema="passwordSchema" :state="enable2FAForm" class="space-y-4" @submit="enable2FA">
275
292
  <UFormField label="Passwort bestätigen" name="password">
276
- <UInput name="password" type="password" placeholder="Dein Passwort" />
293
+ <UInput v-model="enable2FAForm.password" type="password" placeholder="Dein Passwort" />
277
294
  </UFormField>
278
295
  <UButton type="submit" :loading="loading"> 2FA aktivieren </UButton>
279
296
  </UForm>
@@ -281,15 +298,15 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
281
298
 
282
299
  <template v-if="showTotpSetup">
283
300
  <div class="space-y-4">
284
- <UAlert color="info" icon="i-lucide-info"> Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy) und gib den Code ein. </UAlert>
301
+ <p class="text-sm text-muted">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy) und gib den Code ein.</p>
285
302
 
286
303
  <div class="flex justify-center">
287
304
  <img v-if="totpUri" :src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`" alt="TOTP QR Code" class="rounded-lg" />
288
305
  </div>
289
306
 
290
- <UForm :schema="totpSchema" class="space-y-4" @submit="verifyTotp">
307
+ <UForm :schema="totpSchema" :state="totpForm" class="space-y-4" @submit="verifyTotp">
291
308
  <UFormField label="Verifizierungscode" name="code">
292
- <UInput name="code" placeholder="000000" class="text-center font-mono" />
309
+ <UInput v-model="totpForm.code" placeholder="000000" class="text-center font-mono" />
293
310
  </UFormField>
294
311
  <div class="flex gap-2">
295
312
  <UButton type="submit" :loading="loading"> Verifizieren </UButton>
@@ -307,10 +324,10 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
307
324
  </template>
308
325
 
309
326
  <template v-if="show2FADisable">
310
- <UForm :schema="passwordSchema" class="space-y-4" @submit="disable2FA">
327
+ <UForm :schema="passwordSchema" :state="disable2FAForm" class="space-y-4" @submit="disable2FA">
311
328
  <UAlert color="warning" icon="i-lucide-alert-triangle"> 2FA zu deaktivieren verringert die Sicherheit deines Kontos. </UAlert>
312
329
  <UFormField label="Passwort bestätigen" name="password">
313
- <UInput name="password" type="password" placeholder="Dein Passwort" />
330
+ <UInput v-model="disable2FAForm.password" type="password" placeholder="Dein Passwort" />
314
331
  </UFormField>
315
332
  <div class="flex gap-2">
316
333
  <UButton type="submit" color="error" :loading="loading"> 2FA deaktivieren </UButton>
@@ -127,8 +127,13 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
127
127
  }
128
128
 
129
129
  // Check if 2FA is required
130
+ // Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
130
131
  const resultData = 'data' in result ? result.data : result;
131
- if (resultData && 'twoFactorRedirect' in resultData && resultData.twoFactorRedirect) {
132
+ const requires2FA = resultData && (
133
+ ('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) ||
134
+ ('requiresTwoFactor' in resultData && resultData.requiresTwoFactor)
135
+ );
136
+ if (requires2FA) {
132
137
  // Redirect to 2FA page
133
138
  await navigateTo('/auth/2fa');
134
139
  return;
@@ -7,12 +7,11 @@ import type { InferOutput } from 'valibot';
7
7
 
8
8
  import * as v from 'valibot';
9
9
 
10
- import { authClient } from '~/lib/auth-client';
11
-
12
10
  // ============================================================================
13
11
  // Composables
14
12
  // ============================================================================
15
13
  const toast = useToast();
14
+ const { signUp, signIn } = useBetterAuth();
16
15
 
17
16
  // ============================================================================
18
17
  // Page Meta
@@ -25,8 +24,6 @@ definePageMeta({
25
24
  // Variables
26
25
  // ============================================================================
27
26
  const loading = ref<boolean>(false);
28
- const showPasskeyPrompt = ref<boolean>(false);
29
- const passkeyLoading = ref<boolean>(false);
30
27
 
31
28
  const fields: AuthFormField[] = [
32
29
  {
@@ -74,111 +71,86 @@ const schema = v.pipe(
74
71
 
75
72
  type Schema = InferOutput<typeof schema>;
76
73
 
77
- async function addPasskey(): Promise<void> {
78
- passkeyLoading.value = true;
74
+ // ============================================================================
75
+ // Functions
76
+ // ============================================================================
77
+ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
78
+ loading.value = true;
79
79
 
80
80
  try {
81
- const { error } = await authClient.passkey.addPasskey({
82
- name: 'Mein Gerät',
81
+ // Step 1: Sign up
82
+ const signUpResult = await signUp.email({
83
+ email: payload.data.email,
84
+ name: payload.data.name,
85
+ password: payload.data.password,
83
86
  });
84
87
 
85
- if (error) {
88
+ const signUpError = 'error' in signUpResult ? signUpResult.error : null;
89
+
90
+ if (signUpError) {
86
91
  toast.add({
87
92
  color: 'error',
88
- description: error.message || 'Passkey konnte nicht hinzugefügt werden',
93
+ description: signUpError.message || 'Registrierung fehlgeschlagen',
89
94
  title: 'Fehler',
90
95
  });
91
96
  return;
92
97
  }
93
98
 
94
- toast.add({
95
- color: 'success',
96
- description: 'Passkey wurde erfolgreich hinzugefügt',
97
- title: 'Erfolg',
98
- });
99
-
100
- await navigateTo('/app');
101
- } finally {
102
- passkeyLoading.value = false;
103
- }
104
- }
105
-
106
- // ============================================================================
107
- // Functions
108
- // ============================================================================
109
- async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
110
- loading.value = true;
111
-
112
- try {
113
- const { error } = await authClient.signUp.email({
99
+ // Step 2: Sign in to create session (required for passkey registration)
100
+ const signInResult = await signIn.email({
114
101
  email: payload.data.email,
115
- name: payload.data.name,
116
102
  password: payload.data.password,
117
103
  });
118
104
 
119
- if (error) {
105
+ const signInError = 'error' in signInResult ? signInResult.error : null;
106
+
107
+ if (signInError) {
108
+ // Sign-up was successful but sign-in failed - still show success and redirect
120
109
  toast.add({
121
- color: 'error',
122
- description: error.message || 'Registrierung fehlgeschlagen',
123
- title: 'Fehler',
110
+ color: 'success',
111
+ description: 'Dein Konto wurde erstellt. Bitte melde dich an.',
112
+ title: 'Willkommen!',
124
113
  });
114
+ await navigateTo('/auth/login');
125
115
  return;
126
116
  }
127
117
 
128
118
  toast.add({
129
119
  color: 'success',
130
- description: 'Dein Konto wurde erfolgreich erstellt',
120
+ description: 'Dein Konto wurde erfolgreich erstellt. Du kannst Passkeys später in den Sicherheitseinstellungen hinzufügen.',
131
121
  title: 'Willkommen!',
132
122
  });
133
123
 
134
- showPasskeyPrompt.value = true;
124
+ // Navigate to app - passkeys can be added later in security settings
125
+ // Note: Immediate passkey registration after sign-up is currently not supported
126
+ // due to session handling differences between nest-server and Better Auth
127
+ await navigateTo('/app', { external: true });
135
128
  } finally {
136
129
  loading.value = false;
137
130
  }
138
131
  }
139
-
140
- async function skipPasskey(): Promise<void> {
141
- await navigateTo('/app');
142
- }
143
132
  </script>
144
133
 
145
134
  <template>
146
135
  <UPageCard class="w-md" variant="naked">
147
- <template v-if="!showPasskeyPrompt">
148
- <UAuthForm
149
- :schema="schema"
150
- title="Registrieren"
151
- icon="i-lucide-user-plus"
152
- :fields="fields"
153
- :loading="loading"
154
- :submit="{
155
- label: 'Konto erstellen',
156
- block: true,
157
- }"
158
- @submit="onSubmit"
159
- >
160
- <template #footer>
161
- <p class="text-center text-sm text-muted">
162
- Bereits ein Konto?
163
- <ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
164
- </p>
165
- </template>
166
- </UAuthForm>
167
- </template>
168
-
169
- <template v-else>
170
- <div class="flex flex-col items-center gap-6">
171
- <UIcon name="i-lucide-key" class="size-16 text-primary" />
172
- <div class="text-center">
173
- <h2 class="text-xl font-semibold">Passkey hinzufügen?</h2>
174
- <p class="mt-2 text-sm text-muted">Mit einem Passkey kannst du dich schnell und sicher ohne Passwort anmelden.</p>
175
- </div>
176
-
177
- <div class="flex w-full flex-col gap-3">
178
- <UButton block :loading="passkeyLoading" @click="addPasskey"> Passkey hinzufügen </UButton>
179
- <UButton block variant="outline" color="neutral" @click="skipPasskey"> Später einrichten </UButton>
180
- </div>
181
- </div>
182
- </template>
136
+ <UAuthForm
137
+ :schema="schema"
138
+ title="Registrieren"
139
+ icon="i-lucide-user-plus"
140
+ :fields="fields"
141
+ :loading="loading"
142
+ :submit="{
143
+ label: 'Konto erstellen',
144
+ block: true,
145
+ }"
146
+ @submit="onSubmit"
147
+ >
148
+ <template #footer>
149
+ <p class="text-center text-sm text-muted">
150
+ Bereits ein Konto?
151
+ <ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
152
+ </p>
153
+ </template>
154
+ </UAuthForm>
183
155
  </UPageCard>
184
156
  </template>
@@ -11,3 +11,34 @@ export async function sha256(message: string): Promise<string> {
11
11
  const hashArray = Array.from(new Uint8Array(hashBuffer));
12
12
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
13
13
  }
14
+
15
+ // ============================================================================
16
+ // WebAuthn/Passkey Utilities
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Converts an ArrayBuffer to a base64url-encoded string
21
+ * Used for WebAuthn credential responses
22
+ *
23
+ * @param buffer - The ArrayBuffer to convert
24
+ * @returns The base64url-encoded string (no padding)
25
+ */
26
+ export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
27
+ const bytes = new Uint8Array(buffer);
28
+ let binary = '';
29
+ bytes.forEach((b) => (binary += String.fromCharCode(b)));
30
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
31
+ }
32
+
33
+ /**
34
+ * Converts a base64url-encoded string to a Uint8Array
35
+ * Used for WebAuthn challenge decoding
36
+ *
37
+ * @param base64url - The base64url-encoded string
38
+ * @returns The decoded Uint8Array
39
+ */
40
+ export function base64UrlToUint8Array(base64url: string): Uint8Array {
41
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
42
+ const paddedBase64 = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
43
+ return Uint8Array.from(atob(paddedBase64), (c) => c.charCodeAt(0));
44
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Proxy handler for Better Auth API requests
3
+ *
4
+ * This proxy is REQUIRED for WebAuthn/Passkey functionality in development mode because:
5
+ * 1. Frontend runs on a different port (e.g., 3001/3002) than the API (3000)
6
+ * 2. WebAuthn validates the origin, which must be consistent
7
+ * 3. Cross-origin requests have cookie handling issues
8
+ *
9
+ * The proxy ensures:
10
+ * - Cookies are properly forwarded between frontend and API
11
+ * - The origin header is preserved for WebAuthn validation
12
+ * - Set-Cookie headers from the API are forwarded to the browser
13
+ */
14
+ export default defineEventHandler(async (event) => {
15
+ const path = getRouterParam(event, 'path') || '';
16
+ const apiUrl = process.env.API_URL || 'http://localhost:3000';
17
+ const targetUrl = `${apiUrl}/iam/${path}`;
18
+
19
+ // Get query string
20
+ const query = getQuery(event);
21
+ const queryString = new URLSearchParams(query as Record<string, string>).toString();
22
+ const fullUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl;
23
+
24
+ // Get request body for POST/PUT/PATCH requests
25
+ const method = event.method;
26
+ let body: any;
27
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
28
+ body = await readBody(event);
29
+ }
30
+
31
+ // Forward cookies from the incoming request
32
+ const cookieHeader = getHeader(event, 'cookie');
33
+ // Use the actual origin from the request, fallback to localhost
34
+ const originHeader = getHeader(event, 'origin') || getHeader(event, 'referer')?.replace(/\/[^/]*$/, '') || 'http://localhost:3001';
35
+
36
+ // Make the request to the API
37
+ const response = await $fetch.raw(fullUrl, {
38
+ method: method as any,
39
+ body: body ? JSON.stringify(body) : undefined,
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'Origin': originHeader,
43
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
44
+ },
45
+ credentials: 'include',
46
+ // Don't throw on error status codes
47
+ ignoreResponseError: true,
48
+ });
49
+
50
+ // Forward Set-Cookie headers from the API response
51
+ const setCookieHeaders = response.headers.getSetCookie?.() || [];
52
+ for (const cookie of setCookieHeaders) {
53
+ // Rewrite cookie to work on localhost (remove domain/port specifics)
54
+ const rewrittenCookie = cookie
55
+ .replace(/domain=[^;]+;?\s*/gi, '')
56
+ .replace(/secure;?\s*/gi, '');
57
+ appendResponseHeader(event, 'Set-Cookie', rewrittenCookie);
58
+ }
59
+
60
+ // Set response status
61
+ setResponseStatus(event, response.status);
62
+
63
+ // Return the response body
64
+ return response._data;
65
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nuxt-base",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Starter to generate a configured environment with VueJS, Nuxt, Tailwind, Eslint, Unit Tests, Playwright etc.",
5
5
  "license": "MIT",
6
6
  "repository": {