create-nuxt-base 1.0.3 → 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.
@@ -13,6 +13,7 @@ import { authClient } from '~/lib/auth-client';
13
13
  // Composables
14
14
  // ============================================================================
15
15
  const toast = useToast();
16
+ const { signIn, setUser, isLoading, validateSession } = useBetterAuth();
16
17
 
17
18
  // ============================================================================
18
19
  // Page Meta
@@ -51,22 +52,53 @@ const schema = v.object({
51
52
 
52
53
  type Schema = InferOutput<typeof schema>;
53
54
 
55
+ /**
56
+ * Handle passkey authentication
57
+ * Uses official Better Auth signIn.passkey() method
58
+ * @see https://www.better-auth.com/docs/plugins/passkey
59
+ */
54
60
  async function onPasskeyLogin(): Promise<void> {
55
61
  passkeyLoading.value = true;
56
62
 
57
63
  try {
58
- const { error } = await authClient.signIn.passkey();
64
+ // Use official Better Auth client method
65
+ // This calls: GET /passkey/generate-authenticate-options → POST /passkey/verify-authentication
66
+ const result = await authClient.signIn.passkey();
59
67
 
60
- if (error) {
68
+ // Check for error in response
69
+ if (result.error) {
61
70
  toast.add({
62
71
  color: 'error',
63
- description: error.message || 'Passkey-Anmeldung fehlgeschlagen',
72
+ description: result.error.message || 'Passkey-Anmeldung fehlgeschlagen',
64
73
  title: 'Fehler',
65
74
  });
66
75
  return;
67
76
  }
68
77
 
78
+ // Update auth state with user data if available
79
+ if (result.data?.user) {
80
+ setUser(result.data.user as any);
81
+ } else if (result.data?.session) {
82
+ // Passkey auth returns session without user - fetch user via session validation
83
+ await validateSession();
84
+ }
85
+
69
86
  await navigateTo('/app');
87
+ } catch (err: unknown) {
88
+ // Handle WebAuthn-specific errors
89
+ if (err instanceof Error && err.name === 'NotAllowedError') {
90
+ toast.add({
91
+ color: 'error',
92
+ description: 'Passkey-Authentifizierung wurde abgebrochen',
93
+ title: 'Fehler',
94
+ });
95
+ return;
96
+ }
97
+ toast.add({
98
+ color: 'error',
99
+ description: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen',
100
+ title: 'Fehler',
101
+ });
70
102
  } finally {
71
103
  passkeyLoading.value = false;
72
104
  }
@@ -79,21 +111,53 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
79
111
  loading.value = true;
80
112
 
81
113
  try {
82
- const { error } = await authClient.signIn.email({
114
+ const result = await signIn.email({
83
115
  email: payload.data.email,
84
116
  password: payload.data.password,
85
117
  });
86
118
 
87
- if (error) {
119
+ // Check for error in response
120
+ if ('error' in result && result.error) {
88
121
  toast.add({
89
122
  color: 'error',
90
- description: error.message || 'Anmeldung fehlgeschlagen',
123
+ description: (result.error as { message?: string }).message || 'Anmeldung fehlgeschlagen',
91
124
  title: 'Fehler',
92
125
  });
93
126
  return;
94
127
  }
95
128
 
96
- await navigateTo('/app');
129
+ // Check if 2FA is required
130
+ // Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
131
+ const resultData = 'data' in result ? result.data : result;
132
+ const requires2FA = resultData && (
133
+ ('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) ||
134
+ ('requiresTwoFactor' in resultData && resultData.requiresTwoFactor)
135
+ );
136
+ if (requires2FA) {
137
+ // Redirect to 2FA page
138
+ await navigateTo('/auth/2fa');
139
+ return;
140
+ }
141
+
142
+ // Check if login was successful (user data in response)
143
+ const userData = 'user' in result ? result.user : ('data' in result ? result.data?.user : null);
144
+ if (userData) {
145
+ // Auth state is already stored by useBetterAuth
146
+ // Navigate to app
147
+ await navigateTo('/app');
148
+ } else {
149
+ toast.add({
150
+ color: 'error',
151
+ description: 'Anmeldung fehlgeschlagen - keine Benutzerdaten erhalten',
152
+ title: 'Fehler',
153
+ });
154
+ }
155
+ } catch (err) {
156
+ toast.add({
157
+ color: 'error',
158
+ description: 'Ein unerwarteter Fehler ist aufgetreten',
159
+ title: 'Fehler',
160
+ });
97
161
  } finally {
98
162
  loading.value = false;
99
163
  }
@@ -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.0.3",
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": {