create-nuxt-base 0.3.16 → 1.0.2

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.
Files changed (64) hide show
  1. package/.github/workflows/publish.yml +4 -2
  2. package/.oxfmtrc.jsonc +7 -0
  3. package/CHANGELOG.md +22 -8
  4. package/nuxt-base-template/.dockerignore +44 -0
  5. package/nuxt-base-template/.env.example +1 -1
  6. package/nuxt-base-template/.nuxtrc +1 -0
  7. package/nuxt-base-template/.oxfmtrc.jsonc +8 -0
  8. package/nuxt-base-template/Dockerfile.dev +23 -0
  9. package/nuxt-base-template/README.md +127 -13
  10. package/nuxt-base-template/app/app.config.ts +67 -0
  11. package/nuxt-base-template/app/app.vue +10 -2
  12. package/nuxt-base-template/app/assets/css/tailwind.css +124 -84
  13. package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +117 -0
  14. package/nuxt-base-template/app/components/Modal/ModalBase.vue +65 -0
  15. package/nuxt-base-template/app/components/Transition/TransitionSlide.vue +0 -2
  16. package/nuxt-base-template/app/components/Transition/TransitionSlideBottom.vue +0 -2
  17. package/nuxt-base-template/app/components/Transition/TransitionSlideRevert.vue +0 -2
  18. package/nuxt-base-template/app/components/Upload/TusFileUpload.vue +302 -0
  19. package/nuxt-base-template/app/composables/use-better-auth.ts +25 -0
  20. package/nuxt-base-template/app/composables/use-file.ts +57 -6
  21. package/nuxt-base-template/app/composables/use-share.ts +26 -10
  22. package/nuxt-base-template/app/composables/use-tus-upload.ts +278 -0
  23. package/nuxt-base-template/app/error.vue +7 -43
  24. package/nuxt-base-template/app/interfaces/upload.interface.ts +58 -0
  25. package/nuxt-base-template/app/interfaces/user.interface.ts +12 -0
  26. package/nuxt-base-template/app/layouts/default.vue +76 -4
  27. package/nuxt-base-template/app/layouts/slim.vue +5 -0
  28. package/nuxt-base-template/app/lib/auth-client.ts +135 -0
  29. package/nuxt-base-template/app/middleware/admin.global.ts +20 -6
  30. package/nuxt-base-template/app/middleware/auth.global.ts +15 -6
  31. package/nuxt-base-template/app/middleware/guest.global.ts +18 -0
  32. package/nuxt-base-template/app/pages/app/settings/security.vue +409 -0
  33. package/nuxt-base-template/app/pages/auth/2fa.vue +120 -0
  34. package/nuxt-base-template/app/pages/auth/forgot-password.vue +115 -0
  35. package/nuxt-base-template/app/pages/auth/login.vue +135 -0
  36. package/nuxt-base-template/app/pages/auth/register.vue +184 -0
  37. package/nuxt-base-template/app/pages/auth/reset-password.vue +153 -0
  38. package/nuxt-base-template/app/pages/index.vue +139 -2
  39. package/nuxt-base-template/app/utils/crypto.ts +13 -0
  40. package/nuxt-base-template/docker-entrypoint.sh +21 -0
  41. package/nuxt-base-template/docs/nuxt.config.ts +4 -0
  42. package/nuxt-base-template/docs/pages/docs.vue +663 -0
  43. package/nuxt-base-template/nuxt.config.ts +75 -30
  44. package/nuxt-base-template/openapi-ts.config.ts +18 -0
  45. package/nuxt-base-template/oxlint.json +14 -0
  46. package/nuxt-base-template/package-lock.json +11414 -15883
  47. package/nuxt-base-template/package.json +48 -50
  48. package/nuxt-base-template/tests/iam.spec.ts +247 -0
  49. package/nuxt-base-template/tsconfig.json +1 -1
  50. package/package.json +15 -12
  51. package/.eslintignore +0 -14
  52. package/.eslintrc +0 -3
  53. package/.prettierignore +0 -5
  54. package/.prettierrc +0 -6
  55. package/nuxt-base-template/app/composables/use-context-menu.ts +0 -19
  56. package/nuxt-base-template/app/composables/use-form-helper.ts +0 -41
  57. package/nuxt-base-template/app/composables/use-modal.ts +0 -84
  58. package/nuxt-base-template/app/composables/use-notification.ts +0 -29
  59. package/nuxt-base-template/app/middleware/logged-in.global.ts +0 -9
  60. package/nuxt-base-template/app/plugins/auth.server.ts +0 -72
  61. package/nuxt-base-template/app/plugins/form.plugin.ts +0 -21
  62. package/nuxt-base-template/app/plugins/pwa.plugin.ts +0 -114
  63. package/nuxt-base-template/eslint.config.mjs +0 -3
  64. package/nuxt-base-template/tailwind.config.js +0 -21
