create-nuxt-base 1.1.2 → 2.0.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 +46 -11
- package/README.md +5 -5
- package/nuxt-base-template/README.md +11 -11
- package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +2 -1
- package/nuxt-base-template/app/components/Upload/TusFileUpload.vue +7 -7
- package/nuxt-base-template/app/interfaces/user.interface.ts +5 -12
- package/nuxt-base-template/app/layouts/default.vue +1 -1
- 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 +4 -18
- package/nuxt-base-template/app/pages/app/settings/security.vue +2 -2
- package/nuxt-base-template/app/pages/auth/2fa.vue +39 -8
- package/nuxt-base-template/app/pages/auth/forgot-password.vue +2 -1
- package/nuxt-base-template/app/pages/auth/login.vue +14 -21
- package/nuxt-base-template/app/pages/auth/register.vue +5 -8
- package/nuxt-base-template/app/pages/auth/reset-password.vue +2 -1
- package/nuxt-base-template/docs/pages/docs.vue +1 -1
- package/nuxt-base-template/nuxt.config.ts +38 -1
- package/nuxt-base-template/package-lock.json +136 -2905
- package/nuxt-base-template/package.json +1 -0
- package/nuxt-base-template/server/api/iam/[...path].ts +12 -4
- package/package.json +2 -2
- package/nuxt-base-template/app/components/Transition/TransitionFade.vue +0 -27
- package/nuxt-base-template/app/components/Transition/TransitionFadeScale.vue +0 -27
- package/nuxt-base-template/app/components/Transition/TransitionSlide.vue +0 -12
- package/nuxt-base-template/app/components/Transition/TransitionSlideBottom.vue +0 -12
- package/nuxt-base-template/app/components/Transition/TransitionSlideRevert.vue +0 -12
- package/nuxt-base-template/app/composables/use-better-auth.ts +0 -415
- package/nuxt-base-template/app/composables/use-file.ts +0 -71
- package/nuxt-base-template/app/composables/use-share.ts +0 -38
- package/nuxt-base-template/app/composables/use-tus-upload.ts +0 -278
- package/nuxt-base-template/app/composables/use-tw.ts +0 -1
- package/nuxt-base-template/app/interfaces/upload.interface.ts +0 -58
- package/nuxt-base-template/app/lib/auth-client.ts +0 -232
- package/nuxt-base-template/app/plugins/auth-interceptor.client.ts +0 -143
- package/nuxt-base-template/app/utils/crypto.ts +0 -44
|
@@ -28,8 +28,9 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
body = await readBody(event);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// Forward cookies from the incoming request
|
|
31
|
+
// Forward cookies and authorization from the incoming request
|
|
32
32
|
const cookieHeader = getHeader(event, 'cookie');
|
|
33
|
+
const authHeader = getHeader(event, 'authorization');
|
|
33
34
|
// Use the actual origin from the request, fallback to localhost
|
|
34
35
|
const originHeader = getHeader(event, 'origin') || getHeader(event, 'referer')?.replace(/\/[^/]*$/, '') || 'http://localhost:3001';
|
|
35
36
|
|
|
@@ -39,8 +40,9 @@ export default defineEventHandler(async (event) => {
|
|
|
39
40
|
body: body ? JSON.stringify(body) : undefined,
|
|
40
41
|
headers: {
|
|
41
42
|
'Content-Type': 'application/json',
|
|
42
|
-
|
|
43
|
+
Origin: originHeader,
|
|
43
44
|
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
|
45
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
44
46
|
},
|
|
45
47
|
credentials: 'include',
|
|
46
48
|
// Don't throw on error status codes
|
|
@@ -50,10 +52,16 @@ export default defineEventHandler(async (event) => {
|
|
|
50
52
|
// Forward Set-Cookie headers from the API response
|
|
51
53
|
const setCookieHeaders = response.headers.getSetCookie?.() || [];
|
|
52
54
|
for (const cookie of setCookieHeaders) {
|
|
53
|
-
// Rewrite cookie to work on localhost
|
|
55
|
+
// Rewrite cookie to work on localhost:
|
|
56
|
+
// 1. Remove domain (cookie will be set for current origin)
|
|
57
|
+
// 2. Remove secure flag (not needed for localhost)
|
|
58
|
+
// 3. Rewrite path from /iam to /api/iam (or set to / for broader access)
|
|
54
59
|
const rewrittenCookie = cookie
|
|
55
60
|
.replace(/domain=[^;]+;?\s*/gi, '')
|
|
56
|
-
.replace(/secure;?\s*/gi, '')
|
|
61
|
+
.replace(/secure;?\s*/gi, '')
|
|
62
|
+
// Ensure path is set to / so cookies work for all routes
|
|
63
|
+
.replace(/path=\/iam[^;]*;?\s*/gi, 'path=/; ')
|
|
64
|
+
.replace(/path=[^;]+;?\s*/gi, 'path=/; ');
|
|
57
65
|
appendResponseHeader(event, 'Set-Cookie', rewrittenCookie);
|
|
58
66
|
}
|
|
59
67
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-nuxt-base",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Starter to generate a configured environment with VueJS, Nuxt, Tailwind, Eslint, Unit Tests, Playwright etc.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "lenne.Tech GmbH",
|
|
6
7
|
"repository": {
|
|
7
8
|
"url": "https://github.com/lenneTech/nuxt-base-starter"
|
|
8
9
|
},
|
|
9
|
-
"author": "lenne.Tech GmbH",
|
|
10
10
|
"bin": {
|
|
11
11
|
"create-nuxt-base": "./index.js"
|
|
12
12
|
},
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
const props = withDefaults(
|
|
3
|
-
defineProps<{
|
|
4
|
-
leaveDuration?: `${number}` | number;
|
|
5
|
-
startDuration?: `${number}` | number;
|
|
6
|
-
}>(),
|
|
7
|
-
{
|
|
8
|
-
leaveDuration: 100,
|
|
9
|
-
startDuration: 100,
|
|
10
|
-
},
|
|
11
|
-
);
|
|
12
|
-
</script>
|
|
13
|
-
|
|
14
|
-
<template>
|
|
15
|
-
<div :style="`--start-duration: ${props.startDuration}ms; --leave-duration: ${props.leaveDuration}ms;`">
|
|
16
|
-
<Transition
|
|
17
|
-
enter-active-class="transition ease-out duration-[--start-duration]"
|
|
18
|
-
enter-from-class="transform opacity-0"
|
|
19
|
-
enter-to-class="transform opacity-100"
|
|
20
|
-
leave-active-class="transition ease-in duration-[--leave-duration]"
|
|
21
|
-
leave-from-class="transform opacity-100"
|
|
22
|
-
leave-to-class="transform opacity-0"
|
|
23
|
-
>
|
|
24
|
-
<slot></slot>
|
|
25
|
-
</Transition>
|
|
26
|
-
</div>
|
|
27
|
-
</template>
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
const props = withDefaults(
|
|
3
|
-
defineProps<{
|
|
4
|
-
leaveDuration?: `${number}` | number;
|
|
5
|
-
startDuration?: `${number}` | number;
|
|
6
|
-
}>(),
|
|
7
|
-
{
|
|
8
|
-
leaveDuration: 100,
|
|
9
|
-
startDuration: 100,
|
|
10
|
-
},
|
|
11
|
-
);
|
|
12
|
-
</script>
|
|
13
|
-
|
|
14
|
-
<template>
|
|
15
|
-
<div :style="`--start-duration: ${props.startDuration}ms; --leave-duration: ${props.leaveDuration}ms;`">
|
|
16
|
-
<Transition
|
|
17
|
-
enter-active-class="transition ease-out duration-[--start-duration]"
|
|
18
|
-
enter-from-class="transform opacity-0 scale-95"
|
|
19
|
-
enter-to-class="transform opacity-100 scale-100"
|
|
20
|
-
leave-active-class="transition ease-in duration-[--leave-duration]"
|
|
21
|
-
leave-from-class="transform opacity-100 scale-100"
|
|
22
|
-
leave-to-class="transform opacity-0 scale-95"
|
|
23
|
-
>
|
|
24
|
-
<slot></slot>
|
|
25
|
-
</Transition>
|
|
26
|
-
</div>
|
|
27
|
-
</template>
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<Transition
|
|
3
|
-
enter-active-class="transition ease-out duration-500"
|
|
4
|
-
enter-from-class="transform translate-x-full"
|
|
5
|
-
enter-to-class="transform translate-x-0"
|
|
6
|
-
leave-active-class="transition ease-in duration-500"
|
|
7
|
-
leave-from-class="transform translate-x-0"
|
|
8
|
-
leave-to-class="transform translate-x-full"
|
|
9
|
-
>
|
|
10
|
-
<slot></slot>
|
|
11
|
-
</Transition>
|
|
12
|
-
</template>
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<Transition
|
|
3
|
-
enter-active-class="transition ease-out duration-500"
|
|
4
|
-
enter-from-class="transform translate-y-full"
|
|
5
|
-
enter-to-class="transform translate-y-0"
|
|
6
|
-
leave-active-class="transition ease-in duration-500"
|
|
7
|
-
leave-from-class="transform translate-y-0"
|
|
8
|
-
leave-to-class="transform translate-y-full"
|
|
9
|
-
>
|
|
10
|
-
<slot></slot>
|
|
11
|
-
</Transition>
|
|
12
|
-
</template>
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<Transition
|
|
3
|
-
enter-active-class="transition ease-out duration-500"
|
|
4
|
-
enter-from-class="transform translate-x-[-100%]"
|
|
5
|
-
enter-to-class="transform translate-x-0"
|
|
6
|
-
leave-active-class="transition ease-in duration-500"
|
|
7
|
-
leave-from-class="transform translate-x-0"
|
|
8
|
-
leave-to-class="transform translate-x-[-100%]"
|
|
9
|
-
>
|
|
10
|
-
<slot></slot>
|
|
11
|
-
</Transition>
|
|
12
|
-
</template>
|
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
import { authClient } from '~/lib/auth-client';
|
|
2
|
-
import { arrayBufferToBase64Url, base64UrlToUint8Array } from '~/utils/crypto';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* User type for Better Auth session
|
|
6
|
-
*/
|
|
7
|
-
interface BetterAuthUser {
|
|
8
|
-
email: string;
|
|
9
|
-
emailVerified?: boolean;
|
|
10
|
-
id: string;
|
|
11
|
-
name?: string;
|
|
12
|
-
role?: string;
|
|
13
|
-
twoFactorEnabled?: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Stored auth state (persisted in cookie for SSR compatibility)
|
|
18
|
-
*/
|
|
19
|
-
interface StoredAuthState {
|
|
20
|
-
user: BetterAuthUser | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Result of passkey authentication
|
|
25
|
-
*/
|
|
26
|
-
interface PasskeyAuthResult {
|
|
27
|
-
error?: string;
|
|
28
|
-
success: boolean;
|
|
29
|
-
user?: BetterAuthUser;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Better Auth composable with client-side state management
|
|
34
|
-
*
|
|
35
|
-
* This composable manages auth state using:
|
|
36
|
-
* 1. Client-side state stored in a cookie (for SSR compatibility)
|
|
37
|
-
* 2. Better Auth's session endpoint as a validation check
|
|
38
|
-
*
|
|
39
|
-
* The state is populated after login and cleared on logout.
|
|
40
|
-
*/
|
|
41
|
-
export function useBetterAuth() {
|
|
42
|
-
// Use useCookie for SSR-compatible persistent state
|
|
43
|
-
const authState = useCookie<StoredAuthState>('auth-state', {
|
|
44
|
-
default: () => ({ user: null }),
|
|
45
|
-
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
46
|
-
sameSite: 'lax',
|
|
47
|
-
});
|
|
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
|
-
|
|
56
|
-
// Loading state
|
|
57
|
-
const isLoading = ref<boolean>(false);
|
|
58
|
-
|
|
59
|
-
// Computed properties based on stored state
|
|
60
|
-
const user = computed<BetterAuthUser | null>(() => authState.value?.user ?? null);
|
|
61
|
-
const isAuthenticated = computed<boolean>(() => !!user.value);
|
|
62
|
-
const isAdmin = computed<boolean>(() => user.value?.role === 'admin');
|
|
63
|
-
const is2FAEnabled = computed<boolean>(() => user.value?.twoFactorEnabled ?? false);
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Set user data after successful login/signup
|
|
67
|
-
*/
|
|
68
|
-
function setUser(userData: BetterAuthUser | null): void {
|
|
69
|
-
authState.value = { user: userData };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Clear user data on logout
|
|
74
|
-
*/
|
|
75
|
-
function clearUser(): void {
|
|
76
|
-
authState.value = { user: null };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Validate session with backend (called on app init)
|
|
81
|
-
* If session is invalid, clear the stored state
|
|
82
|
-
*/
|
|
83
|
-
async function validateSession(): Promise<boolean> {
|
|
84
|
-
try {
|
|
85
|
-
// Try to get session from Better Auth
|
|
86
|
-
const session = authClient.useSession();
|
|
87
|
-
|
|
88
|
-
// Wait for session to load
|
|
89
|
-
if (session.value.isPending) {
|
|
90
|
-
await new Promise((resolve) => {
|
|
91
|
-
const unwatch = watch(
|
|
92
|
-
() => session.value.isPending,
|
|
93
|
-
(isPending) => {
|
|
94
|
-
if (!isPending) {
|
|
95
|
-
unwatch();
|
|
96
|
-
resolve(true);
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
{ immediate: true },
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// If session has user data, update our state
|
|
105
|
-
if (session.value.data?.user) {
|
|
106
|
-
setUser(session.value.data.user as BetterAuthUser);
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Session not found - check if we have a stored token cookie
|
|
111
|
-
// If we have auth-state but no session, it might be a mismatch
|
|
112
|
-
// For now, trust the stored state if token cookie exists
|
|
113
|
-
const tokenCookie = useCookie('token');
|
|
114
|
-
if (tokenCookie.value && authState.value?.user) {
|
|
115
|
-
// We have both token and stored user - trust it
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// No valid session found - clear state
|
|
120
|
-
if (authState.value?.user) {
|
|
121
|
-
clearUser();
|
|
122
|
-
}
|
|
123
|
-
return false;
|
|
124
|
-
} catch (error) {
|
|
125
|
-
console.debug('Session validation failed:', error);
|
|
126
|
-
return !!authState.value?.user;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Sign in with email and password
|
|
132
|
-
*/
|
|
133
|
-
const signIn = {
|
|
134
|
-
...authClient.signIn,
|
|
135
|
-
email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
|
|
136
|
-
isLoading.value = true;
|
|
137
|
-
try {
|
|
138
|
-
const result = await authClient.signIn.email(params, options);
|
|
139
|
-
|
|
140
|
-
// Check for successful response with user data
|
|
141
|
-
if (result && 'user' in result && result.user) {
|
|
142
|
-
setUser(result.user as BetterAuthUser);
|
|
143
|
-
} else if (result && 'data' in result && result.data?.user) {
|
|
144
|
-
setUser(result.data.user as BetterAuthUser);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return result;
|
|
148
|
-
} finally {
|
|
149
|
-
isLoading.value = false;
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Sign up with email and password
|
|
156
|
-
*/
|
|
157
|
-
const signUp = {
|
|
158
|
-
...authClient.signUp,
|
|
159
|
-
email: async (params: { email: string; name: string; password: string }, options?: any) => {
|
|
160
|
-
isLoading.value = true;
|
|
161
|
-
try {
|
|
162
|
-
const result = await authClient.signUp.email(params, options);
|
|
163
|
-
|
|
164
|
-
// Check for successful response with user data
|
|
165
|
-
if (result && 'user' in result && result.user) {
|
|
166
|
-
setUser(result.user as BetterAuthUser);
|
|
167
|
-
} else if (result && 'data' in result && result.data?.user) {
|
|
168
|
-
setUser(result.data.user as BetterAuthUser);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return result;
|
|
172
|
-
} finally {
|
|
173
|
-
isLoading.value = false;
|
|
174
|
-
}
|
|
175
|
-
},
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Sign out
|
|
180
|
-
*/
|
|
181
|
-
const signOut = async (options?: any) => {
|
|
182
|
-
isLoading.value = true;
|
|
183
|
-
try {
|
|
184
|
-
const result = await authClient.signOut(options);
|
|
185
|
-
// Clear user data on logout
|
|
186
|
-
clearUser();
|
|
187
|
-
return result;
|
|
188
|
-
} finally {
|
|
189
|
-
isLoading.value = false;
|
|
190
|
-
}
|
|
191
|
-
};
|
|
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
|
-
// 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;
|
|
211
|
-
const runtimeConfig = useRuntimeConfig();
|
|
212
|
-
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
213
|
-
|
|
214
|
-
// Step 1: Get authentication options from server
|
|
215
|
-
const optionsResponse = await fetch(`${apiBase}/passkey/generate-authenticate-options`, {
|
|
216
|
-
method: 'GET',
|
|
217
|
-
credentials: 'include',
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
if (!optionsResponse.ok) {
|
|
221
|
-
return { success: false, error: 'Konnte Passkey-Optionen nicht laden' };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const options = await optionsResponse.json();
|
|
225
|
-
|
|
226
|
-
// Step 2: Convert challenge from base64url to ArrayBuffer
|
|
227
|
-
const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
|
|
228
|
-
|
|
229
|
-
// Step 3: Get credential from browser's WebAuthn API
|
|
230
|
-
const credential = (await navigator.credentials.get({
|
|
231
|
-
publicKey: {
|
|
232
|
-
challenge: challengeBuffer,
|
|
233
|
-
rpId: options.rpId,
|
|
234
|
-
allowCredentials: options.allowCredentials || [],
|
|
235
|
-
userVerification: options.userVerification,
|
|
236
|
-
timeout: options.timeout,
|
|
237
|
-
},
|
|
238
|
-
})) as PublicKeyCredential | null;
|
|
239
|
-
|
|
240
|
-
if (!credential) {
|
|
241
|
-
return { success: false, error: 'Kein Passkey ausgewählt' };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Step 4: Convert credential response to base64url format
|
|
245
|
-
const response = credential.response as AuthenticatorAssertionResponse;
|
|
246
|
-
const credentialBody = {
|
|
247
|
-
id: credential.id,
|
|
248
|
-
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
249
|
-
type: credential.type,
|
|
250
|
-
response: {
|
|
251
|
-
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
|
252
|
-
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
|
253
|
-
signature: arrayBufferToBase64Url(response.signature),
|
|
254
|
-
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
|
|
255
|
-
},
|
|
256
|
-
clientExtensionResults: credential.getClientExtensionResults?.() || {},
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
// Step 5: Verify with server
|
|
260
|
-
// Note: The server expects { response: credentialData } format (matching @simplewebauthn/browser output)
|
|
261
|
-
const authResponse = await fetch(`${apiBase}/passkey/verify-authentication`, {
|
|
262
|
-
method: 'POST',
|
|
263
|
-
credentials: 'include',
|
|
264
|
-
headers: { 'Content-Type': 'application/json' },
|
|
265
|
-
body: JSON.stringify({ response: credentialBody }),
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
const result = await authResponse.json();
|
|
269
|
-
|
|
270
|
-
if (!authResponse.ok) {
|
|
271
|
-
return { success: false, error: result.message || 'Passkey-Anmeldung fehlgeschlagen' };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Store user data after successful passkey login
|
|
275
|
-
if (result.user) {
|
|
276
|
-
setUser(result.user as BetterAuthUser);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return { success: true, user: result.user as BetterAuthUser };
|
|
280
|
-
} catch (err: unknown) {
|
|
281
|
-
// Handle WebAuthn-specific errors
|
|
282
|
-
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
283
|
-
return { success: false, error: 'Passkey-Authentifizierung wurde abgebrochen' };
|
|
284
|
-
}
|
|
285
|
-
return { success: false, error: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen' };
|
|
286
|
-
} finally {
|
|
287
|
-
isLoading.value = false;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Register a new passkey for the current user
|
|
293
|
-
*
|
|
294
|
-
* This function handles the complete WebAuthn registration flow:
|
|
295
|
-
* 1. Fetches registration options from the server
|
|
296
|
-
* 2. Prompts the user to create a passkey via the browser's WebAuthn API
|
|
297
|
-
* 3. Sends the credential to the server for storage
|
|
298
|
-
*
|
|
299
|
-
* @param name - Optional name for the passkey
|
|
300
|
-
* @returns Result with success status or error message
|
|
301
|
-
*/
|
|
302
|
-
async function registerPasskey(name?: string): Promise<{ success: boolean; error?: string; passkey?: any }> {
|
|
303
|
-
isLoading.value = true;
|
|
304
|
-
|
|
305
|
-
try {
|
|
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;
|
|
309
|
-
const runtimeConfig = useRuntimeConfig();
|
|
310
|
-
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
311
|
-
|
|
312
|
-
// Step 1: Get registration options from server
|
|
313
|
-
const optionsResponse = await fetch(`${apiBase}/passkey/generate-register-options`, {
|
|
314
|
-
method: 'GET',
|
|
315
|
-
credentials: 'include',
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
if (!optionsResponse.ok) {
|
|
319
|
-
const error = await optionsResponse.json().catch(() => ({}));
|
|
320
|
-
return { success: false, error: error.message || 'Konnte Registrierungsoptionen nicht laden' };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const options = await optionsResponse.json();
|
|
324
|
-
|
|
325
|
-
// Step 2: Convert challenge from base64url to ArrayBuffer
|
|
326
|
-
const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
|
|
327
|
-
|
|
328
|
-
// Step 3: Convert user.id from base64url to ArrayBuffer
|
|
329
|
-
const userIdBuffer = base64UrlToUint8Array(options.user.id).buffer as ArrayBuffer;
|
|
330
|
-
|
|
331
|
-
// Step 4: Create credential via browser's WebAuthn API
|
|
332
|
-
const credential = (await navigator.credentials.create({
|
|
333
|
-
publicKey: {
|
|
334
|
-
challenge: challengeBuffer,
|
|
335
|
-
rp: options.rp,
|
|
336
|
-
user: {
|
|
337
|
-
...options.user,
|
|
338
|
-
id: userIdBuffer,
|
|
339
|
-
},
|
|
340
|
-
pubKeyCredParams: options.pubKeyCredParams,
|
|
341
|
-
timeout: options.timeout,
|
|
342
|
-
attestation: options.attestation,
|
|
343
|
-
authenticatorSelection: options.authenticatorSelection,
|
|
344
|
-
excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
|
|
345
|
-
...cred,
|
|
346
|
-
id: base64UrlToUint8Array(cred.id).buffer as ArrayBuffer,
|
|
347
|
-
})),
|
|
348
|
-
},
|
|
349
|
-
})) as PublicKeyCredential | null;
|
|
350
|
-
|
|
351
|
-
if (!credential) {
|
|
352
|
-
return { success: false, error: 'Passkey-Erstellung abgebrochen' };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Step 5: Convert credential response to base64url format
|
|
356
|
-
const attestationResponse = credential.response as AuthenticatorAttestationResponse;
|
|
357
|
-
const credentialBody = {
|
|
358
|
-
name,
|
|
359
|
-
response: {
|
|
360
|
-
id: credential.id,
|
|
361
|
-
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
362
|
-
type: credential.type,
|
|
363
|
-
response: {
|
|
364
|
-
attestationObject: arrayBufferToBase64Url(attestationResponse.attestationObject),
|
|
365
|
-
clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON),
|
|
366
|
-
transports: attestationResponse.getTransports?.() || [],
|
|
367
|
-
},
|
|
368
|
-
clientExtensionResults: credential.getClientExtensionResults?.() || {},
|
|
369
|
-
},
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
// Step 6: Send to server for verification and storage
|
|
373
|
-
const registerResponse = await fetch(`${apiBase}/passkey/verify-registration`, {
|
|
374
|
-
method: 'POST',
|
|
375
|
-
credentials: 'include',
|
|
376
|
-
headers: { 'Content-Type': 'application/json' },
|
|
377
|
-
body: JSON.stringify(credentialBody),
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const result = await registerResponse.json();
|
|
381
|
-
|
|
382
|
-
if (!registerResponse.ok) {
|
|
383
|
-
return { success: false, error: result.message || 'Passkey-Registrierung fehlgeschlagen' };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return { success: true, passkey: result };
|
|
387
|
-
} catch (err: unknown) {
|
|
388
|
-
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
389
|
-
return { success: false, error: 'Passkey-Erstellung wurde abgebrochen' };
|
|
390
|
-
}
|
|
391
|
-
return { success: false, error: err instanceof Error ? err.message : 'Passkey-Registrierung fehlgeschlagen' };
|
|
392
|
-
} finally {
|
|
393
|
-
isLoading.value = false;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return {
|
|
398
|
-
authenticateWithPasskey,
|
|
399
|
-
changePassword: authClient.changePassword,
|
|
400
|
-
clearUser,
|
|
401
|
-
is2FAEnabled,
|
|
402
|
-
isAdmin,
|
|
403
|
-
isAuthenticated,
|
|
404
|
-
isLoading: computed(() => isLoading.value),
|
|
405
|
-
passkey: authClient.passkey,
|
|
406
|
-
registerPasskey,
|
|
407
|
-
setUser,
|
|
408
|
-
signIn,
|
|
409
|
-
signOut,
|
|
410
|
-
signUp,
|
|
411
|
-
twoFactor: authClient.twoFactor,
|
|
412
|
-
user,
|
|
413
|
-
validateSession,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
interface FileInfo {
|
|
2
|
-
[key: string]: unknown;
|
|
3
|
-
filename: string;
|
|
4
|
-
id: string;
|
|
5
|
-
mimetype: string;
|
|
6
|
-
size: number;
|
|
7
|
-
url?: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function useFile() {
|
|
11
|
-
const config = useRuntimeConfig();
|
|
12
|
-
|
|
13
|
-
function isValidMongoID(id: string): boolean {
|
|
14
|
-
return /^[a-f\d]{24}$/i.test(id);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function getFileInfo(id: string | undefined): Promise<FileInfo | null | string> {
|
|
18
|
-
if (!id) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (!isValidMongoID(id)) {
|
|
23
|
-
return id;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const response = await $fetch<FileInfo>(config.public.host + '/files/info/' + id, {
|
|
28
|
-
credentials: 'include',
|
|
29
|
-
method: 'GET',
|
|
30
|
-
});
|
|
31
|
-
return response;
|
|
32
|
-
} catch (error) {
|
|
33
|
-
console.error('Error fetching file info:', error);
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function getFileUrl(id: string): string {
|
|
39
|
-
return `${config.public.host}/files/${id}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function getDownloadUrl(id: string, filename?: string): string {
|
|
43
|
-
const base = `${config.public.host}/files/download/${id}`;
|
|
44
|
-
return filename ? `${base}?filename=${encodeURIComponent(filename)}` : base;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function formatFileSize(bytes: number): string {
|
|
48
|
-
if (bytes === 0) return '0 B';
|
|
49
|
-
const k = 1024;
|
|
50
|
-
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
51
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
52
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function formatDuration(seconds: number): string {
|
|
56
|
-
if (seconds < 60) return `${seconds}s`;
|
|
57
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
58
|
-
const hours = Math.floor(seconds / 3600);
|
|
59
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
60
|
-
return `${hours}h ${minutes}m`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
formatDuration,
|
|
65
|
-
formatFileSize,
|
|
66
|
-
getDownloadUrl,
|
|
67
|
-
getFileInfo,
|
|
68
|
-
getFileUrl,
|
|
69
|
-
isValidMongoID,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
export function useShare() {
|
|
2
|
-
const route = useRoute();
|
|
3
|
-
|
|
4
|
-
async function share(title?: string, text?: string, url?: string) {
|
|
5
|
-
if (!import.meta.client) {
|
|
6
|
-
return;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
if (window?.navigator?.share) {
|
|
10
|
-
try {
|
|
11
|
-
await window.navigator.share({
|
|
12
|
-
text: text ?? window.location.origin,
|
|
13
|
-
title: title,
|
|
14
|
-
url: url ?? route.fullPath,
|
|
15
|
-
});
|
|
16
|
-
} catch (error) {
|
|
17
|
-
console.error('Error sharing:', error);
|
|
18
|
-
}
|
|
19
|
-
} else {
|
|
20
|
-
// Fallback: Copy to clipboard
|
|
21
|
-
try {
|
|
22
|
-
await navigator.clipboard.writeText(url ?? window.location.origin);
|
|
23
|
-
const toast = useToast();
|
|
24
|
-
toast.add({
|
|
25
|
-
color: 'success',
|
|
26
|
-
description: 'Der Link wurde in die Zwischenablage kopiert.',
|
|
27
|
-
title: 'Link kopiert',
|
|
28
|
-
});
|
|
29
|
-
} catch (error) {
|
|
30
|
-
console.error('Error copying to clipboard:', error);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
share,
|
|
37
|
-
};
|
|
38
|
-
}
|