maxibase 0.0.0-stage → 1.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.
Files changed (115) hide show
  1. package/README.md +269 -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 +5 -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,364 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({
3
+ middleware: ['admin'],
4
+ })
5
+
6
+ const appStore = useAppStore()
7
+
8
+ const isLoading = ref()
9
+ const error = ref('')
10
+ const logs = ref([] as any[])
11
+ const hasMore = ref(false)
12
+ const shownLimit = ref(0)
13
+ const expanded = ref(new Set<number>())
14
+ const day = 24 * 60 * 60 * 1000
15
+ const offset = new Date().getTimezoneOffset() * 60 * 1000 - 60000
16
+ const from = ref(new Date(Date.now() - 7 * day - offset).toISOString().substring(0, 16))
17
+ const to = ref(new Date(Date.now() - offset).toISOString().substring(0, 16))
18
+ const search = ref('')
19
+ const type = ref('')
20
+ const hideDuplicates = ref(true)
21
+ const duplicateMap = ref({} as Record<string, any[]>)
22
+ const sortBy = ref('time')
23
+
24
+ const filteredLogs = computed(() => {
25
+ if (!hideDuplicates.value) return logs.value
26
+ return logs.value.filter((log) => {
27
+ if (log.duplicates.length === 1) return true
28
+ const duplicateId = log.summary + log.origin
29
+ return duplicateMap.value[duplicateId] && duplicateMap.value[duplicateId][0]?.id === log.id
30
+ })
31
+ })
32
+
33
+ const sortedLogs = computed(() => {
34
+ if (sortBy.value === 'time') return filteredLogs.value
35
+ return filteredLogs.value.toSorted((a, b) => {
36
+ if (sortBy.value === 'type') {
37
+ return a.type.localeCompare(b.type)
38
+ } else if (sortBy.value === 'duplicates') {
39
+ return b.duplicates.length - a.duplicates.length
40
+ }
41
+ return 0
42
+ })
43
+ })
44
+
45
+ const groupedLogs = computed(() => {
46
+ if (sortBy.value !== 'time') return [{ label: '', logs: sortedLogs.value }]
47
+ const groups = [] as { label: string; logs: any[] }[]
48
+ for (const log of sortedLogs.value) {
49
+ const label = formatDay(log.time)
50
+ const group = groups[groups.length - 1]
51
+ if (group?.label === label) group.logs.push(log)
52
+ else groups.push({ label, logs: [log] })
53
+ }
54
+ return groups
55
+ })
56
+
57
+ function formatDay(utcTimestamp: string) {
58
+ const date = new Date(utcTimestamp)
59
+ const today = new Date()
60
+ const yesterday = new Date(today)
61
+ yesterday.setDate(today.getDate() - 1)
62
+ if (date.toDateString() === today.toDateString()) return 'Today'
63
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
64
+ return date.toLocaleDateString(undefined, {
65
+ weekday: 'long',
66
+ month: 'long',
67
+ day: 'numeric',
68
+ year: date.getFullYear() === today.getFullYear() ? undefined : 'numeric',
69
+ })
70
+ }
71
+
72
+ function originUrl(origin: string | null) {
73
+ const url = origin?.replace(/ .*/, '')
74
+ return url?.startsWith('/') || url?.startsWith('http') ? url : null
75
+ }
76
+
77
+ function toggleExpanded(id: number, isOpen: boolean) {
78
+ if (isOpen) expanded.value.add(id)
79
+ else expanded.value.delete(id)
80
+ }
81
+
82
+ function toUTC(datetimeLocal: string) {
83
+ return new Date(datetimeLocal).toISOString()
84
+ }
85
+
86
+ function formatDateTime(utcTimestamp: string) {
87
+ return new Date(utcTimestamp).toLocaleString(undefined, {
88
+ month: 'short',
89
+ day: 'numeric',
90
+ hour: '2-digit',
91
+ minute: '2-digit',
92
+ })
93
+ }
94
+
95
+ async function getLogs() {
96
+ isLoading.value = true
97
+ error.value = ''
98
+ try {
99
+ const response = await $fetch<any>('/api/admin/logs', {
100
+ query: {
101
+ from: toUTC(from.value),
102
+ to: toUTC(to.value),
103
+ search: search.value,
104
+ type: type.value || undefined,
105
+ },
106
+ })
107
+ logs.value = response.logs
108
+ hasMore.value = response.hasMore
109
+ shownLimit.value = response.limit
110
+ expanded.value.clear()
111
+ duplicateMap.value = {}
112
+ logs.value.forEach((log) => {
113
+ const duplicateId = log.summary + log.origin
114
+ if (!duplicateMap.value[duplicateId]) {
115
+ duplicateMap.value[duplicateId] = []
116
+ }
117
+ duplicateMap.value[duplicateId]?.push(log)
118
+ log.duplicates = duplicateMap.value[duplicateId]
119
+ })
120
+ } catch (e: any) {
121
+ logs.value = []
122
+ hasMore.value = false
123
+ error.value = e?.data?.message || e?.message || 'Failed to load logs'
124
+ appStore.notify(error.value, 'error')
125
+ } finally {
126
+ isLoading.value = false
127
+ }
128
+ }
129
+
130
+ onMounted(() => {
131
+ getLogs()
132
+ })
133
+ </script>
134
+
135
+ <template>
136
+ <div class="flex">
137
+ <div class="p3">
138
+ <form
139
+ @submit.prevent="getLogs"
140
+ class="flex-column g2 mb1"
141
+ >
142
+ <div class="flex-row group fg flex-1">
143
+ <label
144
+ for="search"
145
+ class="flex-center p-input"
146
+ >
147
+ <Icon
148
+ name="lucide:search"
149
+ class="mr"
150
+ />
151
+ </label>
152
+ <input
153
+ id="search"
154
+ type="text"
155
+ class="m0 flex-4"
156
+ v-model="search"
157
+ />
158
+ </div>
159
+ <div class="flex-row group fg flex-1">
160
+ <label
161
+ for="type"
162
+ class="p-input"
163
+ >
164
+ Type
165
+ </label>
166
+ <select
167
+ v-model="type"
168
+ id="type"
169
+ @change="getLogs"
170
+ >
171
+ <option value="">All</option>
172
+ <option
173
+ v-for="logType in logTypes"
174
+ :key="logType.value"
175
+ :value="logType.value"
176
+ >
177
+ {{ logType.label }}
178
+ </option>
179
+ </select>
180
+ </div>
181
+ <div class="flex-row group fg flex-1">
182
+ <label
183
+ for="sortBy"
184
+ class="p-input"
185
+ style="white-space: nowrap"
186
+ >
187
+ Sort by
188
+ </label>
189
+ <select
190
+ v-model="sortBy"
191
+ id="sortBy"
192
+ >
193
+ <option value="time">Time</option>
194
+ <option value="type">Type</option>
195
+ <option value="duplicates">Duplicates</option>
196
+ </select>
197
+ </div>
198
+ <div class="flex-row group fg flex-1">
199
+ <label
200
+ for="from"
201
+ class="p-input"
202
+ >
203
+ From
204
+ </label>
205
+ <input
206
+ id="from"
207
+ type="datetime-local"
208
+ class="m0 flex-4"
209
+ v-model="from"
210
+ @change="getLogs"
211
+ />
212
+ </div>
213
+ <div class="flex-row group fg flex-1">
214
+ <label
215
+ for="to"
216
+ class="p-input"
217
+ >
218
+ To
219
+ </label>
220
+ <input
221
+ id="to"
222
+ type="datetime-local"
223
+ class="m0 flex-4"
224
+ v-model="to"
225
+ @change="getLogs"
226
+ />
227
+ </div>
228
+ <button
229
+ type="submit"
230
+ :class="['flex-1', isLoading ? 'spin' : '']"
231
+ :disabled="isLoading"
232
+ >
233
+ Get logs
234
+ </button>
235
+ </form>
236
+ <label>
237
+ Hide duplicates
238
+ <input
239
+ type="checkbox"
240
+ class="ml1"
241
+ v-model="hideDuplicates"
242
+ />
243
+ </label>
244
+ </div>
245
+ <div
246
+ class="flex-auto p3"
247
+ style="min-width: 0"
248
+ >
249
+ <div
250
+ v-if="error"
251
+ class="danger-text mb2"
252
+ >
253
+ {{ error }}
254
+ </div>
255
+ <div
256
+ v-else-if="!isLoading && !sortedLogs.length"
257
+ class="muted-text mb2"
258
+ >
259
+ {{ 'No logs match this search.' }}
260
+ </div>
261
+ <div
262
+ v-if="hasMore"
263
+ class="warning-text mb2"
264
+ >
265
+ Showing the first {{ shownLimit }} matches. Narrow the period or refine the search.
266
+ </div>
267
+ <div
268
+ v-for="group in groupedLogs"
269
+ :key="group.label"
270
+ class="mb2"
271
+ >
272
+ <div
273
+ v-if="group.label"
274
+ class="sticky top bg bb pb1 muted-text mb1"
275
+ >
276
+ {{ group.label }}
277
+ </div>
278
+ <div
279
+ v-for="log in group.logs"
280
+ :key="log.id"
281
+ class="accordion fg mb1"
282
+ >
283
+ <label
284
+ :for="`log-${log.id}`"
285
+ class="p2"
286
+ >
287
+ <div class="flex g1">
288
+ <div class="flex-1 line mr1">
289
+ <span
290
+ :class="logTypes.find((t) => t.value === log.type.toLowerCase())?.color"
291
+ style="text-transform: capitalize"
292
+ >
293
+ {{ log.type }}
294
+ <template v-if="log.duplicates.length > 1">
295
+ ({{ log.duplicates.length }})
296
+ </template>
297
+ </span>
298
+ @
299
+ <a
300
+ v-if="originUrl(log.origin)"
301
+ :href="originUrl(log.origin)!"
302
+ target="_blank"
303
+ rel="noopener noreferrer"
304
+ >
305
+ {{ log.origin }}
306
+ </a>
307
+ <span v-else>{{ log.origin }}</span>
308
+ </div>
309
+ <div>{{ formatDateTime(log.time) }}</div>
310
+ </div>
311
+ <small style="word-wrap: break-word">{{ log.summary }}</small>
312
+ </label>
313
+ <input
314
+ :id="`log-${log.id}`"
315
+ type="checkbox"
316
+ :checked="expanded.has(log.id)"
317
+ @change="toggleExpanded(log.id, ($event.target as HTMLInputElement).checked)"
318
+ />
319
+ <div class="px2">
320
+ <ul v-if="expanded.has(log.id)">
321
+ <li
322
+ v-for="duplicate in log.duplicates"
323
+ :key="duplicate.id"
324
+ class="mb1"
325
+ >
326
+ <small>
327
+ <div class="flex g1">
328
+ <div class="mr">
329
+ <b>User:</b> IP: {{ duplicate.ipAddress }}
330
+ <span v-if="duplicate.auth">
331
+ {{ duplicate.auth.email }} (ID: {{ duplicate.userId }})
332
+ </span>
333
+ </div>
334
+ <div>{{ formatDateTime(duplicate.time) }}</div>
335
+ </div>
336
+ <b>User Agent:</b> {{ duplicate.userAgent }}
337
+ <div
338
+ v-if="duplicate.data && Object.keys(duplicate.data).length"
339
+ class=""
340
+ >
341
+ <b>Data:</b>
342
+ <button
343
+ class="fg fg-border p0 ml1"
344
+ @click="copyToClipboard(JSON.stringify(duplicate.data))"
345
+ >
346
+ <Icon
347
+ name="lucide:copy"
348
+ class="mr"
349
+ />
350
+ </button>
351
+ <DataExplorer
352
+ :data="duplicate.data"
353
+ class="bl pl1"
354
+ />
355
+ </div>
356
+ </small>
357
+ </li>
358
+ </ul>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </template>
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ const { $t } = useI18n()
3
+ useSeoMeta({
4
+ title: $t('authCheck.signin') as string,
5
+ ogTitle: $t('authCheck.signin') as string,
6
+ description: $t('authCheck.signin') as string,
7
+ ogDescription: $t('authCheck.signin') as string,
8
+ })
9
+
10
+ definePageMeta({
11
+ layout: 'simple',
12
+ middleware: [
13
+ (to) => {
14
+ const { user } = useUserSession()
15
+
16
+ if (user.value?.email) {
17
+ return navigateTo(safePath(to.query.goto), { replace: true })
18
+ }
19
+ },
20
+ ],
21
+ })
22
+ const config = useRuntimeConfig()
23
+ const route = useRoute()
24
+ const appStore = useAppStore()
25
+ const { fetch: fetchUserSession } = useUserSession()
26
+ const isLoading = ref(false)
27
+
28
+ async function signInAnonymously() {
29
+ isLoading.value = true
30
+ try {
31
+ await $fetch('/api/auth/anonymous', {
32
+ method: 'POST',
33
+ })
34
+ await fetchUserSession()
35
+ await navigateTo(safePath(route.query.goto), { replace: true })
36
+ } catch (e: any) {
37
+ appStore.notify(e.data?.message || e.message, 'error')
38
+ } finally {
39
+ isLoading.value = false
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div class="page" />
46
+ <div
47
+ class="absolute top w p2 flex-row flex-center"
48
+ style="z-index: 99999"
49
+ >
50
+ <I18nLink
51
+ :to="{ name: 'index' }"
52
+ style="color: var(--g-bg-contrast)"
53
+ >
54
+ <img
55
+ v-if="config.public.logo"
56
+ :src="config.public.logo"
57
+ :alt="config.public.name"
58
+ height="42"
59
+ class="fg"
60
+ />
61
+ <span v-else>{{ config.public.name }}</span>
62
+ </I18nLink>
63
+ <LocaleSwitcher class="ml" />
64
+ </div>
65
+ <AuthCheck @authenticated="navigateTo(safePath(route.query.goto), { replace: true })">
66
+ <template #header>
67
+ <h1 class="text-center">{{ $t('authCheck.signin') }}</h1>
68
+ </template>
69
+ <template #footer>
70
+ <div
71
+ v-if="config.public.oauth.microsoft || config.public.anonymousSignup"
72
+ class="flex-column"
73
+ >
74
+ <span class="text-center muted-text my1">{{ $t('or') }}</span>
75
+ <a
76
+ v-if="config.public.oauth.microsoft"
77
+ href="/auth/microsoft"
78
+ class="provider b flex-center g2 mb1"
79
+ >
80
+ <img
81
+ src="/images/auth/microsoft.svg"
82
+ alt=""
83
+ width="21"
84
+ height="21"
85
+ />
86
+ {{ $t('signinWith') }} Microsoft
87
+ </a>
88
+ <button
89
+ v-if="config.public.anonymousSignup"
90
+ type="button"
91
+ :class="['w', isLoading ? 'spin' : '']"
92
+ style="
93
+ background: none;
94
+ border: none;
95
+ color: inherit;
96
+ font-weight: normal;
97
+ font-size: 14px;
98
+ "
99
+ :disabled="isLoading"
100
+ @click="signInAnonymously"
101
+ >
102
+ {{ $t('continueAsGuest') }}
103
+ </button>
104
+ </div>
105
+ </template>
106
+ </AuthCheck>
107
+ </template>
108
+
109
+ <style scoped>
110
+ .page {
111
+ box-shadow: inset 0 0 40vh var(--g-fg);
112
+ height: 100dvh;
113
+ }
114
+
115
+ .provider {
116
+ height: 40px;
117
+ border-radius: var(--g-border-radius);
118
+ font-size: 14px;
119
+ color: inherit;
120
+ }
121
+ </style>