@@ -0,0 +1,278 @@
1
+ import * as tus from 'tus-js-client';
2
+
3
+ import type { UploadItem, UploadOptions, UploadProgress, UseTusUploadReturn } from '~/interfaces/upload.interface';
4
+
5
+ export function useTusUpload(defaultOptions: UploadOptions = {}): UseTusUploadReturn {
6
+ const config = useRuntimeConfig();
7
+
8
+ // State
9
+ const uploadItems = ref<Map<string, UploadItem>>(new Map());
10
+ const tusUploads = ref<Map<string, tus.Upload>>(new Map());
11
+
12
+ // Default config
13
+ const defaultConfig: UploadOptions = {
14
+ autoStart: true,
15
+ chunkSize: 5 * 1024 * 1024, // 5MB chunks
16
+ endpoint: `${config.public.host}/files/upload`,
17
+ parallelUploads: 3,
18
+ retryDelays: [0, 1000, 3000, 5000, 10000],
19
+ ...defaultOptions,
20
+ };
21
+
22
+ // Computed
23
+ const uploads = computed(() => Array.from(uploadItems.value.values()));
24
+ const isUploading = computed(() => uploads.value.some((u) => u.status === 'uploading'));
25
+ const totalProgress = computed<UploadProgress>(() => {
26
+ const items = uploads.value;
27
+ if (items.length === 0) {
28
+ return { bytesTotal: 0, bytesUploaded: 0, percentage: 0, remainingTime: 0, speed: 0 };
29
+ }
30
+
31
+ const bytesUploaded = items.reduce((acc, i) => acc + i.progress.bytesUploaded, 0);
32
+ const bytesTotal = items.reduce((acc, i) => acc + i.progress.bytesTotal, 0);
33
+ const speed = items.reduce((acc, i) => acc + i.progress.speed, 0);
34
+
35
+ return {
36
+ bytesTotal,
37
+ bytesUploaded,
38
+ percentage: bytesTotal > 0 ? Math.round((bytesUploaded / bytesTotal) * 100) : 0,
39
+ remainingTime: speed > 0 ? Math.ceil((bytesTotal - bytesUploaded) / speed) : 0,
40
+ speed,
41
+ };
42
+ });
43
+
44
+ // Helper: Generate unique ID
45
+ function generateId(): string {
46
+ return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
47
+ }
48
+
49
+ // Helper: Calculate speed with smoothing
50
+ function createSpeedTracker() {
51
+ let lastBytes = 0;
52
+ let lastTime = Date.now();
53
+ let smoothedSpeed = 0;
54
+
55
+ return (bytesUploaded: number): number => {
56
+ const now = Date.now();
57
+ const timeDiff = (now - lastTime) / 1000;
58
+ const bytesDiff = bytesUploaded - lastBytes;
59
+
60
+ if (timeDiff > 0) {
61
+ const currentSpeed = bytesDiff / timeDiff;
62
+ // Exponential moving average for smoother display
63
+ smoothedSpeed = smoothedSpeed === 0 ? currentSpeed : smoothedSpeed * 0.7 + currentSpeed * 0.3;
64
+ }
65
+
66
+ lastBytes = bytesUploaded;
67
+ lastTime = now;
68
+
69
+ return Math.round(smoothedSpeed);
70
+ };
71
+ }
72
+
73
+ // Update item in map (triggers reactivity)
74
+ function updateItem(id: string, updates: Partial<UploadItem>): void {
75
+ const item = uploadItems.value.get(id);
76
+ if (item) {
77
+ const newMap = new Map(uploadItems.value);
78
+ newMap.set(id, { ...item, ...updates });
79
+ uploadItems.value = newMap;
80
+ }
81
+ }
82
+
83
+ // Create TUS upload instance
84
+ function createTusUpload(item: UploadItem, options: UploadOptions): tus.Upload {
85
+ const speedTracker = createSpeedTracker();
86
+
87
+ return new tus.Upload(item.file, {
88
+ chunkSize: options.chunkSize || defaultConfig.chunkSize,
89
+ endpoint: options.endpoint || defaultConfig.endpoint,
90
+ headers: options.headers,
91
+ metadata: {
92
+ filename: item.file.name,
93
+ filetype: item.file.type,
94
+ ...options.metadata,
95
+ ...item.metadata,
96
+ },
97
+ onBeforeRequest: (req) => {
98
+ const xhr = req.getUnderlyingObject() as XMLHttpRequest;
99
+ xhr.withCredentials = true;
100
+ },
101
+ onError: (error) => {
102
+ updateItem(item.id, {
103
+ error: error.message,
104
+ status: 'error',
105
+ });
106
+ options.onError?.(uploadItems.value.get(item.id)!, error);
107
+ },
108
+
109
+ onProgress: (bytesUploaded, bytesTotal) => {
110
+ const speed = speedTracker(bytesUploaded);
111
+ const percentage = Math.round((bytesUploaded / bytesTotal) * 100);
112
+ const remainingTime = speed > 0 ? Math.ceil((bytesTotal - bytesUploaded) / speed) : 0;
113
+
114
+ updateItem(item.id, {
115
+ progress: { bytesTotal, bytesUploaded, percentage, remainingTime, speed },
116
+ });
117
+
118
+ options.onProgress?.(uploadItems.value.get(item.id)!);
119
+ },
120
+
121
+ onShouldRetry: (err) => {
122
+ const status = (err as { originalResponse?: { getStatus?: () => number } }).originalResponse?.getStatus?.();
123
+ // Don't retry on 4xx errors (except 429 Too Many Requests)
124
+ if (status && status >= 400 && status < 500 && status !== 429) {
125
+ return false;
126
+ }
127
+ return true;
128
+ },
129
+
130
+ onSuccess: () => {
131
+ const tusUpload = tusUploads.value.get(item.id);
132
+ const currentItem = uploadItems.value.get(item.id);
133
+ updateItem(item.id, {
134
+ completedAt: new Date(),
135
+ progress: { ...currentItem!.progress, percentage: 100 },
136
+ status: 'completed',
137
+ url: tusUpload?.url ?? undefined,
138
+ });
139
+ options.onSuccess?.(uploadItems.value.get(item.id)!);
140
+ },
141
+
142
+ retryDelays: options.retryDelays || defaultConfig.retryDelays,
143
+ });
144
+ }
145
+
146
+ // Actions
147
+ function addFiles(files: File | File[]): string[] {
148
+ const fileArray = Array.isArray(files) ? files : [files];
149
+ const ids: string[] = [];
150
+
151
+ for (const file of fileArray) {
152
+ const id = generateId();
153
+ const item: UploadItem = {
154
+ file,
155
+ id,
156
+ metadata: defaultConfig.metadata,
157
+ progress: { bytesTotal: file.size, bytesUploaded: 0, percentage: 0, remainingTime: 0, speed: 0 },
158
+ status: 'idle',
159
+ };
160
+
161
+ const newMap = new Map(uploadItems.value);
162
+ newMap.set(id, item);
163
+ uploadItems.value = newMap;
164
+
165
+ const tusUpload = createTusUpload(item, defaultConfig);
166
+ tusUploads.value.set(id, tusUpload);
167
+
168
+ ids.push(id);
169
+ }
170
+
171
+ if (defaultConfig.autoStart) {
172
+ startAll();
173
+ }
174
+
175
+ return ids;
176
+ }
177
+
178
+ function startUpload(id: string): void {
179
+ const item = uploadItems.value.get(id);
180
+ const tusUpload = tusUploads.value.get(id);
181
+
182
+ if (item && tusUpload && item.status !== 'uploading') {
183
+ updateItem(id, { startedAt: new Date(), status: 'uploading' });
184
+
185
+ // Check for previous uploads to resume
186
+ tusUpload.findPreviousUploads().then((previousUploads) => {
187
+ const previousUpload = previousUploads[0];
188
+ if (previousUpload) {
189
+ tusUpload.resumeFromPreviousUpload(previousUpload);
190
+ }
191
+ tusUpload.start();
192
+ });
193
+ }
194
+ }
195
+
196
+ function startAll(): void {
197
+ const pending = uploads.value.filter((u) => u.status === 'idle' || u.status === 'paused');
198
+ const currentlyUploading = uploads.value.filter((u) => u.status === 'uploading').length;
199
+ const limit = (defaultConfig.parallelUploads || 3) - currentlyUploading;
200
+
201
+ pending.slice(0, Math.max(0, limit)).forEach((item) => startUpload(item.id));
202
+ }
203
+
204
+ function pauseUpload(id: string): void {
205
+ const tusUpload = tusUploads.value.get(id);
206
+ if (tusUpload) {
207
+ tusUpload.abort();
208
+ updateItem(id, { status: 'paused' });
209
+ }
210
+ }
211
+
212
+ function pauseAll(): void {
213
+ uploads.value.filter((u) => u.status === 'uploading').forEach((item) => pauseUpload(item.id));
214
+ }
215
+
216
+ function resumeUpload(id: string): void {
217
+ startUpload(id);
218
+ }
219
+
220
+ function resumeAll(): void {
221
+ uploads.value.filter((u) => u.status === 'paused').forEach((item) => resumeUpload(item.id));
222
+ }
223
+
224
+ function cancelUpload(id: string): void {
225
+ const tusUpload = tusUploads.value.get(id);
226
+ if (tusUpload) {
227
+ tusUpload.abort();
228
+ }
229
+ tusUploads.value.delete(id);
230
+
231
+ const newMap = new Map(uploadItems.value);
232
+ newMap.delete(id);
233
+ uploadItems.value = newMap;
234
+ }
235
+
236
+ function cancelAll(): void {
237
+ uploads.value.forEach((item) => cancelUpload(item.id));
238
+ }
239
+
240
+ function removeUpload(id: string): void {
241
+ cancelUpload(id);
242
+ }
243
+
244
+ function clearCompleted(): void {
245
+ uploads.value.filter((u) => u.status === 'completed').forEach((item) => removeUpload(item.id));
246
+ }
247
+
248
+ function retryUpload(id: string): void {
249
+ const item = uploadItems.value.get(id);
250
+ if (item && item.status === 'error') {
251
+ updateItem(id, { error: undefined, status: 'idle' });
252
+ startUpload(id);
253
+ }
254
+ }
255
+
256
+ function getUpload(id: string): undefined | UploadItem {
257
+ return uploadItems.value.get(id);
258
+ }
259
+
260
+ return {
261
+ addFiles,
262
+ cancelAll,
263
+ cancelUpload,
264
+ clearCompleted,
265
+ getUpload,
266
+ isUploading,
267
+ pauseAll,
268
+ pauseUpload,
269
+ removeUpload,
270
+ resumeAll,
271
+ resumeUpload,
272
+ retryUpload,
273
+ startAll,
274
+ startUpload,
275
+ totalProgress,
276
+ uploads,
277
+ };
278
+ }
@@ -1,49 +1,13 @@
1
1
  <script setup lang="ts">
