create-nuxt-base 0.3.17 → 1.0.3
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 +4 -2
- package/.oxfmtrc.jsonc +7 -0
- package/CHANGELOG.md +22 -8
- package/README.md +130 -3
- package/nuxt-base-template/.dockerignore +44 -0
- package/nuxt-base-template/.env.example +0 -2
- package/nuxt-base-template/.nuxtrc +1 -0
- package/nuxt-base-template/.oxfmtrc.jsonc +8 -0
- package/nuxt-base-template/Dockerfile.dev +23 -0
- package/nuxt-base-template/README.md +76 -29
- package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +117 -0
- package/nuxt-base-template/app/components/Upload/TusFileUpload.vue +302 -0
- package/nuxt-base-template/app/composables/use-better-auth.ts +25 -0
- package/nuxt-base-template/app/composables/use-file.ts +39 -4
- package/nuxt-base-template/app/composables/use-share.ts +1 -1
- package/nuxt-base-template/app/composables/use-tus-upload.ts +278 -0
- package/nuxt-base-template/app/interfaces/upload.interface.ts +58 -0
- package/nuxt-base-template/app/interfaces/user.interface.ts +12 -0
- package/nuxt-base-template/app/lib/auth-client.ts +135 -0
- package/nuxt-base-template/app/middleware/admin.global.ts +23 -0
- package/nuxt-base-template/app/middleware/auth.global.ts +18 -0
- package/nuxt-base-template/app/middleware/guest.global.ts +18 -0
- package/nuxt-base-template/app/pages/app/settings/security.vue +409 -0
- package/nuxt-base-template/app/pages/auth/2fa.vue +120 -0
- package/nuxt-base-template/app/pages/auth/forgot-password.vue +72 -21
- package/nuxt-base-template/app/pages/auth/login.vue +75 -11
- package/nuxt-base-template/app/pages/auth/register.vue +184 -0
- package/nuxt-base-template/app/pages/auth/reset-password.vue +153 -0
- package/nuxt-base-template/app/utils/crypto.ts +13 -0
- package/nuxt-base-template/docker-entrypoint.sh +21 -0
- package/nuxt-base-template/nuxt.config.ts +4 -1
- package/nuxt-base-template/oxlint.json +14 -0
- package/nuxt-base-template/package-lock.json +11582 -10675
- package/nuxt-base-template/package.json +35 -32
- package/nuxt-base-template/tests/iam.spec.ts +247 -0
- package/package.json +14 -11
- package/.eslintignore +0 -14
- package/.eslintrc +0 -3
- package/.prettierignore +0 -5
- package/.prettierrc +0 -6
- package/nuxt-base-template/CLAUDE.md +0 -361
- package/nuxt-base-template/app/pages/auth/reset-password/[token].vue +0 -110
- package/nuxt-base-template/app/public/favicon.ico +0 -0
- package/nuxt-base-template/eslint.config.mjs +0 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ComputedRef } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface UploadItem {
|
|
4
|
+
completedAt?: Date;
|
|
5
|
+
error?: string;
|
|
6
|
+
file: File;
|
|
7
|
+
id: string;
|
|
8
|
+
metadata?: Record<string, string>;
|
|
9
|
+
progress: UploadProgress;
|
|
10
|
+
startedAt?: Date;
|
|
11
|
+
status: UploadStatus;
|
|
12
|
+
url?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UploadOptions {
|
|
16
|
+
autoStart?: boolean;
|
|
17
|
+
chunkSize?: number;
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
metadata?: Record<string, string>;
|
|
21
|
+
onError?: (item: UploadItem, error: Error) => void;
|
|
22
|
+
onProgress?: (item: UploadItem) => void;
|
|
23
|
+
onSuccess?: (item: UploadItem) => void;
|
|
24
|
+
parallelUploads?: number;
|
|
25
|
+
retryDelays?: number[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UploadProgress {
|
|
29
|
+
bytesTotal: number;
|
|
30
|
+
bytesUploaded: number;
|
|
31
|
+
percentage: number;
|
|
32
|
+
remainingTime: number; // seconds
|
|
33
|
+
speed: number; // bytes/second
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type UploadStatus = 'completed' | 'error' | 'idle' | 'paused' | 'uploading';
|
|
37
|
+
|
|
38
|
+
export interface UseTusUploadReturn {
|
|
39
|
+
// Actions
|
|
40
|
+
addFiles: (files: File | File[]) => string[];
|
|
41
|
+
cancelAll: () => void;
|
|
42
|
+
cancelUpload: (id: string) => void;
|
|
43
|
+
clearCompleted: () => void;
|
|
44
|
+
getUpload: (id: string) => undefined | UploadItem;
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
isUploading: ComputedRef<boolean>;
|
|
48
|
+
pauseAll: () => void;
|
|
49
|
+
pauseUpload: (id: string) => void;
|
|
50
|
+
removeUpload: (id: string) => void;
|
|
51
|
+
resumeAll: () => void;
|
|
52
|
+
resumeUpload: (id: string) => void;
|
|
53
|
+
retryUpload: (id: string) => void;
|
|
54
|
+
startAll: () => void;
|
|
55
|
+
startUpload: (id: string) => void;
|
|
56
|
+
totalProgress: ComputedRef<UploadProgress>;
|
|
57
|
+
uploads: ComputedRef<UploadItem[]>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { passkeyClient } from '@better-auth/passkey/client';
|
|
2
|
+
import { adminClient, twoFactorClient } from 'better-auth/client/plugins';
|
|
3
|
+
import { createAuthClient } from 'better-auth/vue';
|
|
4
|
+
|
|
5
|
+
import { sha256 } from '~/utils/crypto';
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Type Definitions
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalized response type for Better-Auth operations
|
|
13
|
+
* The Vue client returns complex union types - this provides a consistent interface
|
|
14
|
+
*/
|
|
15
|
+
export interface AuthResponse {
|
|
16
|
+
data?: null | {
|
|
17
|
+
redirect?: boolean;
|
|
18
|
+
token?: null | string;
|
|
19
|
+
url?: string;
|
|
20
|
+
user?: {
|
|
21
|
+
createdAt?: Date;
|
|
22
|
+
email?: string;
|
|
23
|
+
emailVerified?: boolean;
|
|
24
|
+
id?: string;
|
|
25
|
+
image?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
updatedAt?: Date;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
error?: null | {
|
|
31
|
+
code?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
status?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Base Client Configuration
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
const baseClient = createAuthClient({
|
|
42
|
+
basePath: '/iam', // IMPORTANT: Must match nest-server betterAuth.basePath, default: '/iam'
|
|
43
|
+
baseURL: import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000',
|
|
44
|
+
plugins: [
|
|
45
|
+
adminClient(),
|
|
46
|
+
twoFactorClient({
|
|
47
|
+
onTwoFactorRedirect() {
|
|
48
|
+
navigateTo('/auth/2fa');
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
passkeyClient(),
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Auth Client with Password Hashing
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extended auth client that hashes passwords before transmission.
|
|
61
|
+
*
|
|
62
|
+
* SECURITY: Passwords are hashed with SHA256 client-side to prevent
|
|
63
|
+
* plain text password transmission over the network.
|
|
64
|
+
*
|
|
65
|
+
* The server's normalizePasswordForIam() detects SHA256 hashes (64 hex chars)
|
|
66
|
+
* and processes them correctly.
|
|
67
|
+
*/
|
|
68
|
+
export const authClient = {
|
|
69
|
+
// Spread all base client properties and methods
|
|
70
|
+
...baseClient,
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Change password for an authenticated user (both passwords are hashed)
|
|
74
|
+
*/
|
|
75
|
+
changePassword: async (params: { currentPassword: string; newPassword: string }, options?: any) => {
|
|
76
|
+
const [hashedCurrent, hashedNew] = await Promise.all([sha256(params.currentPassword), sha256(params.newPassword)]);
|
|
77
|
+
return baseClient.changePassword?.({ currentPassword: hashedCurrent, newPassword: hashedNew }, options);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reset password with token (new password is hashed before sending)
|
|
82
|
+
*/
|
|
83
|
+
resetPassword: async (params: { newPassword: string; token: string }, options?: any) => {
|
|
84
|
+
const hashedPassword = await sha256(params.newPassword);
|
|
85
|
+
return baseClient.resetPassword?.({ newPassword: hashedPassword, token: params.token }, options);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Override signIn to hash password
|
|
89
|
+
signIn: {
|
|
90
|
+
...baseClient.signIn,
|
|
91
|
+
/**
|
|
92
|
+
* Sign in with email and password (password is hashed before sending)
|
|
93
|
+
*/
|
|
94
|
+
email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
|
|
95
|
+
const hashedPassword = await sha256(params.password);
|
|
96
|
+
return baseClient.signIn.email({ ...params, password: hashedPassword }, options);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Explicitly pass through signOut (not captured by spread operator)
|
|
101
|
+
signOut: baseClient.signOut,
|
|
102
|
+
|
|
103
|
+
// Override signUp to hash password
|
|
104
|
+
signUp: {
|
|
105
|
+
...baseClient.signUp,
|
|
106
|
+
/**
|
|
107
|
+
* Sign up with email and password (password is hashed before sending)
|
|
108
|
+
*/
|
|
109
|
+
email: async (params: { email: string; name: string; password: string }, options?: any) => {
|
|
110
|
+
const hashedPassword = await sha256(params.password);
|
|
111
|
+
return baseClient.signUp.email({ ...params, password: hashedPassword }, options);
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Override twoFactor to hash passwords
|
|
116
|
+
twoFactor: {
|
|
117
|
+
...baseClient.twoFactor,
|
|
118
|
+
/**
|
|
119
|
+
* Disable 2FA (password is hashed before sending)
|
|
120
|
+
*/
|
|
121
|
+
disable: async (params: { password: string }, options?: any) => {
|
|
122
|
+
const hashedPassword = await sha256(params.password);
|
|
123
|
+
return baseClient.twoFactor.disable({ password: hashedPassword }, options);
|
|
124
|
+
},
|
|
125
|
+
/**
|
|
126
|
+
* Enable 2FA (password is hashed before sending)
|
|
127
|
+
*/
|
|
128
|
+
enable: async (params: { password: string }, options?: any) => {
|
|
129
|
+
const hashedPassword = await sha256(params.password);
|
|
130
|
+
return baseClient.twoFactor.enable({ password: hashedPassword }, options);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type AuthClient = typeof authClient;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
2
|
+
// Only check routes starting with /app/admin
|
|
3
|
+
if (!to.path.startsWith('/app/admin')) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { isAdmin, isAuthenticated, isLoading } = useBetterAuth();
|
|
8
|
+
|
|
9
|
+
// Wait for session to load
|
|
10
|
+
if (isLoading.value) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Redirect to login if not authenticated
|
|
15
|
+
if (!isAuthenticated.value) {
|
|
16
|
+
return navigateTo('/auth/login');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Redirect to /app if authenticated but not admin
|
|
20
|
+
if (!isAdmin.value) {
|
|
21
|
+
return navigateTo('/app');
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
2
|
+
// Only check routes starting with /app (but not /app/admin, handled by admin middleware)
|
|
3
|
+
if (!to.path.startsWith('/app') || to.path.startsWith('/app/admin')) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { isAuthenticated, isLoading } = useBetterAuth();
|
|
8
|
+
|
|
9
|
+
// Wait for session to load
|
|
10
|
+
if (isLoading.value) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Redirect to login if not authenticated
|
|
15
|
+
if (!isAuthenticated.value) {
|
|
16
|
+
return navigateTo('/auth/login');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
2
|
+
// Only check /auth/login route
|
|
3
|
+
if (to.path !== '/auth/login') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { isAuthenticated, isLoading } = useBetterAuth();
|
|
8
|
+
|
|
9
|
+
// Wait for session to load
|
|
10
|
+
if (isLoading.value) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Redirect to /app if already authenticated
|
|
15
|
+
if (isAuthenticated.value) {
|
|
16
|
+
return navigateTo('/app');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Imports
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import type { FormSubmitEvent } from '@nuxt/ui';
|
|
6
|
+
import type { InferOutput } from 'valibot';
|
|
7
|
+
|
|
8
|
+
import * as v from 'valibot';
|
|
9
|
+
|
|
10
|
+
import ModalBackupCodes from '~/components/Modal/ModalBackupCodes.vue';
|
|
11
|
+
import { authClient } from '~/lib/auth-client';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Interfaces
|
|
15
|
+
// ============================================================================
|
|
16
|
+
interface Passkey {
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
id: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Composables
|
|
24
|
+
// ============================================================================
|
|
25
|
+
const toast = useToast();
|
|
26
|
+
const overlay = useOverlay();
|
|
27
|
+
const { is2FAEnabled, user } = useBetterAuth();
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Variables
|
|
31
|
+
// ============================================================================
|
|
32
|
+
const loading = ref<boolean>(false);
|
|
33
|
+
const totpUri = ref<string>('');
|
|
34
|
+
const backupCodes = ref<string[]>([]);
|
|
35
|
+
const showTotpSetup = ref<boolean>(false);
|
|
36
|
+
const show2FADisable = ref<boolean>(false);
|
|
37
|
+
const passkeys = ref<Passkey[]>([]);
|
|
38
|
+
const passkeyLoading = ref<boolean>(false);
|
|
39
|
+
const newPasskeyName = ref<string>('');
|
|
40
|
+
const showAddPasskey = ref<boolean>(false);
|
|
41
|
+
|
|
42
|
+
const passwordSchema = v.object({
|
|
43
|
+
password: v.pipe(v.string('Passwort ist erforderlich'), v.minLength(1, 'Passwort ist erforderlich')),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const totpSchema = v.object({
|
|
47
|
+
code: v.pipe(v.string('Code ist erforderlich'), v.length(6, 'Code muss 6 Ziffern haben')),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type PasswordSchema = InferOutput<typeof passwordSchema>;
|
|
51
|
+
type TotpSchema = InferOutput<typeof totpSchema>;
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Lifecycle Hooks
|
|
55
|
+
// ============================================================================
|
|
56
|
+
onMounted(async () => {
|
|
57
|
+
await loadPasskeys();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function addPasskey(): Promise<void> {
|
|
61
|
+
if (!newPasskeyName.value.trim()) {
|
|
62
|
+
toast.add({
|
|
63
|
+
color: 'error',
|
|
64
|
+
description: 'Bitte gib einen Namen für den Passkey ein',
|
|
65
|
+
title: 'Fehler',
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
passkeyLoading.value = true;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const { error } = await authClient.passkey.addPasskey({
|
|
74
|
+
name: newPasskeyName.value,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (error) {
|
|
78
|
+
toast.add({
|
|
79
|
+
color: 'error',
|
|
80
|
+
description: error.message || 'Passkey konnte nicht hinzugefügt werden',
|
|
81
|
+
title: 'Fehler',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
toast.add({
|
|
87
|
+
color: 'success',
|
|
88
|
+
description: 'Passkey wurde erfolgreich hinzugefügt',
|
|
89
|
+
title: 'Erfolg',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
newPasskeyName.value = '';
|
|
93
|
+
showAddPasskey.value = false;
|
|
94
|
+
await loadPasskeys();
|
|
95
|
+
} finally {
|
|
96
|
+
passkeyLoading.value = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function deletePasskey(id: string): Promise<void> {
|
|
101
|
+
passkeyLoading.value = true;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const { error } = await authClient.passkey.deletePasskey({
|
|
105
|
+
id,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (error) {
|
|
109
|
+
toast.add({
|
|
110
|
+
color: 'error',
|
|
111
|
+
description: error.message || 'Passkey konnte nicht gelöscht werden',
|
|
112
|
+
title: 'Fehler',
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
toast.add({
|
|
118
|
+
color: 'success',
|
|
119
|
+
description: 'Passkey wurde gelöscht',
|
|
120
|
+
title: 'Erfolg',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await loadPasskeys();
|
|
124
|
+
} finally {
|
|
125
|
+
passkeyLoading.value = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<void> {
|
|
130
|
+
loading.value = true;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const { error } = await authClient.twoFactor.disable({
|
|
134
|
+
password: payload.data.password,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (error) {
|
|
138
|
+
toast.add({
|
|
139
|
+
color: 'error',
|
|
140
|
+
description: error.message || '2FA konnte nicht deaktiviert werden',
|
|
141
|
+
title: 'Fehler',
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
toast.add({
|
|
147
|
+
color: 'success',
|
|
148
|
+
description: '2FA wurde deaktiviert',
|
|
149
|
+
title: 'Erfolg',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
show2FADisable.value = false;
|
|
153
|
+
} finally {
|
|
154
|
+
loading.value = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function enable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<void> {
|
|
159
|
+
loading.value = true;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const { data, error } = await authClient.twoFactor.enable({
|
|
163
|
+
password: payload.data.password,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (error) {
|
|
167
|
+
toast.add({
|
|
168
|
+
color: 'error',
|
|
169
|
+
description: error.message || '2FA konnte nicht aktiviert werden',
|
|
170
|
+
title: 'Fehler',
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
totpUri.value = data?.totpURI ?? '';
|
|
176
|
+
backupCodes.value = data?.backupCodes ?? [];
|
|
177
|
+
showTotpSetup.value = true;
|
|
178
|
+
} finally {
|
|
179
|
+
loading.value = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Functions
|
|
185
|
+
// ============================================================================
|
|
186
|
+
async function loadPasskeys(): Promise<void> {
|
|
187
|
+
try {
|
|
188
|
+
const { data, error } = await authClient.passkey.listUserPasskeys();
|
|
189
|
+
|
|
190
|
+
if (error) {
|
|
191
|
+
console.error('Failed to load passkeys:', error);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
passkeys.value = (data ?? []) as Passkey[];
|
|
196
|
+
} catch {
|
|
197
|
+
console.error('Failed to load passkeys');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function openBackupCodesModal(codes: string[] = []): Promise<void> {
|
|
202
|
+
const modal = overlay.create(ModalBackupCodes, {
|
|
203
|
+
props: {
|
|
204
|
+
initialCodes: codes,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
await modal.open();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
211
|
+
loading.value = true;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const { error } = await authClient.twoFactor.verifyTotp({
|
|
215
|
+
code: payload.data.code,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (error) {
|
|
219
|
+
toast.add({
|
|
220
|
+
color: 'error',
|
|
221
|
+
description: error.message || 'Code ungültig',
|
|
222
|
+
title: 'Fehler',
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
toast.add({
|
|
228
|
+
color: 'success',
|
|
229
|
+
description: '2FA wurde erfolgreich aktiviert',
|
|
230
|
+
title: 'Erfolg',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
showTotpSetup.value = false;
|
|
234
|
+
await openBackupCodesModal(backupCodes.value);
|
|
235
|
+
} finally {
|
|
236
|
+
loading.value = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
</script>
|
|
240
|
+
|
|
241
|
+
<template>
|
|
242
|
+
<div class="mx-auto max-w-2xl space-y-8">
|
|
243
|
+
<div>
|
|
244
|
+
<h1 class="text-2xl font-bold">Sicherheit</h1>
|
|
245
|
+
<p class="text-muted">Verwalte deine Sicherheitseinstellungen</p>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- 2FA Section -->
|
|
249
|
+
<UCard>
|
|
250
|
+
<template #header>
|
|
251
|
+
<div class="flex items-center gap-3">
|
|
252
|
+
<UIcon name="i-lucide-shield-check" class="size-6 text-primary" />
|
|
253
|
+
<div>
|
|
254
|
+
<h2 class="font-semibold">Zwei-Faktor-Authentifizierung</h2>
|
|
255
|
+
<p class="text-sm text-muted">Zusätzliche Sicherheit für dein Konto</p>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</template>
|
|
259
|
+
|
|
260
|
+
<div class="space-y-4">
|
|
261
|
+
<div class="flex items-center justify-between">
|
|
262
|
+
<div>
|
|
263
|
+
<p class="font-medium">Status</p>
|
|
264
|
+
<p class="text-sm text-muted">
|
|
265
|
+
{{ is2FAEnabled ? '2FA ist aktiviert' : '2FA ist deaktiviert' }}
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
<UBadge :color="is2FAEnabled ? 'success' : 'neutral'">
|
|
269
|
+
{{ is2FAEnabled ? 'Aktiv' : 'Inaktiv' }}
|
|
270
|
+
</UBadge>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<template v-if="!is2FAEnabled && !showTotpSetup">
|
|
274
|
+
<UForm :schema="passwordSchema" class="space-y-4" @submit="enable2FA">
|
|
275
|
+
<UFormField label="Passwort bestätigen" name="password">
|
|
276
|
+
<UInput name="password" type="password" placeholder="Dein Passwort" />
|
|
277
|
+
</UFormField>
|
|
278
|
+
<UButton type="submit" :loading="loading"> 2FA aktivieren </UButton>
|
|
279
|
+
</UForm>
|
|
280
|
+
</template>
|
|
281
|
+
|
|
282
|
+
<template v-if="showTotpSetup">
|
|
283
|
+
<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>
|
|
285
|
+
|
|
286
|
+
<div class="flex justify-center">
|
|
287
|
+
<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
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<UForm :schema="totpSchema" class="space-y-4" @submit="verifyTotp">
|
|
291
|
+
<UFormField label="Verifizierungscode" name="code">
|
|
292
|
+
<UInput name="code" placeholder="000000" class="text-center font-mono" />
|
|
293
|
+
</UFormField>
|
|
294
|
+
<div class="flex gap-2">
|
|
295
|
+
<UButton type="submit" :loading="loading"> Verifizieren </UButton>
|
|
296
|
+
<UButton variant="outline" color="neutral" @click="showTotpSetup = false"> Abbrechen </UButton>
|
|
297
|
+
</div>
|
|
298
|
+
</UForm>
|
|
299
|
+
</div>
|
|
300
|
+
</template>
|
|
301
|
+
|
|
302
|
+
<template v-if="is2FAEnabled && !show2FADisable">
|
|
303
|
+
<div class="flex gap-2">
|
|
304
|
+
<UButton variant="outline" @click="show2FADisable = true"> 2FA deaktivieren </UButton>
|
|
305
|
+
<UButton variant="outline" color="neutral" @click="openBackupCodesModal()"> Backup-Codes anzeigen </UButton>
|
|
306
|
+
</div>
|
|
307
|
+
</template>
|
|
308
|
+
|
|
309
|
+
<template v-if="show2FADisable">
|
|
310
|
+
<UForm :schema="passwordSchema" class="space-y-4" @submit="disable2FA">
|
|
311
|
+
<UAlert color="warning" icon="i-lucide-alert-triangle"> 2FA zu deaktivieren verringert die Sicherheit deines Kontos. </UAlert>
|
|
312
|
+
<UFormField label="Passwort bestätigen" name="password">
|
|
313
|
+
<UInput name="password" type="password" placeholder="Dein Passwort" />
|
|
314
|
+
</UFormField>
|
|
315
|
+
<div class="flex gap-2">
|
|
316
|
+
<UButton type="submit" color="error" :loading="loading"> 2FA deaktivieren </UButton>
|
|
317
|
+
<UButton variant="outline" color="neutral" @click="show2FADisable = false"> Abbrechen </UButton>
|
|
318
|
+
</div>
|
|
319
|
+
</UForm>
|
|
320
|
+
</template>
|
|
321
|
+
</div>
|
|
322
|
+
</UCard>
|
|
323
|
+
|
|
324
|
+
<!-- Passkeys Section -->
|
|
325
|
+
<UCard>
|
|
326
|
+
<template #header>
|
|
327
|
+
<div class="flex items-center gap-3">
|
|
328
|
+
<UIcon name="i-lucide-fingerprint" class="size-6 text-primary" />
|
|
329
|
+
<div>
|
|
330
|
+
<h2 class="font-semibold">Passkeys</h2>
|
|
331
|
+
<p class="text-sm text-muted">Passwordlose Anmeldung mit biometrischen Daten</p>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</template>
|
|
335
|
+
|
|
336
|
+
<div class="space-y-4">
|
|
337
|
+
<template v-if="passkeys.length > 0">
|
|
338
|
+
<div class="divide-y">
|
|
339
|
+
<div v-for="passkey in passkeys" :key="passkey.id" class="flex items-center justify-between py-3">
|
|
340
|
+
<div class="flex items-center gap-3">
|
|
341
|
+
<UIcon name="i-lucide-key" class="size-5 text-muted" />
|
|
342
|
+
<div>
|
|
343
|
+
<p class="font-medium">{{ passkey.name || 'Unbenannter Passkey' }}</p>
|
|
344
|
+
<p class="text-xs text-muted">Erstellt am {{ new Date(passkey.createdAt).toLocaleDateString('de-DE') }}</p>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<UButton variant="ghost" color="error" icon="i-lucide-trash-2" size="sm" :loading="passkeyLoading" @click="deletePasskey(passkey.id)" />
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</template>
|
|
351
|
+
|
|
352
|
+
<template v-else>
|
|
353
|
+
<p class="text-center text-muted py-4">Du hast noch keine Passkeys eingerichtet.</p>
|
|
354
|
+
</template>
|
|
355
|
+
|
|
356
|
+
<template v-if="!showAddPasskey">
|
|
357
|
+
<UButton variant="outline" icon="i-lucide-plus" @click="showAddPasskey = true"> Passkey hinzufügen </UButton>
|
|
358
|
+
</template>
|
|
359
|
+
|
|
360
|
+
<template v-else>
|
|
361
|
+
<div class="flex gap-2">
|
|
362
|
+
<UInput v-model="newPasskeyName" placeholder="Name für den Passkey" class="flex-1" />
|
|
363
|
+
<UButton :loading="passkeyLoading" @click="addPasskey"> Hinzufügen </UButton>
|
|
364
|
+
<UButton
|
|
365
|
+
variant="outline"
|
|
366
|
+
color="neutral"
|
|
367
|
+
@click="
|
|
368
|
+
showAddPasskey = false;
|
|
369
|
+
newPasskeyName = '';
|
|
370
|
+
"
|
|
371
|
+
>
|
|
372
|
+
Abbrechen
|
|
373
|
+
</UButton>
|
|
374
|
+
</div>
|
|
375
|
+
</template>
|
|
376
|
+
</div>
|
|
377
|
+
</UCard>
|
|
378
|
+
|
|
379
|
+
<!-- Account Info -->
|
|
380
|
+
<UCard>
|
|
381
|
+
<template #header>
|
|
382
|
+
<div class="flex items-center gap-3">
|
|
383
|
+
<UIcon name="i-lucide-user" class="size-6 text-primary" />
|
|
384
|
+
<div>
|
|
385
|
+
<h2 class="font-semibold">Kontoinformationen</h2>
|
|
386
|
+
<p class="text-sm text-muted">Deine Kontodaten</p>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</template>
|
|
390
|
+
|
|
391
|
+
<div class="space-y-3">
|
|
392
|
+
<div class="flex justify-between">
|
|
393
|
+
<span class="text-muted">E-Mail</span>
|
|
394
|
+
<span class="font-medium">{{ user?.email }}</span>
|
|
395
|
+
</div>
|
|
396
|
+
<div class="flex justify-between">
|
|
397
|
+
<span class="text-muted">Name</span>
|
|
398
|
+
<span class="font-medium">{{ user?.name || '-' }}</span>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="flex justify-between">
|
|
401
|
+
<span class="text-muted">E-Mail verifiziert</span>
|
|
402
|
+
<UBadge :color="user?.emailVerified ? 'success' : 'warning'">
|
|
403
|
+
{{ user?.emailVerified ? 'Ja' : 'Nein' }}
|
|
404
|
+
</UBadge>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</UCard>
|
|
408
|
+
</div>
|
|
409
|
+
</template>
|