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.
- package/README.md +270 -2
- package/docker/Dockerfile +22 -0
- package/docker/Dockerfile.dockerignore +25 -0
- package/docker/compose.yaml +19 -0
- package/layers/base/app/app.vue +22 -0
- package/layers/base/app/components/AuthCheck.vue +316 -0
- package/layers/base/app/components/DataExplorer.vue +38 -0
- package/layers/base/app/components/LocaleSwitcher.vue +28 -0
- package/layers/base/app/components/TheConfirmation.vue +48 -0
- package/layers/base/app/components/TheLoading.vue +36 -0
- package/layers/base/app/components/TheNotifications.vue +71 -0
- package/layers/base/app/components/files/FilesDelete.vue +33 -0
- package/layers/base/app/components/files/FilesUpload.vue +85 -0
- package/layers/base/app/error.vue +28 -0
- package/layers/base/app/layouts/default.vue +15 -0
- package/layers/base/app/layouts/simple.vue +3 -0
- package/layers/base/app/middleware/admin.ts +15 -0
- package/layers/base/app/middleware/authenticated.ts +10 -0
- package/layers/base/app/middleware/mfa.global.ts +8 -0
- package/layers/base/app/pages/admin/logs.vue +364 -0
- package/layers/base/app/pages/signin.vue +121 -0
- package/layers/base/app/pages/user/auth.vue +509 -0
- package/layers/base/app/plugins/error.ts +21 -0
- package/layers/base/app/plugins/session.ts +22 -0
- package/layers/base/app/stores/app.ts +100 -0
- package/layers/base/app/utils/clipboard.ts +6 -0
- package/layers/base/app/utils/files.ts +95 -0
- package/layers/base/app/utils/image.ts +33 -0
- package/layers/base/nuxt.config.ts +146 -0
- package/layers/base/public/images/auth/microsoft.svg +1 -0
- package/layers/base/public/robots.txt +2 -0
- package/layers/base/server/api/admin/logs.get.ts +49 -0
- package/layers/base/server/api/auth/anonymous.post.ts +31 -0
- package/layers/base/server/api/auth/email/get.post.ts +46 -0
- package/layers/base/server/api/auth/email/verify.post.ts +46 -0
- package/layers/base/server/api/auth/index.get.ts +10 -0
- package/layers/base/server/api/auth/index.post.ts +19 -0
- package/layers/base/server/api/auth/options.post.ts +26 -0
- package/layers/base/server/api/auth/otp/get.post.ts +74 -0
- package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
- package/layers/base/server/api/auth/password.post.ts +42 -0
- package/layers/base/server/api/auth/refresh.delete.ts +4 -0
- package/layers/base/server/api/auth/refresh.post.ts +5 -0
- package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
- package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
- package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
- package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
- package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
- package/layers/base/server/api/files/[...path].get.ts +37 -0
- package/layers/base/server/api/files/chunk.post.ts +36 -0
- package/layers/base/server/api/files/copy.post.ts +22 -0
- package/layers/base/server/api/files/index.delete.ts +12 -0
- package/layers/base/server/api/files/index.get.ts +19 -0
- package/layers/base/server/api/files/index.post.ts +15 -0
- package/layers/base/server/api/log.post.ts +20 -0
- package/layers/base/server/database/defaults/access.ts +14 -0
- package/layers/base/server/database/defaults/db.ts +13 -0
- package/layers/base/server/database/defaults/relations.ts +5 -0
- package/layers/base/server/database/relations.ts +35 -0
- package/layers/base/server/database/schema.ts +66 -0
- package/layers/base/server/middleware/0.rateLimit.ts +21 -0
- package/layers/base/server/middleware/1.session.ts +14 -0
- package/layers/base/server/middleware/2.mfa.ts +34 -0
- package/layers/base/server/middleware/3.admin.ts +4 -0
- package/layers/base/server/plugins/session.ts +9 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
- package/layers/base/server/routes/dev/email/[template].ts +15 -0
- package/layers/base/server/tasks/backup.ts +42 -0
- package/layers/base/server/tasks/clean.ts +39 -0
- package/layers/base/server/utils/access.ts +8 -0
- package/layers/base/server/utils/auth.ts +207 -0
- package/layers/base/server/utils/backup.ts +98 -0
- package/layers/base/server/utils/db.ts +3 -0
- package/layers/base/server/utils/email.ts +138 -0
- package/layers/base/server/utils/files.ts +751 -0
- package/layers/base/server/utils/ip.ts +15 -0
- package/layers/base/server/utils/log.ts +31 -0
- package/layers/base/server/utils/rateLimit.ts +69 -0
- package/layers/base/server/utils/schemas.ts +8 -0
- package/layers/base/server/utils/text.ts +4 -0
- package/layers/base/server/utils/time.ts +3 -0
- package/layers/base/shared/types/auth.d.ts +22 -0
- package/layers/base/shared/utils/logs.ts +9 -0
- package/layers/base/shared/utils/url.ts +5 -0
- package/locales/en.json +33 -0
- package/locales/fr.json +33 -0
- package/locales/pages/admin-logs/en.json +1 -0
- package/locales/pages/admin-logs/fr.json +1 -0
- package/locales/pages/index/en.json +1 -0
- package/locales/pages/index/fr.json +1 -0
- package/locales/pages/signin/en.json +5 -0
- package/locales/pages/signin/fr.json +5 -0
- package/locales/pages/user-auth/en.json +22 -0
- package/locales/pages/user-auth/fr.json +22 -0
- package/nuxt.config.ts +54 -0
- package/package.json +92 -4
- package/scripts/db/migrate.ts +9 -0
- package/scripts/db/push-server.sh +45 -0
- package/scripts/db/restore.sh +26 -0
- package/scripts/deploy.sh +34 -0
- package/scripts/env.sh +20 -0
- package/scripts/remote/switch.sh +48 -0
- package/scripts/rollback.sh +12 -0
- package/server/assets/emails/base.html +43 -0
- package/server/assets/emails/en/base.html +1 -0
- package/server/assets/emails/en/emailChange.html +4 -0
- package/server/assets/emails/en/emailChanged.html +2 -0
- package/server/assets/emails/en/otp.html +10 -0
- package/server/assets/emails/fr/base.html +1 -0
- package/server/assets/emails/fr/emailChange.html +4 -0
- package/server/assets/emails/fr/emailChanged.html +2 -0
- package/server/assets/emails/fr/otp.html +10 -0
package/README.md
CHANGED
|
@@ -1,3 +1,271 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Nuxt base
|
|
2
2
|
|
|
3
|
-
|
|
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>
|