2
- const props = defineProps({
3
- error: Object,
4
- });
2
+ import type { NuxtError } from '#app';
5
3
 
6
- console.error(props.error);
7
- const debugWord = ref<string>('');
8
- const { current } = useMagicKeys();
9
-
10
- watch(
11
- () => current.values(),
12
- () => {
13
- if (current.values().next().value === 'escape') {
14
- debugWord.value = '';
15
- return;
16
- }
17
-
18
- if (debugWord.value === 'debug') {
19
- return;
20
- }
21
-
22
- if (current.values().next().value && !debugWord.value.includes(current.values().next().value)) {
23
- debugWord.value += current.values().next().value;
24
- }
25
- },
26
- );
27
-
28
- const handleError = () => clearError({ redirect: '/' });
4
+ defineProps<{
5
+ error: NuxtError;
6
+ }>();
29
7
  </script>
30
8
 
31
9
  <template>
32
- <NuxtLayout>
33
- <div class="w-full min-h-screen flex flex-col justify-center items-center">
34
- <div class="w-full flex flex-col items-center mb-20">
35
- <h1 class="text-[12rem] font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-400">Oops!</h1>
36
- <h2 v-if="error?.statusCode" class="text-3xl text-gray-600">
37
- {{ error?.statusCode }}
38
- </h2>
39
- </div>
40
-
41
- <pre v-if="debugWord === 'debug'" class="w-full max-w-3xl mb-5 mx-auto bg-black/80 text-white font-mono p-2 rounded-lg overflow-x-scroll">
42
- {{ { error } }}
43
- </pre
44
- >
45
-
46
- <BaseButton color="primary" appearance="outline" @click="handleError"> Zurück zur Startseite </BaseButton>
47
- </div>
48
- </NuxtLayout>
10
+ <UApp>
11
+ <UError :error="error" />
12
+ </UApp>
49
13
  </template>
