maxibase 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +270 -2
  2. package/docker/Dockerfile +22 -0
  3. package/docker/Dockerfile.dockerignore +25 -0
  4. package/docker/compose.yaml +19 -0
  5. package/layers/base/app/app.vue +22 -0
  6. package/layers/base/app/components/AuthCheck.vue +316 -0
  7. package/layers/base/app/components/DataExplorer.vue +38 -0
  8. package/layers/base/app/components/LocaleSwitcher.vue +28 -0
  9. package/layers/base/app/components/TheConfirmation.vue +48 -0
  10. package/layers/base/app/components/TheLoading.vue +36 -0
  11. package/layers/base/app/components/TheNotifications.vue +71 -0
  12. package/layers/base/app/components/files/FilesDelete.vue +33 -0
  13. package/layers/base/app/components/files/FilesUpload.vue +85 -0
  14. package/layers/base/app/error.vue +28 -0
  15. package/layers/base/app/layouts/default.vue +15 -0
  16. package/layers/base/app/layouts/simple.vue +3 -0
  17. package/layers/base/app/middleware/admin.ts +15 -0
  18. package/layers/base/app/middleware/authenticated.ts +10 -0
  19. package/layers/base/app/middleware/mfa.global.ts +8 -0
  20. package/layers/base/app/pages/admin/logs.vue +364 -0
  21. package/layers/base/app/pages/signin.vue +121 -0
  22. package/layers/base/app/pages/user/auth.vue +509 -0
  23. package/layers/base/app/plugins/error.ts +21 -0
  24. package/layers/base/app/plugins/session.ts +22 -0
  25. package/layers/base/app/stores/app.ts +100 -0
  26. package/layers/base/app/utils/clipboard.ts +6 -0
  27. package/layers/base/app/utils/files.ts +95 -0
  28. package/layers/base/app/utils/image.ts +33 -0
  29. package/layers/base/nuxt.config.ts +146 -0
  30. package/layers/base/public/images/auth/microsoft.svg +1 -0
  31. package/layers/base/public/robots.txt +2 -0
  32. package/layers/base/server/api/admin/logs.get.ts +49 -0
  33. package/layers/base/server/api/auth/anonymous.post.ts +31 -0
  34. package/layers/base/server/api/auth/email/get.post.ts +46 -0
  35. package/layers/base/server/api/auth/email/verify.post.ts +46 -0
  36. package/layers/base/server/api/auth/index.get.ts +10 -0
  37. package/layers/base/server/api/auth/index.post.ts +19 -0
  38. package/layers/base/server/api/auth/options.post.ts +26 -0
  39. package/layers/base/server/api/auth/otp/get.post.ts +74 -0
  40. package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
  41. package/layers/base/server/api/auth/password.post.ts +42 -0
  42. package/layers/base/server/api/auth/refresh.delete.ts +4 -0
  43. package/layers/base/server/api/auth/refresh.post.ts +5 -0
  44. package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
  45. package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
  46. package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
  47. package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
  48. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
  49. package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
  50. package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
  51. package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
  52. package/layers/base/server/api/files/[...path].get.ts +37 -0
  53. package/layers/base/server/api/files/chunk.post.ts +36 -0
  54. package/layers/base/server/api/files/copy.post.ts +22 -0
  55. package/layers/base/server/api/files/index.delete.ts +12 -0
  56. package/layers/base/server/api/files/index.get.ts +19 -0
  57. package/layers/base/server/api/files/index.post.ts +15 -0
  58. package/layers/base/server/api/log.post.ts +20 -0
  59. package/layers/base/server/database/defaults/access.ts +14 -0
  60. package/layers/base/server/database/defaults/db.ts +13 -0
  61. package/layers/base/server/database/defaults/relations.ts +5 -0
  62. package/layers/base/server/database/relations.ts +35 -0
  63. package/layers/base/server/database/schema.ts +66 -0
  64. package/layers/base/server/middleware/0.rateLimit.ts +21 -0
  65. package/layers/base/server/middleware/1.session.ts +14 -0
  66. package/layers/base/server/middleware/2.mfa.ts +34 -0
  67. package/layers/base/server/middleware/3.admin.ts +4 -0
  68. package/layers/base/server/plugins/session.ts +9 -0
  69. package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
  70. package/layers/base/server/routes/dev/email/[template].ts +15 -0
  71. package/layers/base/server/tasks/backup.ts +42 -0
  72. package/layers/base/server/tasks/clean.ts +39 -0
  73. package/layers/base/server/utils/access.ts +8 -0
  74. package/layers/base/server/utils/auth.ts +207 -0
  75. package/layers/base/server/utils/backup.ts +98 -0
  76. package/layers/base/server/utils/db.ts +3 -0
  77. package/layers/base/server/utils/email.ts +138 -0
  78. package/layers/base/server/utils/files.ts +751 -0
  79. package/layers/base/server/utils/ip.ts +15 -0
  80. package/layers/base/server/utils/log.ts +31 -0
  81. package/layers/base/server/utils/rateLimit.ts +69 -0
  82. package/layers/base/server/utils/schemas.ts +8 -0
  83. package/layers/base/server/utils/text.ts +4 -0
  84. package/layers/base/server/utils/time.ts +3 -0
  85. package/layers/base/shared/types/auth.d.ts +22 -0
  86. package/layers/base/shared/utils/logs.ts +9 -0
  87. package/layers/base/shared/utils/url.ts +5 -0
  88. package/locales/en.json +33 -0
  89. package/locales/fr.json +33 -0
  90. package/locales/pages/admin-logs/en.json +1 -0
  91. package/locales/pages/admin-logs/fr.json +1 -0
  92. package/locales/pages/index/en.json +1 -0
  93. package/locales/pages/index/fr.json +1 -0
  94. package/locales/pages/signin/en.json +5 -0
  95. package/locales/pages/signin/fr.json +5 -0
  96. package/locales/pages/user-auth/en.json +22 -0
  97. package/locales/pages/user-auth/fr.json +22 -0
  98. package/nuxt.config.ts +54 -0
  99. package/package.json +92 -4
  100. package/scripts/db/migrate.ts +9 -0
  101. package/scripts/db/push-server.sh +45 -0
  102. package/scripts/db/restore.sh +26 -0
  103. package/scripts/deploy.sh +34 -0
  104. package/scripts/env.sh +20 -0
  105. package/scripts/remote/switch.sh +48 -0
  106. package/scripts/rollback.sh +12 -0
  107. package/server/assets/emails/base.html +43 -0
  108. package/server/assets/emails/en/base.html +1 -0
  109. package/server/assets/emails/en/emailChange.html +4 -0
  110. package/server/assets/emails/en/emailChanged.html +2 -0
  111. package/server/assets/emails/en/otp.html +10 -0
  112. package/server/assets/emails/fr/base.html +1 -0
  113. package/server/assets/emails/fr/emailChange.html +4 -0
  114. package/server/assets/emails/fr/emailChanged.html +2 -0
  115. package/server/assets/emails/fr/otp.html +10 -0
