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
package/README.md CHANGED
@@ -1,3 +1,271 @@
1
- # Temporary Holding Version
1
+ # Nuxt base
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Nuxt layer with auth, files, logs and backups, meant to be extended by other projects.
4
+
5
+ ## Dev
6
+
7
+ Setup your env file by editing the `.env.example` file and rename it to `.env`.
8
+
9
+ ### Email customisation
10
+
11
+ Use the route `http://localhost:3000/dev/email/[templateId]` to see the email while you edit. You can put the locale or any parameter in the query.
12
+
13
+ ## Deployment
14
+
15
+ ### On your server
16
+
17
+ Create the Docker `shared-network` (`docker network create shared-network || true`).
18
+
19
+ Start the postgres and drizzle-gateway dockers from the `remote-db` folder to manage your DB.
20
+ Use the addDB script to create a new DB (`./addDB.sh {db-name} {username} {password}`).
21
+
22
+ Create a folder for your project where you will deploy the website and add your env file with the production values.
23
+
24
+ Setup nginx serve your app and your public files folder or s3 public url.
25
+
26
+ #### Serve s3 folder
27
+
28
+ ```
29
+ location /files/ {
30
+ rewrite ^/files/(.*) /object/v1/AUTH_123/bucket/$1 break;
31
+
32
+ # 2. Proxy the S3 endpoint
33
+ proxy_pass https://s3.pub2.infomaniak.cloud;
34
+
35
+ # 3. Essential headers for SSL S3 connection
36
+ proxy_set_header Host s3.pub2.infomaniak.cloud;
37
+ proxy_ssl_server_name on;
38
+
39
+ # Optional: Hide S3 headers for security
40
+ proxy_hide_header x-amz-request-id;
41
+ proxy_hide_header x-amz-id-2;
42
+ }
43
+ ```
44
+
45
+ #### Server server folder
46
+
47
+ ```
48
+ location /files/ {
49
+ alias /home/debian/project/volumes/files/public/;
50
+ }
51
+ ```
52
+
53
+ ### On your local machine
54
+
55
+ Run `pnpm prod` to deploy the website. To deploy with another env file, run `pnpm prod -e .env.{name}`.
56
+
57
+ It builds with the `Dockerfile` and `compose.yaml` at the root of your project, or the
58
+ package's ones in `docker/` when you have none. To customise them, copy them from
59
+ `node_modules/maxibase/docker`, along with `Dockerfile.dockerignore` renamed to `.dockerignore`.
60
+
61
+ Each build is tagged `{utc-timestamp}-{commit}` and the last 5 stay on the server.
62
+
63
+ Roll back with `pnpm rollback` (the version before the live one), `pnpm rollback {tag}`, or
64
+ list what is available with `pnpm rollback --list`. Rolling back does not undo migrations.
65
+
66
+ Push your migration through SSH with `pnpm db:push-prod`.
67
+
68
+ In a project that uses the layer, these scripts run from the package, see [Scripts](#scripts).
69
+
70
+ ### Run one instance
71
+
72
+ Rate limit counters and the `auth` storage that holds OTPs and email-change codes both
73
+ live in the process memory (`nitro.storage.auth` is the `memory` driver). A second
74
+ container, or node in cluster mode, gets its own copy: rate limits are multiplied by the
75
+ number of instances, and an OTP verified on the instance that did not issue it fails.
76
+
77
+ Scale up only after pointing both at shared storage — swap the `auth` storage driver for
78
+ Redis and move `enforceRateLimit`'s map to the same place.
79
+
80
+ ## Backups
81
+
82
+ The `backup` task dumps the DB to `backups/` every night and uploads it to the private S3
83
+ bucket when one is configured. `clean` drops both copies past `NUXT_BACKUP_RETENTION_DAYS`.
84
+
85
+ ### Encrypting them
86
+
87
+ A dump is every row you have in one portable file, so the offsite copy is worth encrypting.
88
+ Set `NUXT_BACKUP_AGE_PUBLIC_KEY` and backups become `.dump.age`, encrypted with
89
+ [age](https://github.com/FiloSottile/age).
90
+
91
+ It is public-key encryption on purpose: the server holds only the public key, so it can write
92
+ backups but cannot read any of them back, and a compromise of the app or of the bucket yields
93
+ ciphertext. Never reuse the postgres password for this, it sits in the same env as the S3
94
+ credentials, so an attacker who reaches the backups already has it.
95
+
96
+ ```sh
97
+ age-keygen -o key.txt # prints the public key, keep key.txt off the server
98
+ ```
99
+
100
+ Put the `age1...` public key in the server's `.env` and store `key.txt` in a password manager
101
+ plus one offline copy. **Lose it and every backup is unrecoverable**, so restore one now to
102
+ check the whole chain works:
103
+
104
+ ```sh
105
+ pnpm db:restore backups/backup-....dump.age key.txt
106
+ ```
107
+
108
+ ### Restoring
109
+
110
+ Backups include the schema, so restore into a database straight from `addDB.sh` (use the same user and password as the old DB):
111
+
112
+ ```sh
113
+ ./remote-db/postgres/addDB.sh backup
114
+ ```
115
+
116
+ Then restore with `pnpm db:restore`. It restores into the `NUXT_DB` of your `.env`, or of
117
+ another env file with `-e`, so keep an env file whose `NUXT_DB` points at the new DB:
118
+
119
+ ```sh
120
+ pnpm db:restore -e .env.backup backups/backup-....dump.age key.txt # encrypted backup
121
+ pnpm db:restore -e .env.backup backups/backup-....dump # unencrypted backup
122
+ ```
123
+
124
+ To roll a database back, restore into a new one and swap the names. Stop the app first, the rename needs zero connections. Restore as the app user, or the tables end up owned by
125
+ `postgres` and the app gets `permission denied`:
126
+
127
+ ```sh
128
+ psql -c "alter database app rename to old;" \
129
+ -c "alter database backup rename to app;"
130
+ ```
131
+
132
+ ## Nuxt layer
133
+
134
+ This repo is published on npm as [`maxibase`](https://www.npmjs.com/package/maxibase). The
135
+ root of the repo is an example project: copy it to start a new project, then edit what you
136
+ need.
137
+
138
+ ### Using it in a project
139
+
140
+ ```sh
141
+ pnpm add maxibase
142
+ ```
143
+
144
+ ```ts
145
+ // nuxt.config.ts
146
+ export default defineNuxtConfig({
147
+ extends: ['maxibase'],
148
+ })
149
+ ```
150
+
151
+ ### What ships in the package
152
+
153
+ | Path | Role |
154
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
155
+ | `layers/base` | the layer itself: pages, components, API routes, utils, shared types. Not meant to be edited |
156
+ | `nuxt.config.ts` | default route rules, rate limits, i18n and head config. Your config overrides it |
157
+ | `locales` | default translations. Your `locales` files are merged on top, key by key |
158
+ | `server/assets/emails` | default email templates. A template in your `server/assets/emails` replaces the package's one with the same path |
159
+ | `scripts` | deploy, rollback and DB scripts, run from `node_modules` (see `package.json` below) |
160
+ | `docker` | default `Dockerfile` and `compose.yaml` for `pnpm prod`, used when your project has none |
161
+
162
+ New translations, templates and config defaults in a new version reach every project with
163
+ `pnpm update maxibase`, without copying anything.
164
+
165
+ To keep a single language, disable the other one in your config:
166
+
167
+ ```ts
168
+ i18n: { locales: [{ code: 'fr' }, { code: 'en', disabled: true }], defaultLocale: 'fr' }
169
+ ```
170
+
171
+ ### Starting a project
172
+
173
+ Copy only these files from this repo, the rest comes from the package:
174
+
175
+ | Path | Notes |
176
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------- |
177
+ | `nuxt.config.ts` | replace `extends: ['./layers/base']` with `extends: ['maxibase']` |
178
+ | `package.json` | use the one below instead |
179
+ | `tsconfig.json`, `pnpm-workspace.yaml`, `.gitignore` | as they are |
180
+ | `.env.example` | fill it in and rename it to `.env` |
181
+ | `drizzle.config.ts` | used by the `db:*` scripts |
182
+ | `server/database/schema.ts` | re-exports the layer's tables, add yours below |
183
+ | `app/assets/css/index.css` | required by the `css` entry of the package config |
184
+ | `app/components/TheHeader.vue`, `TheFooter.vue` | required by the `default` layout |
185
+ | `app/pages/index.vue` | replace the example with your home page, the layer links to the `index` route |
186
+
187
+ And only if you need them:
188
+
189
+ | Path | Notes |
190
+ | ------------------------------------------- | -------------------------------------------------------------------------- |
191
+ | `server/database/relations.ts`, `access.ts` | organizations, along with `server/utils/organizations.ts` and their tables |
192
+ | `server/database/seed.ts` | for `db:seed` |
193
+ | `.oxlintrc.json`, `.oxfmtrc.json` | lint and format config |
194
+
195
+ Organizations come as a set: the `organizations` and `organization_members` tables in `schema.ts`,
196
+ `relations.ts`, `access.ts` and `server/utils/organizations.ts`. Keep all of them or none.
197
+
198
+ Don't copy `locales`, `server/assets/emails`, `scripts` or `docker`, they ship with the package. A
199
+ copied translation or email template replaces the package's one and stops receiving its updates,
200
+ so copy only the keys or templates you change. `layers`, `test`, `vitest.config.ts`, `remote-db`
201
+ and `.github` are for this repo only (the CI runs scripts a project's `package.json` doesn't have).
202
+
203
+ The tables of the `auth` schema ship with the layer, in `layers/base/server/database/schema.ts`.
204
+ Your `server/database/schema.ts` re-exports them and adds your own tables, so `pnpm update maxibase`
205
+ brings their changes: run `pnpm db:generate` after an update. To customise one, copy it into your
206
+ schema, a table declared in your file takes precedence over the package's one with the same name.
207
+ Their relations come from `authRelations` in `layers/base/server/database/relations.ts`. To add a
208
+ relation to one of these tables, define the table in your `relations.ts`: your entry replaces the
209
+ layer's for that table, so repeat the layer's relations you still need there.
210
+
211
+ `db.ts`, `relations.ts` and `access.ts` in `server/database` are optional, the layer has a default
212
+ for each in `layers/base/server/database/defaults`: no relations of your own, and file access to
213
+ the user's own folder `u/{id}` or to admins. Add the file to replace the default, then restart the
214
+ dev server. The migrate script is in the package too, see `db:migrate` below.
215
+
216
+ A project's `package.json` looks like this. Add any package your own files import: pnpm only lets a project import its direct dependencies.
217
+
218
+ ```json
219
+ {
220
+ "name": "my-project",
221
+ "private": true,
222
+ "type": "module",
223
+ "scripts": {
224
+ "build": "nuxt build",
225
+ "dev": "nuxt dev",
226
+ "preview": "nuxt preview",
227
+ "postinstall": "nuxt prepare",
228
+ "prod": "bash node_modules/maxibase/scripts/deploy.sh",
229
+ "rollback": "bash node_modules/maxibase/scripts/rollback.sh",
230
+ "db:seed": "npx jiti ./server/database/seed.ts",
231
+ "db:generate": "drizzle-kit generate",
232
+ "db:push": "drizzle-kit push",
233
+ "db:push-prod": "bash node_modules/maxibase/scripts/db/push-server.sh",
234
+ "db:restore": "bash node_modules/maxibase/scripts/db/restore.sh",
235
+ "db:migrate": "npx jiti node_modules/maxibase/scripts/db/migrate.ts",
236
+ "db:studio": "drizzle-kit studio",
237
+ "lint": "oxlint",
238
+ "fmt": "oxfmt",
239
+ "typecheck": "nuxt typecheck"
240
+ },
241
+ "dependencies": {
242
+ "drizzle-orm": "1.0.0-rc.3",
243
+ "goku-css": "^9.0.8",
244
+ "maxibase": "^1.0.0",
245
+ "nuxt": "^4.6.0"
246
+ },
247
+ "devDependencies": {
248
+ "@types/node": "^24.0.0",
249
+ "drizzle-kit": "1.0.0-rc.3",
250
+ "drizzle-seed": "1.0.0-rc.3",
251
+ "oxfmt": "^0.72.0",
252
+ "oxlint": "^1.87.0",
253
+ "typescript": "^5.9.3",
254
+ "vue-tsc": "^3.3.12"
255
+ }
256
+ }
257
+ ```
258
+
259
+ ### Publishing a new version
260
+
261
+ The first release goes from `0.0.0` with `npm version major`, which tags it `v1.0.0`.
262
+ `prepublishOnly` runs lint, format check, typecheck and tests before anything is uploaded.
263
+
264
+ ```sh
265
+ npm version patch # or minor, or major for a change projects must adapt to
266
+ npm publish
267
+ git push --follow-tags
268
+ ```
269
+
270
+ Then run `pnpm update maxibase` in each project. Projects depend on `^1.0.0`, so they only
271
+ move to a new major version when you change that range.
@@ -0,0 +1,22 @@
1
+ FROM node:24-alpine AS build
2
+
3
+ RUN corepack enable && corepack prepare pnpm@latest --activate
4
+
5
+ WORKDIR /app
6
+
7
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
8
+
9
+ RUN pnpm install --frozen-lockfile
10
+
11
+ COPY . ./
12
+
13
+ RUN pnpm build
14
+
15
+ FROM node:24-alpine
16
+ WORKDIR /app
17
+
18
+ RUN apk add --no-cache postgresql-client age
19
+
20
+ COPY --from=build /app/.output/ ./
21
+
22
+ CMD ["node", "./server/index.mjs"]
@@ -0,0 +1,25 @@
1
+ **/.classpath
2
+ **/.dockerignore
3
+ **/.env
4
+ **/.git
5
+ **/.gitignore
6
+ **/.vscode
7
+ **/.cache
8
+ **/.output
9
+ **/.nuxt
10
+ **/.firebase
11
+ **/*.log
12
+ **/docker-compose*
13
+ **/compose.y*ml
14
+ **/Dockerfile*
15
+ **/node_modules
16
+ **/build
17
+ **/dist
18
+ **/functions
19
+ LICENSE
20
+ README.md
21
+
22
+ .env*
23
+ remote-db
24
+ scripts
25
+ workflows
@@ -0,0 +1,19 @@
1
+ services:
2
+ nuxt-app:
3
+ image: ${PROJECT_NAME}:${TAG:-latest}
4
+ restart: unless-stopped
5
+ env_file:
6
+ - .env
7
+ volumes:
8
+ - ./volumes/files/public:/app/${NUXT_FILES_PUBLIC_FOLDER:-files/public}
9
+ - ./volumes/files/private:/app/${NUXT_FILES_PRIVATE_FOLDER:-files/private}
10
+ - ./volumes/backups:/app/backups
11
+ ports:
12
+ - ${SERVER_PORT:-3000}:3000
13
+ networks:
14
+ - shared-network
15
+
16
+ networks:
17
+ shared-network:
18
+ external: true
19
+ name: shared-network
@@ -0,0 +1,22 @@
1
+ <script script setup lang="ts">
2
+ const config = useRuntimeConfig()
3
+ const { $t } = useI18n()
4
+ useSeoMeta({
5
+ title: config.public.name,
6
+ ogTitle: config.public.name,
7
+ description: $t('description') as string,
8
+ ogDescription: $t('description') as string,
9
+ })
10
+
11
+ const appStore = useAppStore()
12
+ </script>
13
+
14
+ <template>
15
+ <NuxtLayout>
16
+ <NuxtPage />
17
+ </NuxtLayout>
18
+ <LazyTheConfirmation />
19
+ <LazyTheLoading />
20
+ <LazyTheNotifications />
21
+ <LazyAuthCheck v-if="appStore.authPromise" />
22
+ </template>
@@ -0,0 +1,316 @@
1
+ <script setup lang="ts">
2
+ const emits = defineEmits(['authenticated', 'cancel'])
3
+ const appStore = useAppStore()
4
+ const { $t } = useI18n()
5
+ const { loggedIn, user, fetch: fetchUserSession } = useUserSession()
6
+ const config = useRuntimeConfig()
7
+ const route = useRoute()
8
+ const email = ref('')
9
+ const password = ref('')
10
+ const otpRequested = ref(false)
11
+ const totp = ref('')
12
+ const optionsFetched = ref(false)
13
+ const showPassword = ref(false)
14
+ const options = ref({
15
+ hasOTP: true,
16
+ hasPassword: false,
17
+ hasTOTP: false,
18
+ hasPasskey: false,
19
+ })
20
+ let isLoading = ref()
21
+
22
+ async function refreshSession() {
23
+ await fetchUserSession()
24
+ if (loggedIn.value) {
25
+ appStore.authPromise?.resolve(true)
26
+ emits('authenticated')
27
+ }
28
+ email.value = ''
29
+ }
30
+
31
+ async function requestOtp() {
32
+ if (!email.value) return
33
+ isLoading.value = true
34
+ try {
35
+ await $fetch('/api/auth/otp/get', {
36
+ method: 'POST',
37
+ body: {
38
+ email: email.value,
39
+ locale: useI18n().$getLocale(),
40
+ goto: route.query.goto,
41
+ path: route.path,
42
+ },
43
+ })
44
+ otpRequested.value = true
45
+ } catch (e: any) {
46
+ handleError(e)
47
+ } finally {
48
+ isLoading.value = false
49
+ }
50
+ }
51
+
52
+ async function signInWithOtp() {
53
+ isLoading.value = true
54
+ try {
55
+ await $fetch('/api/auth/otp/verify', {
56
+ method: 'POST',
57
+ body: { email: email.value, otp: password.value },
58
+ })
59
+ await refreshSession()
60
+ } catch (e: any) {
61
+ handleError(e)
62
+ } finally {
63
+ otpRequested.value = false
64
+ isLoading.value = false
65
+ }
66
+ }
67
+
68
+ async function signInWithTOTP() {
69
+ if (!email.value || !totp.value || !password.value) return
70
+ isLoading.value = true
71
+ try {
72
+ const body: {
73
+ email: string
74
+ token: string
75
+ otp?: string
76
+ password?: string
77
+ } = { email: email.value, token: totp.value }
78
+ if (password.value.length === 6 || password.value === route.query.token) {
79
+ body.otp = password.value
80
+ } else body.password = password.value
81
+ await $fetch('/api/auth/totp/verify', {
82
+ method: 'POST',
83
+ body,
84
+ })
85
+ await refreshSession()
86
+ totp.value = ''
87
+ } catch (e: any) {
88
+ handleError(e)
89
+ } finally {
90
+ isLoading.value = false
91
+ }
92
+ }
93
+
94
+ async function signInWithPassword() {
95
+ if (!email.value || !password.value) return
96
+ isLoading.value = true
97
+ try {
98
+ await $fetch('/api/auth/password', {
99
+ method: 'POST',
100
+ body: { email: email.value, password: password.value },
101
+ })
102
+ await refreshSession()
103
+ password.value = ''
104
+ } catch (e: any) {
105
+ handleError(e)
106
+ } finally {
107
+ isLoading.value = false
108
+ }
109
+ }
110
+
111
+ function handleError(error: any) {
112
+ appStore.notify(error.data?.message || 'Bad credentials', 'error')
113
+ }
114
+
115
+ function signIn() {
116
+ if (!email.value) return
117
+ if (options.value.hasTOTP && !totp.value) return
118
+ setTimeout(() => {
119
+ if (totp.value) signInWithTOTP()
120
+ else if (!password.value) requestOtp()
121
+ else if (password.value.length === 6) signInWithOtp()
122
+ else signInWithPassword()
123
+ }, 100) // wait for paste event to complete
124
+ }
125
+
126
+ const { authenticate } = useWebAuthn({
127
+ registerEndpoint: '/api/auth/webauthn/register',
128
+ authenticateEndpoint: '/api/auth/webauthn/authenticate',
129
+ })
130
+
131
+ async function signInWithPasskey() {
132
+ try {
133
+ await authenticate(email.value)
134
+ await refreshSession()
135
+ } catch (e: any) {
136
+ if (!e.message.startsWith('The operation either timed out')) {
137
+ handleError(e)
138
+ }
139
+ }
140
+ }
141
+
142
+ async function getSignInOptions(email: string) {
143
+ isLoading.value = true
144
+ try {
145
+ options.value = await $fetch<typeof options.value>('/api/auth/options', {
146
+ method: 'POST',
147
+ body: { email },
148
+ })
149
+ if (route.query.token) {
150
+ password.value = route.query.token as string
151
+ if (!options.value.hasTOTP) await signInWithOtp()
152
+ } else if (options.value.hasPasskey) {
153
+ await signInWithPasskey()
154
+ } else if (!options.value.hasPassword) {
155
+ await requestOtp()
156
+ }
157
+ optionsFetched.value = true
158
+ } catch (e: any) {
159
+ handleError(e)
160
+ } finally {
161
+ isLoading.value = false
162
+ }
163
+ }
164
+
165
+ async function pastePassword() {
166
+ try {
167
+ password.value = (await navigator.clipboard.readText()).trim()
168
+ signIn()
169
+ } catch (e) {
170
+ appStore.notify($t('authCheck.clipboardPermission') as string, 'error')
171
+ }
172
+ }
173
+
174
+ onMounted(async () => {
175
+ email.value = user.value?.email || (route.query.email as string) || ''
176
+ if (email.value) {
177
+ await getSignInOptions(email.value)
178
+ }
179
+ })
180
+ </script>
181
+
182
+ <template>
183
+ <div
184
+ class="modal"
185
+ @click.self="emits('cancel')"
186
+ >
187
+ <div class="signin p3 mx">
188
+ <slot name="header"></slot>
189
+ <form
190
+ v-if="optionsFetched"
191
+ class="flex-column g2"
192
+ @submit.prevent="signIn"
193
+ >
194
+ <input
195
+ v-show="false"
196
+ type="email"
197
+ :value="email"
198
+ autocomplete="username email"
199
+ />
200
+ <label for="password">
201
+ {{
202
+ options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
203
+ }}
204
+ </label>
205
+ <div class="group flex-row">
206
+ <input
207
+ id="password"
208
+ v-model.trim="password"
209
+ :type="showPassword ? 'text' : 'password'"
210
+ :disabled="isLoading"
211
+ autocomplete="current-password"
212
+ :pattern="
213
+ otpRequested
214
+ ? '[0-9]{6}'
215
+ : `.{${config.public.password.min},${config.public.password.max}}`
216
+ "
217
+ @paste="signIn"
218
+ />
219
+ <button
220
+ type="button"
221
+ class="flex-center fg"
222
+ @click="showPassword = !showPassword"
223
+ >
224
+ <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
225
+ </button>
226
+ <button
227
+ type="button"
228
+ class="flex-center fg"
229
+ :title="$t('paste') as string"
230
+ @click="pastePassword"
231
+ >
232
+ <Icon name="lucide:clipboard-paste" />
233
+ </button>
234
+ </div>
235
+ <div v-if="options.hasTOTP">
236
+ <label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
237
+ <input
238
+ id="totp"
239
+ v-model.trim="totp"
240
+ type="text"
241
+ :disabled="isLoading || !password"
242
+ autocomplete="one-time-code"
243
+ @paste="signInWithTOTP"
244
+ @change="signInWithTOTP"
245
+ />
246
+ </div>
247
+ <button
248
+ type="submit"
249
+ :disabled="isLoading || (!password && !otpRequested) || (options.hasTOTP && !totp)"
250
+ :class="['w mt1', isLoading ? 'spin' : '']"
251
+ >
252
+ {{ $t('authCheck.signin') }}
253
+ </button>
254
+ <button
255
+ v-if="!otpRequested"
256
+ type="button"
257
+ :disabled="isLoading"
258
+ :class="['flex-1 w mt1 bg bg-border', isLoading ? 'spin' : '']"
259
+ @click="requestOtp"
260
+ >
261
+ {{ $t('authCheck.getCode') }}
262
+ </button>
263
+ <p
264
+ v-if="otpRequested"
265
+ class="text-center"
266
+ >
267
+ {{ $t('authCheck.otpSent', { email }) }}
268
+ </p>
269
+ </form>
270
+ <form
271
+ v-else
272
+ class="flex-column g2"
273
+ @submit.prevent="getSignInOptions(email)"
274
+ >
275
+ <label for="email">
276
+ {{ $t('email') }}
277
+ <input
278
+ id="email"
279
+ v-model.trim="email"
280
+ type="email"
281
+ required
282
+ :disabled="isLoading"
283
+ autocomplete="username"
284
+ />
285
+ </label>
286
+ <button
287
+ :class="['w', isLoading ? 'spin' : '']"
288
+ :disabled="isLoading || !email.includes('@')"
289
+ >
290
+ {{ $t('continue') }}
291
+ </button>
292
+ </form>
293
+ <slot
294
+ v-if="!optionsFetched"
295
+ name="footer"
296
+ ></slot>
297
+ <button
298
+ v-if="appStore.authPromise"
299
+ type="button"
300
+ class="mt2 w"
301
+ style="background: none; border: none; color: inherit"
302
+ :disabled="isLoading"
303
+ @click="(appStore.authPromise.resolve(false), emits('cancel'))"
304
+ >
305
+ {{ $t('cancel') }}
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </template>
310
+
311
+ <style scoped>
312
+ .signin {
313
+ max-width: min(400px, 80vw);
314
+ min-width: 300px;
315
+ }
316
+ </style>