@@ -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,12 @@
1
+ export interface User {
2
+ banExpires?: Date;
3
+ banned?: boolean;
4
+ banReason?: string;
5
+ email: string;
6
+ emailVerified: boolean;
7
+ id: string;
8
+ image?: string;
9
+ name?: string;
10
+ role?: string;
11
+ twoFactorEnabled?: boolean;
12
+ }
@@ -1,8 +1,80 @@
1
- <script setup lang="ts"></script>
1
+ <script setup lang="ts">
2
+ import type { NavigationMenuItem } from '@nuxt/ui';
3
+
4
+ const headerItems = computed<NavigationMenuItem[]>(() => [
5
+ {
6
+ label: 'Docs',
7
+ to: '#',
8
+ },
9
+ {
10
+ label: 'Components',
11
+ to: '#',
12
+ },
13
+ {
14
+ label: 'Figma',
15
+ target: '_blank',
16
+ to: '#',
17
+ },
18
+ {
19
+ label: 'Releases',
20
+ target: '_blank',
21
+ to: '#',
22
+ },
23
+ ]);
24
+
25
+ const footerItems: NavigationMenuItem[] = [
26
+ {
27
+ label: 'Figma Kit',
28
+ target: '_blank',
29
+ to: '#',
30
+ },
31
+ {
32
+ label: 'Playground',
33
+ target: '_blank',
34
+ to: '#',
35
+ },
36
+ {
37
+ label: 'Releases',
38
+ target: '_blank',
39
+ to: '#',
40
+ },
41
+ ];
42
+ </script>
2
43
 