@@ -0,0 +1,509 @@
1
+ <script setup lang="ts">
2
+ import QRCode from 'qrcode'
3
+
4
+ definePageMeta({
5
+ middleware: ['authenticated'],
6
+ })
7
+
8
+ const appStore = useAppStore()
9
+ const { user, fetch: fetchUserSession } = useUserSession()
10
+ const passwordPolicy = useRuntimeConfig().public.password
11
+ type AuthInfo = {
12
+ credentials: { id: string; name: string }[]
13
+ hasPassword: boolean
14
+ hasTOTP: boolean
15
+ forceMfa: boolean
16
+ }
17
+
18
+ const auth = ref<AuthInfo>({
19
+ credentials: [],
20
+ hasPassword: false,
21
+ hasTOTP: false,
22
+ forceMfa: false,
23
+ })
24
+ const TOTPSecret = ref('')
25
+ const TOTPCode = ref('')
26
+ const password1 = ref('')
27
+ const password2 = ref('')
28
+ const showPasswordChange = ref(false)
29
+ const showPassword1 = ref(false)
30
+ const showPassword2 = ref(false)
31
+ const showTOTP = ref(false)
32
+ const newEmail = ref('')
33
+ const emailCode = ref('')
34
+ const emailCodeSent = ref(false)
35
+ const { clear: clearSession } = useUserSession()
36
+ const { $getLocale, $t } = useI18n()
37
+
38
+ const isMfaSetup = computed(() => user.value?.requiresMfaSetup === true)
39
+
40
+ async function checkAuth() {
41
+ return isMfaSetup.value || (await appStore.checkAuth())
42
+ }
43
+
44
+ const isCurrentEmail = computed(() => {
45
+ const value = newEmail.value.toLowerCase()
46
+ return !value || value === user.value?.email?.toLowerCase()
47
+ })
48
+
49
+ const isPasswordValid = computed(() => {
50
+ return password1.value.length >= passwordPolicy.min
51
+ })
52
+
53
+ const canRemovePasskey = computed(
54
+ () => !auth.value?.forceMfa || auth.value.hasTOTP || auth.value.credentials.length > 1,
55
+ )
56
+ const canDisableTOTP = computed(() => !auth.value?.forceMfa || auth.value.credentials.length > 0)
57
+
58
+ async function getAuth() {
59
+ try {
60
+ auth.value = await $fetch<AuthInfo>('/api/auth')
61
+ } catch (e: any) {
62
+ appStore.notify(e?.data?.message || e?.message, 'error')
63
+ }
64
+ }
65
+
66
+ const { register } = useWebAuthn({
67
+ registerEndpoint: '/api/auth/webauthn/register',
68
+ })
69
+
70
+ async function registerPasskey() {
71
+ try {
72
+ if (!(await checkAuth())) return
73
+ await register({ userName: user.value!.email! })
74
+ await getAuth()
75
+ appStore.notify('saved', 'success')
76
+ if (isMfaSetup.value) {
77
+ await fetchUserSession()
78
+ if (!user.value?.requiresMfaSetup) await navigateTo('/', { replace: true })
79
+ }
80
+ } catch (e: any) {
81
+ appStore.notify(e?.data?.message || e?.message, 'error')
82
+ }
83
+ }
84
+
85
+ async function setPasskeyName(name: string, credentialId: string) {
86
+ try {
87
+ if (!(await checkAuth())) return
88
+ await $fetch('/api/auth/webauthn', {
89
+ method: 'POST',
90
+ body: { name, credentialId },
91
+ })
92
+ appStore.notify('saved', 'success')
93
+ } catch (e: any) {
94
+ appStore.notify(e?.data?.message || e?.message, 'error')
95
+ }
96
+ }
97
+
98
+ async function deletePasskey(credentialId: string) {
99
+ try {
100
+ if (!(await checkAuth())) return
101
+ await $fetch('/api/auth/webauthn', {
102
+ method: 'DELETE',
103
+ body: { credentialId },
104
+ })
105
+ await getAuth()
106
+ appStore.notify('deleted', 'success')
107
+ } catch (e: any) {
108
+ appStore.notify(e?.data?.message || e?.message, 'error')
109
+ }
110
+ }
111
+
112
+ async function requestEmailChange() {
113
+ try {
114
+ await $fetch('/api/auth/email/get', {
115
+ method: 'POST',
116
+ body: { email: newEmail.value, locale: $getLocale() },
117
+ })
118
+ emailCodeSent.value = true
119
+ } catch (e: any) {
120
+ appStore.notify(e?.data?.message || e?.message, 'error')
121
+ }
122
+ }
123
+
124
+ async function confirmEmailChange() {
125
+ if (emailCode.value.length !== 6) return
126
+ try {
127
+ if (!(await checkAuth())) return
128
+ await $fetch('/api/auth/email/verify', {
129
+ method: 'POST',
130
+ body: { email: newEmail.value, otp: emailCode.value, locale: $getLocale() },
131
+ })
132
+ await fetchUserSession()
133
+ emailCodeSent.value = false
134
+ emailCode.value = ''
135
+ appStore.notify('saved', 'success')
136
+ } catch (e: any) {
137
+ if ((e?.status ?? e?.statusCode) === 409) {
138
+ emailCodeSent.value = false
139
+ emailCode.value = ''
140
+ }
141
+ appStore.notify(e?.data?.message || e?.message, 'error')
142
+ }
143
+ }
144
+
145
+ async function setPassword() {
146
+ try {
147
+ if (!(await checkAuth())) return
148
+ await $fetch('/api/auth', {
149
+ method: 'POST',
150
+ body: { password: password1.value },
151
+ })
152
+ password1.value = ''
153
+ password2.value = ''
154
+ showPasswordChange.value = false
155
+ auth.value.hasPassword = true
156
+ appStore.notify('saved', 'success')
157
+ } catch (e: any) {
158
+ appStore.notify(e?.data?.message || e?.message, 'error')
159
+ }
160
+ }
161
+
162
+ function toggleTOTP() {
163
+ if (auth.value?.hasTOTP) disableTOTP()
164
+ else showTOTPSecret()
165
+ }
166
+
167
+ async function showTOTPSecret() {
168
+ try {
169
+ if (!(await checkAuth())) {
170
+ return
171
+ }
172
+ const response = await $fetch<{ secret: string }>('/api/auth/totp/generate')
173
+ showTOTP.value = true
174
+ TOTPSecret.value = response.secret
175
+ await nextTick()
176
+ const canvas = document.getElementById('totp-qrcode') as HTMLCanvasElement
177
+
178
+ const config = useRuntimeConfig()
179
+ const issuer = config.public.name
180
+ const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user.value!.email!)}?secret=${TOTPSecret.value}&issuer=${encodeURIComponent(issuer)}`
181
+ QRCode.toCanvas(canvas, uri)
182
+ } catch (e: any) {
183
+ showTOTP.value = false
184
+ appStore.notify(e?.data?.message || e?.message, 'error')
185
+ }
186
+ }
187
+
188
+ async function confirmTOTP() {
189
+ if (TOTPCode.value.length !== 6) return
190
+ try {
191
+ if (!(await checkAuth())) return
192
+ await $fetch('/api/auth/totp/confirm', {
193
+ method: 'POST',
194
+ body: { secret: TOTPSecret.value, token: TOTPCode.value },
195
+ })
196
+ TOTPSecret.value = ''
197
+ TOTPCode.value = ''
198
+ auth.value.hasTOTP = true
199
+ showTOTP.value = false
200
+ if (isMfaSetup.value) {
201
+ await fetchUserSession()
202
+ if (!user.value?.requiresMfaSetup) await navigateTo('/', { replace: true })
203
+ }
204
+ } catch (e: any) {
205
+ appStore.notify(e?.data?.message || e?.message, 'error')
206
+ }
207
+ }
208
+
209
+ async function disableTOTP() {
210
+ try {
211
+ if (!(await appStore.confirm($t('disableTOTP') as string)) || !(await checkAuth())) return
212
+ await $fetch('/api/auth/totp/disable', { method: 'POST' })
213
+ TOTPCode.value = ''
214
+ auth.value.hasTOTP = false
215
+ showTOTP.value = false
216
+ } catch (e: any) {
217
+ appStore.notify(e?.data?.message || e?.message, 'error')
218
+ }
219
+ }
220
+
221
+ async function deleteRefreshTokens() {
222
+ if (!(await checkAuth())) return
223
+ appStore.setLoading(true)
224
+ try {
225
+ await $fetch('/api/auth/refresh', {
226
+ method: 'DELETE',
227
+ })
228
+ await clearSession()
229
+ navigateTo('/')
230
+ } catch (e: any) {
231
+ appStore.notify(e?.data?.message || e?.message, 'error')
232
+ } finally {
233
+ appStore.setLoading(false)
234
+ }
235
+ }
236
+
237
+ onMounted(getAuth)
238
+ </script>
239
+
240
+ <template>
241
+ <div class="page">
242
+ <template v-if="isMfaSetup">
243
+ <div class="p2 my2 text-center">
244
+ <b>{{ $t('mfaRequired') }}</b>
245
+ <p class="m0">{{ $t('mfaRequiredDescription') }}</p>
246
+ </div>
247
+
248
+ <button
249
+ class="w mb2"
250
+ @click="registerPasskey"
251
+ >
252
+ {{ $t('registerPasskey') }}
253
+ </button>
254
+ <button
255
+ class="w fg fg-border"
256
+ @click="toggleTOTP"
257
+ >
258
+ {{ $t('setupTotp') }}
259
+ </button>
260
+ </template>
261
+
262
+ <template v-else>
263
+ <h1>{{ $t('authentication') }}</h1>
264
+
265
+ <section>
266
+ <form @submit.prevent="requestEmailChange">
267
+ <label for="email">
268
+ {{ $t('email') }}
269
+ <div class="group flex-row">
270
+ <input
271
+ id="email"
272
+ type="email"
273
+ autocapitalize="none"
274
+ v-model.trim="newEmail"
275
+ :placeholder="user?.email"
276
+ />
277
+ <button
278
+ class="flex-center fg"
279
+ :title="$t('changeEmail')"
280
+ :disabled="isCurrentEmail"
281
+ type="submit"
282
+ >
283
+ <Icon name="lucide:save" />
284
+ </button>
285
+ </div>
286
+ </label>
287
+ </form>
288
+ <div
289
+ v-if="emailCodeSent"
290
+ class="modal"
291
+ @click.self="emailCodeSent = false"
292
+ >
293
+ <div
294
+ class="p3"
295
+ style="max-width: min(400px, 90vw)"
296
+ >
297
+ <form
298
+ class="flex-column g2"
299
+ @submit.prevent="confirmEmailChange"
300
+ >
301
+ <label for="email-code">{{ $t('code') }}</label>
302
+ <input
303
+ id="email-code"
304
+ v-model.trim="emailCode"
305
+ type="text"
306
+ autocomplete="one-time-code"
307
+ maxlength="6"
308
+ required
309
+ />
310
+ <p class="muted-text">{{ $t('emailChangeCodeSent', { email: newEmail }) }}</p>
311
+ <div class="text-right">
312
+ <button
313
+ type="submit"
314
+ :disabled="emailCode.length !== 6"
315
+ >
316
+ {{ $t('save') }}
317
+ </button>
318
+ </div>
319
+ </form>
320
+ </div>
321
+ </div>
322
+ <form
323
+ class="mt1"
324
+ @submit.prevent="showPasswordChange = true"
325
+ >
326
+ <label for="password1">
327
+ {{ $t('password') }}
328
+ <div class="group flex-row">
329
+ <input
330
+ :type="showPassword1 ? 'text' : 'password'"
331
+ id="password1"
332
+ v-model.trim="password1"
333
+ autocomplete="new-password"
334
+ :minlength="passwordPolicy.min"
335
+ :maxlength="passwordPolicy.max"
336
+ required
337
+ :placeholder="auth?.hasPassword ? '************' : ''"
338
+ />
339
+ <button
340
+ class="flex-center fg"
341
+ type="button"
342
+ @click="showPassword1 = !showPassword1"
343
+ >
344
+ <Icon :name="showPassword1 ? 'lucide:eye-off' : 'lucide:eye'" />
345
+ </button>
346
+ <button
347
+ class="fg flex-center"
348
+ :title="$t('changePassword')"
349
+ type="submit"
350
+ :disabled="!isPasswordValid"
351
+ >
352
+ <Icon name="lucide:save" />
353
+ </button>
354
+ </div>
355
+ <small class="warning-text">{{
356
+ $t('passwordPolicy', { count: passwordPolicy.min })
357
+ }}</small>
358
+ </label>
359
+ </form>
360
+ <div
361
+ v-if="showPasswordChange"
362
+ class="modal"
363
+ @click.self="showPasswordChange = false"
364
+ >
365
+ <div
366
+ class="p3"
367
+ style="max-width: min(400px, 90vw)"
368
+ >
369
+ <form
370
+ class="flex-column g2"
371
+ @submit.prevent="setPassword"
372
+ >
373
+ <input
374
+ v-show="false"
375
+ type="email"
376
+ :value="user?.email"
377
+ autocomplete="username email"
378
+ />
379
+ <label for="password2">{{ $t('confirm') }} {{ $t('password') }}</label>
380
+ <div class="flex-row group">
381
+ <input
382
+ :type="showPassword2 ? 'text' : 'password'"
383
+ id="password2"
384
+ v-model.trim="password2"
385
+ autocomplete="new-password"
386
+ :minlength="passwordPolicy.min"
387
+ :maxlength="passwordPolicy.max"
388
+ required
389
+ />
390
+ <button
391
+ class="flex fg"
392
+ type="button"
393
+ @click="showPassword2 = !showPassword2"
394
+ >
395
+ <Icon :name="showPassword2 ? 'lucide:eye-off' : 'lucide:eye'" />
396
+ </button>
397
+ </div>
398
+ <div class="text-right">
399
+ <button
400
+ type="submit"
401
+ :disabled="password1 !== password2 || !isPasswordValid"
402
+ >
403
+ {{ $t('save') }}
404
+ </button>
405
+ </div>
406
+ </form>
407
+ </div>
408
+ </div>
409
+ </section>
410
+
411
+ <section>
412
+ <h2>{{ $t('2fa') }}</h2>
413
+ <h3 class="m0">Passkeys</h3>
414
+ <form
415
+ v-for="credential in auth?.credentials || []"
416
+ :key="credential.id"
417
+ @submit.prevent="setPasskeyName(credential.name, credential.id)"
418
+ >
419
+ <div class="flex-row group mt1">
420
+ <input
421
+ type="text"
422
+ v-model="credential.name"
423
+ maxlength="32"
424
+ />
425
+ <button
426
+ type="button"
427
+ class="bg danger-text flex"
428
+ :disabled="!canRemovePasskey"
429
+ :title="canRemovePasskey ? '' : $t('lastFactor')"
430
+ @click="deletePasskey(credential.id)"
431
+ >
432
+ <Icon name="lucide:trash" />
433
+ </button>
434
+ <button class="flex fg"><Icon name="lucide:save" /></button>
435
+ </div>
436
+ </form>
437
+ <button
438
+ class="flex mt1 mb2"
439
+ :disabled="auth?.credentials.length >= 4"
440
+ :title="auth?.credentials.length >= 4 ? $t('passkeyLimit') : $t('registerPasskey')"
441
+ @click="registerPasskey"
442
+ >
443
+ {{ $t('registerPasskey') }}
444
+ </button>
445
+
446
+ <label for="totp">{{ $t('authenticatorApp') }}</label>
447
+ <input
448
+ type="checkbox"
449
+ id="totp"
450
+ class="ml1"
451
+ :checked="!!auth?.hasTOTP"
452
+ :disabled="!canDisableTOTP"
453
+ :title="canDisableTOTP ? '' : $t('lastFactor')"
454
+ @click.prevent="toggleTOTP"
455
+ />
456
+ </section>
457
+ <div class="flex-row my3">
458
+ <button
459
+ class="ml bg danger-text danger-border"
460
+ @click="deleteRefreshTokens"
461
+ >
462
+ {{ $t('deleteRefreshTokens') }}
463
+ </button>
464
+ </div>
465
+ </template>
466
+
467
+ <div
468
+ v-if="showTOTP && TOTPSecret"
469
+ class="modal"
470
+ @click.self="showTOTP = false"
471
+ >
472
+ <div
473
+ class="p3"
474
+ style="max-width: min(400px, 90vw)"
475
+ >
476
+ <p class="mt0">{{ $t('totpSecret') }}</p>
477
+ <div class="text-center">
478
+ <canvas
479
+ id="totp-qrcode"
480
+ class="border-radius"
481
+ ></canvas>
482
+ <p
483
+ class="muted-text"
484
+ style="word-break: break-all"
485
+ >
486
+ {{ TOTPSecret }}
487
+ </p>
488
+ </div>
489
+ <label for="totp-code">{{ $t('code') }}</label>
490
+ <div class="flex-row group">
491
+ <input
492
+ id="totp-code"
493
+ type="text"
494
+ v-model.trim="TOTPCode"
495
+ autocomplete="one-time-code"
496
+ @keyup="confirmTOTP"
497
+ />
498
+ </div>
499
+ </div>
500
+ </div>
501
+ </div>
502
+ </template>
503
+
504
+ <style scoped>
505
+ .page {
506
+ max-width: min(400px, 90vw);
507
+ margin: 0 auto;
508
+ }
509
+ </style>
@@ -0,0 +1,21 @@
1
+ import type { NuxtError } from '#app'
2
+
3
+ export default defineNuxtPlugin((nuxtApp) => {
4
+ const requestFetch = useRequestFetch()
5
+
6
+ nuxtApp.vueApp.config.errorHandler = (err, instance, info) => {
7
+ const status = (err as NuxtError).status
8
+ if (status && status >= 400 && status < 500) return
9
+
10
+ const componentName = instance?.$options?.__name || ''
11
+ requestFetch('/api/log', {
12
+ method: 'POST',
13
+ body: {
14
+ summary: (err as Error).message + ' ' + info,
15
+ data: err,
16
+ origin: useRoute().fullPath + ' ' + componentName,
17
+ type: 'error',
18
+ },
19
+ }).catch(() => {})
20
+ }
21
+ })
@@ -0,0 +1,22 @@
1
+ import type { FetchError } from 'ofetch'
2
+
3
+ export default defineNuxtPlugin(async () => {
4
+ if (import.meta.server) return
5
+ const { session, clear } = useUserSession()
6
+ if (!session.value?.user) return
7
+
8
+ const lastRefreshed = parseInt(localStorage.getItem('rotate-token-at') || '0')
9
+ if (lastRefreshed) {
10
+ const refreshAfter = lastRefreshed + useRuntimeConfig().public.refreshToken.rotateAfter * 1000
11
+ const { expiresAt } = session.value
12
+ if ((expiresAt !== undefined && expiresAt < Date.now()) || refreshAfter > Date.now()) return
13
+ try {
14
+ await $fetch('/api/auth/refresh', { method: 'POST' })
15
+ } catch (error) {
16
+ if ((error as FetchError).response?.status !== 401) return
17
+ await clear()
18
+ return
19
+ }
20
+ }
21
+ localStorage.setItem('rotate-token-at', Date.now().toString())
22
+ })
@@ -0,0 +1,100 @@
1
+ export const useAppStore = defineStore('app', () => {
2
+ const isLoading = ref(false)
3
+ const notifications = ref(
4
+ [] as {
5
+ id: number
6
+ message: string
7
+ type: 'error' | 'danger' | 'success'
8
+ isSticky: boolean
9
+ params?: Record<string, string | number>
10
+ }[],
11
+ )
12
+ const confirmation = ref<{
13
+ message?: string
14
+ resolve: (value?: any) => any
15
+ reject: (value?: any) => any
16
+ } | null>(null)
17
+
18
+ const authVerifiedAt = ref(0)
19
+ const authPromise = ref()
20
+
21
+ function confirm(message?: string) {
22
+ return new Promise((resolve, reject) => {
23
+ confirmation.value = {
24
+ message,
25
+ resolve,
26
+ reject,
27
+ }
28
+ }).finally(() => {
29
+ confirmation.value = null
30
+ })
31
+ }
32
+
33
+ async function checkAuth(): Promise<boolean> {
34
+ const reverifyAfter = useRuntimeConfig().public.recentAuth.maxAge * 1000
35
+ if (Date.now() - authVerifiedAt.value < reverifyAfter) {
36
+ return true
37
+ }
38
+ return new Promise<boolean>((resolve) => {
39
+ authPromise.value = { resolve }
40
+ })
41
+ .then((verified) => {
42
+ if (verified) authVerifiedAt.value = Date.now()
43
+ return verified
44
+ })
45
+ .finally(() => {
46
+ authPromise.value = null
47
+ })
48
+ }
49
+
50
+ function setLoading(state: boolean) {
51
+ isLoading.value = state
52
+ }
53
+
54
+ let lastNotificationId = 0
55
+
56
+ function notify(
57
+ message: string,
58
+ type: 'success' | 'error' = 'success',
59
+ isSticky = false,
60
+ params?: Record<string, string | number>,
61
+ ) {
62
+ if (!message) return
63
+ const id = ++lastNotificationId
64
+ notifications.value.push({ id, message: handleZodError(message), type, isSticky, params })
65
+
66
+ if (!isSticky) {
67
+ setTimeout(() => removeNotification(id), 5000)
68
+ }
69
+ }
70
+
71
+ function handleZodError(message: string) {
72
+ try {
73
+ if (message[0] === '[') {
74
+ const parsed = JSON.parse(message)
75
+ if (Array.isArray(parsed)) {
76
+ return parsed[0].message
77
+ }
78
+ }
79
+ } catch {
80
+ return message
81
+ }
82
+ return message
83
+ }
84
+
85
+ function removeNotification(id: number) {
86
+ notifications.value = notifications.value.filter((n) => n.id !== id)
87
+ }
88
+
89
+ return {
90
+ isLoading,
91
+ notifications,
92
+ confirmation,
93
+ confirm,
94
+ setLoading,
95
+ notify,
96
+ removeNotification,
97
+ authPromise,
98
+ checkAuth,
99
+ }
100
+ })
@@ -0,0 +1,6 @@
1
+ export async function copyToClipboard(text: string) {
2
+ const { $t } = useI18n()
3
+ const appStore = useAppStore()
4
+ await navigator.clipboard.writeText(text)
5
+ appStore.notify($t('copied') as string, 'success')
6
+ }