create-nuxt-base 1.1.1 → 1.2.0
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/.github/workflows/publish.yml +1 -1
- package/AUTH.md +16 -14
- package/CHANGELOG.md +15 -11
- package/README.md +5 -5
- package/nuxt-base-template/README.md +11 -11
- package/nuxt-base-template/app/composables/use-better-auth.ts +243 -56
- package/nuxt-base-template/app/layouts/default.vue +17 -0
- package/nuxt-base-template/app/lib/auth-client.ts +8 -11
- package/nuxt-base-template/app/lib/auth-state.ts +206 -0
- package/nuxt-base-template/app/middleware/admin.global.ts +27 -7
- package/nuxt-base-template/app/middleware/auth.global.ts +23 -6
- package/nuxt-base-template/app/middleware/guest.global.ts +22 -7
- package/nuxt-base-template/app/pages/app/index.vue +3 -17
- package/nuxt-base-template/app/pages/auth/2fa.vue +38 -6
- package/nuxt-base-template/app/pages/auth/login.vue +13 -20
- package/nuxt-base-template/app/pages/auth/register.vue +76 -25
- package/nuxt-base-template/app/plugins/auth-interceptor.client.ts +151 -0
- package/nuxt-base-template/server/api/iam/[...path].ts +12 -4
- package/package.json +2 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared authentication state for Cookie/JWT dual-mode authentication
|
|
3
|
+
*
|
|
4
|
+
* This module provides a reactive state that is shared between:
|
|
5
|
+
* - auth-client.ts (uses it for customFetch)
|
|
6
|
+
* - use-better-auth.ts (manages the state)
|
|
7
|
+
*
|
|
8
|
+
* Auth Mode Strategy:
|
|
9
|
+
* 1. Primary: Session cookies (more secure, HttpOnly)
|
|
10
|
+
* 2. Fallback: JWT tokens (when cookies are not available/working)
|
|
11
|
+
*
|
|
12
|
+
* The state is persisted in cookies for SSR compatibility.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type AuthMode = 'cookie' | 'jwt';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the current auth mode from cookie
|
|
19
|
+
*/
|
|
20
|
+
export function getAuthMode(): AuthMode {
|
|
21
|
+
if (import.meta.server) return 'cookie';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
25
|
+
if (cookie) {
|
|
26
|
+
const parts = cookie.split('=');
|
|
27
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
28
|
+
const state = JSON.parse(value);
|
|
29
|
+
return state?.authMode || 'cookie';
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore parse errors
|
|
33
|
+
}
|
|
34
|
+
return 'cookie';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the JWT token from cookie
|
|
39
|
+
*/
|
|
40
|
+
export function getJwtToken(): string | null {
|
|
41
|
+
if (import.meta.server) return null;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('jwt-token='));
|
|
45
|
+
if (cookie) {
|
|
46
|
+
const parts = cookie.split('=');
|
|
47
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
48
|
+
// Handle JSON-encoded string (useCookie stores as JSON)
|
|
49
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
50
|
+
return JSON.parse(value);
|
|
51
|
+
}
|
|
52
|
+
return value || null;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore parse errors
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set JWT token in cookie
|
|
62
|
+
*/
|
|
63
|
+
export function setJwtToken(token: string | null): void {
|
|
64
|
+
if (import.meta.server) return;
|
|
65
|
+
|
|
66
|
+
const maxAge = 60 * 60 * 24 * 7; // 7 days
|
|
67
|
+
if (token) {
|
|
68
|
+
document.cookie = `jwt-token=${encodeURIComponent(JSON.stringify(token))}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
69
|
+
} else {
|
|
70
|
+
document.cookie = `jwt-token=; path=/; max-age=0`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Update auth mode in the auth-state cookie
|
|
76
|
+
*/
|
|
77
|
+
export function setAuthMode(mode: AuthMode): void {
|
|
78
|
+
if (import.meta.server) return;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
82
|
+
|
|
83
|
+
let state = { user: null, authMode: mode };
|
|
84
|
+
if (cookie) {
|
|
85
|
+
const parts = cookie.split('=');
|
|
86
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
87
|
+
state = { ...JSON.parse(value), authMode: mode };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const maxAge = 60 * 60 * 24 * 7; // 7 days
|
|
91
|
+
document.cookie = `auth-state=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
92
|
+
} catch {
|
|
93
|
+
// Ignore errors
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the API base URL
|
|
99
|
+
*/
|
|
100
|
+
export function getApiBase(): string {
|
|
101
|
+
const isDev = import.meta.dev;
|
|
102
|
+
if (isDev) {
|
|
103
|
+
return '/api/iam';
|
|
104
|
+
}
|
|
105
|
+
// In production, try to get from runtime config or fall back to default
|
|
106
|
+
if (typeof window !== 'undefined' && (window as any).__NUXT__?.config?.public?.apiUrl) {
|
|
107
|
+
return `${(window as any).__NUXT__.config.public.apiUrl}/iam`;
|
|
108
|
+
}
|
|
109
|
+
return 'http://localhost:3000/iam';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Attempt to switch to JWT mode by fetching a token
|
|
114
|
+
*/
|
|
115
|
+
export async function attemptJwtSwitch(): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
const apiBase = getApiBase();
|
|
118
|
+
const response = await fetch(`${apiBase}/token`, {
|
|
119
|
+
method: 'GET',
|
|
120
|
+
credentials: 'include',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
if (data.token) {
|
|
126
|
+
setJwtToken(data.token);
|
|
127
|
+
setAuthMode('jwt');
|
|
128
|
+
console.debug('[Auth] Switched to JWT mode');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if user is authenticated (has auth-state with user)
|
|
140
|
+
*/
|
|
141
|
+
export function isAuthenticated(): boolean {
|
|
142
|
+
if (import.meta.server) return false;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
146
|
+
if (cookie) {
|
|
147
|
+
const parts = cookie.split('=');
|
|
148
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
149
|
+
const state = JSON.parse(value);
|
|
150
|
+
return !!state?.user;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Ignore parse errors
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Custom fetch function that handles Cookie/JWT dual-mode authentication
|
|
160
|
+
*
|
|
161
|
+
* This function:
|
|
162
|
+
* 1. In cookie mode: Uses credentials: 'include'
|
|
163
|
+
* 2. In JWT mode: Adds Authorization header
|
|
164
|
+
* 3. On 401 in cookie mode: Attempts to switch to JWT and retries
|
|
165
|
+
*/
|
|
166
|
+
export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
167
|
+
const authMode = getAuthMode();
|
|
168
|
+
const jwtToken = getJwtToken();
|
|
169
|
+
|
|
170
|
+
const headers = new Headers(init?.headers);
|
|
171
|
+
|
|
172
|
+
// In JWT mode, add Authorization header
|
|
173
|
+
if (authMode === 'jwt' && jwtToken) {
|
|
174
|
+
headers.set('Authorization', `Bearer ${jwtToken}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Always include credentials for cookie-based session auth
|
|
178
|
+
// In JWT mode, cookies are sent but ignored by the server (Authorization header is used instead)
|
|
179
|
+
// This is more robust than conditionally omitting cookies
|
|
180
|
+
const response = await fetch(input, {
|
|
181
|
+
...init,
|
|
182
|
+
headers,
|
|
183
|
+
credentials: 'include',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// If we get 401 in cookie mode and user is authenticated, try JWT fallback
|
|
187
|
+
if (response.status === 401 && authMode === 'cookie' && isAuthenticated()) {
|
|
188
|
+
console.debug('[Auth] Cookie auth failed, attempting JWT fallback...');
|
|
189
|
+
const switched = await attemptJwtSwitch();
|
|
190
|
+
|
|
191
|
+
if (switched) {
|
|
192
|
+
// Retry the request with JWT
|
|
193
|
+
const newToken = getJwtToken();
|
|
194
|
+
if (newToken) {
|
|
195
|
+
headers.set('Authorization', `Bearer ${newToken}`);
|
|
196
|
+
return fetch(input, {
|
|
197
|
+
...init,
|
|
198
|
+
headers,
|
|
199
|
+
credentials: 'include',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return response;
|
|
206
|
+
}
|
|
@@ -4,20 +4,40 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
|
+
let isAdmin = false;
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
10
|
+
// On client, read directly from document.cookie for accurate state
|
|
11
|
+
if (import.meta.client) {
|
|
12
|
+
try {
|
|
13
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
14
|
+
if (cookie) {
|
|
15
|
+
const parts = cookie.split('=');
|
|
16
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
17
|
+
const state = JSON.parse(value);
|
|
18
|
+
isAuthenticated = !!state?.user;
|
|
19
|
+
isAdmin = state?.user?.role === 'admin';
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Ignore parse errors
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
// On server, use useCookie
|
|
26
|
+
const authStateCookie = useCookie<{ user: { role?: string } | null; authMode: string } | null>('auth-state');
|
|
27
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
28
|
+
isAdmin = authStateCookie.value?.user?.role === 'admin';
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
// Redirect to login if not authenticated
|
|
15
|
-
if (!isAuthenticated
|
|
16
|
-
return navigateTo(
|
|
32
|
+
if (!isAuthenticated) {
|
|
33
|
+
return navigateTo({
|
|
34
|
+
path: '/auth/login',
|
|
35
|
+
query: { redirect: to.fullPath },
|
|
36
|
+
});
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
// Redirect to /app if authenticated but not admin
|
|
20
|
-
if (!isAdmin
|
|
40
|
+
if (!isAdmin) {
|
|
21
41
|
return navigateTo('/app');
|
|
22
42
|
}
|
|
23
43
|
});
|
|
@@ -4,15 +4,32 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
9
|
+
// On client, read directly from document.cookie for accurate state
|
|
10
|
+
if (import.meta.client) {
|
|
11
|
+
try {
|
|
12
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
13
|
+
if (cookie) {
|
|
14
|
+
const parts = cookie.split('=');
|
|
15
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
16
|
+
const state = JSON.parse(value);
|
|
17
|
+
isAuthenticated = !!state?.user;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore parse errors
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
// On server, use useCookie
|
|
24
|
+
const authStateCookie = useCookie<{ user: unknown; authMode: string } | null>('auth-state');
|
|
25
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
// Redirect to login if not authenticated
|
|
15
|
-
if (!isAuthenticated
|
|
16
|
-
return navigateTo(
|
|
29
|
+
if (!isAuthenticated) {
|
|
30
|
+
return navigateTo({
|
|
31
|
+
path: '/auth/login',
|
|
32
|
+
query: { redirect: to.fullPath },
|
|
33
|
+
});
|
|
17
34
|
}
|
|
18
35
|
});
|
|
@@ -4,15 +4,30 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
9
|
+
// On client, read directly from document.cookie for accurate state
|
|
10
|
+
if (import.meta.client) {
|
|
11
|
+
try {
|
|
12
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
13
|
+
if (cookie) {
|
|
14
|
+
const parts = cookie.split('=');
|
|
15
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
16
|
+
const state = JSON.parse(value);
|
|
17
|
+
isAuthenticated = !!state?.user;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore parse errors
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
// On server, use useCookie
|
|
24
|
+
const authStateCookie = useCookie<{ user: unknown; authMode: string } | null>('auth-state');
|
|
25
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
// Redirect to /app if already authenticated
|
|
15
|
-
if (isAuthenticated
|
|
16
|
-
|
|
28
|
+
// Redirect to /app (or redirect query) if already authenticated
|
|
29
|
+
if (isAuthenticated) {
|
|
30
|
+
const redirect = to.query.redirect as string;
|
|
31
|
+
return navigateTo(redirect || '/app');
|
|
17
32
|
}
|
|
18
33
|
});
|
|
@@ -29,9 +29,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
29
29
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
|
30
30
|
<!-- Welcome Header -->
|
|
31
31
|
<div class="mb-8">
|
|
32
|
-
<h1 class="text-3xl font-bold">
|
|
33
|
-
Willkommen{{ user?.name ? `, ${user.name}` : '' }}!
|
|
34
|
-
</h1>
|
|
32
|
+
<h1 class="text-3xl font-bold">Willkommen{{ user?.name ? `, ${user.name}` : '' }}!</h1>
|
|
35
33
|
<p class="mt-2 text-muted">
|
|
36
34
|
{{ user?.email }}
|
|
37
35
|
</p>
|
|
@@ -41,12 +39,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
41
39
|
<div class="mb-8">
|
|
42
40
|
<h2 class="mb-4 text-xl font-semibold">Schnellzugriff</h2>
|
|
43
41
|
<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
|
-
>
|
|
42
|
+
<UCard v-for="page in pages" :key="page.to" class="cursor-pointer transition-shadow hover:shadow-lg" @click="navigateTo(page.to)">
|
|
50
43
|
<div class="flex items-start gap-4">
|
|
51
44
|
<div class="rounded-lg bg-primary/10 p-3">
|
|
52
45
|
<UIcon :name="page.icon" class="size-6 text-primary" />
|
|
@@ -87,14 +80,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
87
80
|
</div>
|
|
88
81
|
|
|
89
82
|
<template #footer>
|
|
90
|
-
<UButton
|
|
91
|
-
color="error"
|
|
92
|
-
variant="outline"
|
|
93
|
-
icon="i-lucide-log-out"
|
|
94
|
-
@click="handleSignOut"
|
|
95
|
-
>
|
|
96
|
-
Abmelden
|
|
97
|
-
</UButton>
|
|
83
|
+
<UButton color="error" variant="outline" icon="i-lucide-log-out" @click="handleSignOut"> Abmelden </UButton>
|
|
98
84
|
</template>
|
|
99
85
|
</UCard>
|
|
100
86
|
</div>
|
|
@@ -13,7 +13,7 @@ import { authClient } from '~/lib/auth-client';
|
|
|
13
13
|
// Composables
|
|
14
14
|
// ============================================================================
|
|
15
15
|
const toast = useToast();
|
|
16
|
-
const { setUser,
|
|
16
|
+
const { fetchWithAuth, setUser, switchToJwtMode, jwtToken } = useBetterAuth();
|
|
17
17
|
|
|
18
18
|
// ============================================================================
|
|
19
19
|
// Page Meta
|
|
@@ -76,13 +76,45 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// Extract token and user data from response (JWT mode: cookies: false)
|
|
80
|
+
const token = result?.token || result?.data?.token;
|
|
80
81
|
const userData = result?.data?.user || result?.user;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
if (token) {
|
|
84
|
+
// JWT mode: Token is in the response
|
|
85
|
+
jwtToken.value = token;
|
|
86
|
+
if (userData) {
|
|
87
|
+
setUser(userData, 'jwt');
|
|
88
|
+
}
|
|
89
|
+
console.debug('[Auth] JWT token received from 2FA response');
|
|
90
|
+
} else if (userData) {
|
|
91
|
+
// Cookie mode: No token in response, use cookies
|
|
92
|
+
setUser(userData, 'cookie');
|
|
93
|
+
// Try to get JWT token for fallback
|
|
94
|
+
switchToJwtMode().catch(() => {});
|
|
83
95
|
} else {
|
|
84
|
-
// Fallback:
|
|
85
|
-
|
|
96
|
+
// Fallback: fetch session data from API using authenticated fetch
|
|
97
|
+
try {
|
|
98
|
+
const isDev = import.meta.dev;
|
|
99
|
+
const runtimeConfig = useRuntimeConfig();
|
|
100
|
+
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
101
|
+
const sessionResponse = await fetchWithAuth(`${apiBase}/get-session`);
|
|
102
|
+
if (sessionResponse.ok) {
|
|
103
|
+
const sessionData = await sessionResponse.json();
|
|
104
|
+
const sessionToken = sessionData?.token;
|
|
105
|
+
if (sessionToken) {
|
|
106
|
+
jwtToken.value = sessionToken;
|
|
107
|
+
if (sessionData?.user) {
|
|
108
|
+
setUser(sessionData.user, 'jwt');
|
|
109
|
+
}
|
|
110
|
+
} else if (sessionData?.user) {
|
|
111
|
+
setUser(sessionData.user, 'cookie');
|
|
112
|
+
switchToJwtMode().catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Ignore session fetch errors
|
|
117
|
+
}
|
|
86
118
|
}
|
|
87
119
|
|
|
88
120
|
await navigateTo('/app');
|
|
@@ -7,13 +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();
|
|
16
|
-
const { signIn, setUser, isLoading, validateSession } = useBetterAuth();
|
|
14
|
+
const { signIn, setUser, isLoading, validateSession, authenticateWithPasskey } = useBetterAuth();
|
|
17
15
|
|
|
18
16
|
// ============================================================================
|
|
19
17
|
// Page Meta
|
|
@@ -54,32 +52,30 @@ type Schema = InferOutput<typeof schema>;
|
|
|
54
52
|
|
|
55
53
|
/**
|
|
56
54
|
* Handle passkey authentication
|
|
57
|
-
* Uses
|
|
58
|
-
* @see https://www.better-auth.com/docs/plugins/passkey
|
|
55
|
+
* Uses authenticateWithPasskey from composable which supports JWT mode (challengeId)
|
|
59
56
|
*/
|
|
60
57
|
async function onPasskeyLogin(): Promise<void> {
|
|
61
58
|
passkeyLoading.value = true;
|
|
62
59
|
|
|
63
60
|
try {
|
|
64
|
-
// Use
|
|
65
|
-
|
|
66
|
-
const result = await authClient.signIn.passkey();
|
|
61
|
+
// Use composable method which handles challengeId for JWT mode
|
|
62
|
+
const result = await authenticateWithPasskey();
|
|
67
63
|
|
|
68
|
-
// Check for error in response
|
|
69
|
-
if (result.
|
|
64
|
+
// Check for error in response (authenticateWithPasskey returns { success, error?, user? })
|
|
65
|
+
if (!result.success) {
|
|
70
66
|
toast.add({
|
|
71
67
|
color: 'error',
|
|
72
|
-
description: result.error
|
|
68
|
+
description: result.error || 'Passkey-Anmeldung fehlgeschlagen',
|
|
73
69
|
title: 'Fehler',
|
|
74
70
|
});
|
|
75
71
|
return;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
// Update auth state with user data if available
|
|
79
|
-
if (result.
|
|
80
|
-
setUser(result.
|
|
81
|
-
} else
|
|
82
|
-
// Passkey auth
|
|
75
|
+
if (result.user) {
|
|
76
|
+
setUser(result.user as any);
|
|
77
|
+
} else {
|
|
78
|
+
// Passkey auth may return success without user - fetch user via session validation
|
|
83
79
|
await validateSession();
|
|
84
80
|
}
|
|
85
81
|
|
|
@@ -129,10 +125,7 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
129
125
|
// Check if 2FA is required
|
|
130
126
|
// Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
|
|
131
127
|
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
|
-
);
|
|
128
|
+
const requires2FA = resultData && (('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) || ('requiresTwoFactor' in resultData && resultData.requiresTwoFactor));
|
|
136
129
|
if (requires2FA) {
|
|
137
130
|
// Redirect to 2FA page
|
|
138
131
|
await navigateTo('/auth/2fa');
|
|
@@ -140,7 +133,7 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
140
133
|
}
|
|
141
134
|
|
|
142
135
|
// Check if login was successful (user data in response)
|
|
143
|
-
const userData = 'user' in result ? result.user :
|
|
136
|
+
const userData = 'user' in result ? result.user : 'data' in result ? result.data?.user : null;
|
|
144
137
|
if (userData) {
|
|
145
138
|
// Auth state is already stored by useBetterAuth
|
|
146
139
|
// Navigate to app
|
|
@@ -11,7 +11,7 @@ import * as v from 'valibot';
|
|
|
11
11
|
// Composables
|
|
12
12
|
// ============================================================================
|
|
13
13
|
const toast = useToast();
|
|
14
|
-
const { signUp, signIn } = useBetterAuth();
|
|
14
|
+
const { signUp, signIn, registerPasskey } = useBetterAuth();
|
|
15
15
|
|
|
16
16
|
// ============================================================================
|
|
17
17
|
// Page Meta
|
|
@@ -24,6 +24,8 @@ definePageMeta({
|
|
|
24
24
|
// Variables
|
|
25
25
|
// ============================================================================
|
|
26
26
|
const loading = ref<boolean>(false);
|
|
27
|
+
const showPasskeyPrompt = ref<boolean>(false);
|
|
28
|
+
const passkeyLoading = ref<boolean>(false);
|
|
27
29
|
|
|
28
30
|
const fields: AuthFormField[] = [
|
|
29
31
|
{
|
|
@@ -117,40 +119,89 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
117
119
|
|
|
118
120
|
toast.add({
|
|
119
121
|
color: 'success',
|
|
120
|
-
description: 'Dein Konto wurde erfolgreich erstellt
|
|
122
|
+
description: 'Dein Konto wurde erfolgreich erstellt',
|
|
121
123
|
title: 'Willkommen!',
|
|
122
124
|
});
|
|
123
125
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
// due to session handling differences between nest-server and Better Auth
|
|
127
|
-
await navigateTo('/app', { external: true });
|
|
126
|
+
// Show passkey prompt after successful registration + login
|
|
127
|
+
showPasskeyPrompt.value = true;
|
|
128
128
|
} finally {
|
|
129
129
|
loading.value = false;
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
async function addPasskey(): Promise<void> {
|
|
134
|
+
passkeyLoading.value = true;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// Use registerPasskey from composable which properly handles challengeId
|
|
138
|
+
const result = await registerPasskey('Mein Gerät');
|
|
139
|
+
|
|
140
|
+
if (!result.success) {
|
|
141
|
+
toast.add({
|
|
142
|
+
color: 'error',
|
|
143
|
+
description: result.error || 'Passkey konnte nicht hinzugefügt werden',
|
|
144
|
+
title: 'Fehler',
|
|
145
|
+
});
|
|
146
|
+
// Still navigate to app even if passkey failed
|
|
147
|
+
await navigateTo('/app');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toast.add({
|
|
152
|
+
color: 'success',
|
|
153
|
+
description: 'Passkey wurde erfolgreich hinzugefügt',
|
|
154
|
+
title: 'Erfolg',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await navigateTo('/app');
|
|
158
|
+
} finally {
|
|
159
|
+
passkeyLoading.value = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function skipPasskey(): Promise<void> {
|
|
164
|
+
await navigateTo('/app');
|
|
165
|
+
}
|
|
132
166
|
</script>
|
|
133
167
|
|
|
134
168
|
<template>
|
|
135
169
|
<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
|
-
|
|
170
|
+
<template v-if="!showPasskeyPrompt">
|
|
171
|
+
<UAuthForm
|
|
172
|
+
:schema="schema"
|
|
173
|
+
title="Registrieren"
|
|
174
|
+
icon="i-lucide-user-plus"
|
|
175
|
+
:fields="fields"
|
|
176
|
+
:loading="loading"
|
|
177
|
+
:submit="{
|
|
178
|
+
label: 'Konto erstellen',
|
|
179
|
+
block: true,
|
|
180
|
+
}"
|
|
181
|
+
@submit="onSubmit"
|
|
182
|
+
>
|
|
183
|
+
<template #footer>
|
|
184
|
+
<p class="text-center text-sm text-muted">
|
|
185
|
+
Bereits ein Konto?
|
|
186
|
+
<ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
|
|
187
|
+
</p>
|
|
188
|
+
</template>
|
|
189
|
+
</UAuthForm>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<template v-else>
|
|
193
|
+
<div class="flex flex-col items-center gap-6">
|
|
194
|
+
<UIcon name="i-lucide-key" class="size-16 text-primary" />
|
|
195
|
+
<div class="text-center">
|
|
196
|
+
<h2 class="text-xl font-semibold">Passkey hinzufügen?</h2>
|
|
197
|
+
<p class="mt-2 text-sm text-muted">Mit einem Passkey kannst du dich schnell und sicher ohne Passwort anmelden.</p>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="flex w-full flex-col gap-3">
|
|
201
|
+
<UButton block :loading="passkeyLoading" @click="addPasskey"> Passkey hinzufügen </UButton>
|
|
202
|
+
<UButton block variant="outline" color="neutral" @click="skipPasskey"> Später einrichten </UButton>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
155
206
|
</UPageCard>
|
|
156
207
|
</template>
|