3
44
  <template>
4
- <div>
5
- <NuxtLoadingIndicator />
6
- <slot></slot>
45
+ <div class="flex flex-col min-h-screen">
46
+ <UHeader>
47
+ <template #title>
48
+ <UIcon name="i-lucide-code" class="text-primary" />
49
+ </template>
50
+
51
+ <UNavigationMenu :items="headerItems" />
52
+
53
+ <template #right>
54
+ <UColorModeButton />
55
+
56
+ <UTooltip text="Open on GitHub" :kbds="['meta', 'G']">
57
+ <UButton color="neutral" variant="ghost" to="https://github.com/lenneTech/nuxt-base-starter" target="_blank" icon="i-simple-icons-github" aria-label="GitHub" />
58
+ </UTooltip>
59
+ </template>
60
+ </UHeader>
61
+ <UMain>
62
+ <slot></slot>
63
+ </UMain>
64
+ <USeparator icon="i-simple-icons-nuxtdotjs" type="dashed" class="h-px" />
65
+
66
+ <UFooter>
67
+ <template #left>
68
+ <p class="text-muted text-sm">Copyright © {{ new Date().getFullYear() }}</p>
69
+ </template>
70
+
71
+ <UNavigationMenu :items="footerItems" variant="link" />
72
+
73
+ <template #right>
74
+ <UButton icon="i-simple-icons-discord" color="neutral" variant="ghost" to="#" target="_blank" aria-label="Discord" />
75
+ <UButton icon="i-simple-icons-x" color="neutral" variant="ghost" to="#" target="_blank" aria-label="X" />
76
+ <UButton icon="i-simple-icons-github" color="neutral" variant="ghost" to="#" target="_blank" aria-label="GitHub" />
77
+ </template>
78
+ </UFooter>
7
79
  </div>
8
80
  </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <div class="min-h-screen flex justify-center items-center">
3
+ <slot></slot>
4
+ </div>
5
+ </template>
@@ -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;
@@ -1,9 +1,23 @@
1
- export default defineNuxtRouteMiddleware((to) => {
2
- const { currentUserState } = useAuthState();
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
+ }
3
18
 
4
- if (to.fullPath.startsWith('/app/admin')) {
5
- if (!currentUserState?.value?.roles?.includes('admin')) {
6
- return navigateTo('/app');
7
- }
19
+ // Redirect to /app if authenticated but not admin
20
+ if (!isAdmin.value) {
21
+ return navigateTo('/app');
8
22
  }
9
23
  });
@@ -1,9 +1,18 @@
1
- export default defineNuxtRouteMiddleware((to) => {
2
- const { accessTokenState } = useAuthState();
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
+ }
3
13
 
4
- if (to.fullPath.startsWith('/app')) {
5
- if (!accessTokenState?.value) {
6
- return navigateTo('/auth/login');
7
- }
14
+ // Redirect to login if not authenticated
15
+ if (!isAuthenticated.value) {
16
+ return navigateTo('/auth/login');
8
17
  }
9
18
  });