@voltro/cli 0.28.0 → 0.29.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.
- package/CHANGELOG.md +278 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
- package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
- package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/whats-new.md +162 -181
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — auth surface
|
|
2
|
+
|
|
3
|
+
A drop-in set of auth pages other apps in the project link to, so the login UI
|
|
4
|
+
lives in ONE place. Every page is a real form POSTing to the sibling api's
|
|
5
|
+
`/auth/*` routes (from [`@voltro/plugin-auth`](https://voltro.cloud/docs/plugins/auth))
|
|
6
|
+
with CSRF. Bilingual (en/de).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
voltro init my-app --api=api-auth --web=frontend-auth
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Pages
|
|
13
|
+
|
|
14
|
+
| URL | Route it drives | Purpose |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `/login` | `POST /auth/sign-in` | email + password |
|
|
17
|
+
| `/signup` | `POST /auth/sign-up` | register + auto sign-in (with a strength meter) |
|
|
18
|
+
| `/forgot` | `POST /auth/password-reset` | request a reset link (neutral 202) |
|
|
19
|
+
| `/reset?token=` | `POST /auth/password-reset/confirm` | set a new password |
|
|
20
|
+
| `/magic` | `POST /auth/magic-link` | request a passwordless sign-in link |
|
|
21
|
+
| `/verify?token=` | `POST /auth/magic-link/callback` | consume the link / confirm email |
|
|
22
|
+
| `/logout` | `POST /auth/sign-out` | revoke the session, back to /login |
|
|
23
|
+
|
|
24
|
+
The emailed reset link points at `/reset?token=…`; the magic / verification link
|
|
25
|
+
points at `/verify?token=…`. Delivery needs `sendEmail` wired on the api (via
|
|
26
|
+
`@voltro/plugin-mail`) — without it those routes still mint tokens and return
|
|
27
|
+
`202`, they just don't send.
|
|
28
|
+
|
|
29
|
+
## Safety built in
|
|
30
|
+
|
|
31
|
+
- **Open-redirect guard** (`lib/redirect.ts`) — after sign-in we only honour a
|
|
32
|
+
same-origin, absolute-path `?next=`; a cross-origin or protocol-relative value
|
|
33
|
+
falls back to `AUTH_HOME`. Unit-tested.
|
|
34
|
+
- **CSRF** — `lib/auth.ts` fetches a token from `GET /auth/csrf` and echoes it on
|
|
35
|
+
every state-changing POST.
|
|
36
|
+
- **No account enumeration** — `/forgot` and `/magic` always show the same
|
|
37
|
+
neutral "check your email" message.
|
|
38
|
+
|
|
39
|
+
## Files
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
app.config.ts web app; apis.auth → @{{projectName}}/api
|
|
43
|
+
src/lib/
|
|
44
|
+
auth.ts csrfToken() + postAuth() over /auth/*
|
|
45
|
+
redirect.ts(.test) safeNext() open-redirect guard (unit-tested)
|
|
46
|
+
src/components/
|
|
47
|
+
AuthShell.tsx centered card + brand + locale switcher
|
|
48
|
+
PasswordStrength.tsx advisory 0–4 meter
|
|
49
|
+
src/pages/
|
|
50
|
+
page.tsx / → redirect to /login
|
|
51
|
+
login/ · signup/ · forgot/ · reset/ · magic/ · verify/ · logout/
|
|
52
|
+
src/locales/{en,de}.ts bilingual catalogs (parity-enforced)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Opt-in: standalone auth subdomain (shared-cookie SSO)
|
|
56
|
+
|
|
57
|
+
The default is a **path-based** auth surface mounted inside your app (`/login`,
|
|
58
|
+
`/signup`, …) — cookies are same-origin and nothing extra is needed. If instead
|
|
59
|
+
you want a dedicated `auth.acme.dev` that signs a cookie for the whole
|
|
60
|
+
`.acme.dev` family (so `app.acme.dev` and `cms.acme.dev` share one session),
|
|
61
|
+
deploy this app on its own subdomain and set the api's cookie domain
|
|
62
|
+
(`authRoutesPlugin({ cookieDomain: '.acme.dev' })`). The pages are identical; only
|
|
63
|
+
the deploy topology + cookie domain change.
|
|
64
|
+
|
|
65
|
+
## Opt-in: OAuth / SSO providers
|
|
66
|
+
|
|
67
|
+
The base `@voltro/plugin-auth` ships password + magic-link + reset. Third-party
|
|
68
|
+
sign-in (Google, GitHub, WorkOS, Auth0, Kinde, Supabase, generic OIDC) is a
|
|
69
|
+
SEPARATE provider plugin (`@voltro/plugin-auth-*`) with its own callback routes —
|
|
70
|
+
add the plugin on the api, then render its buttons here. We don't ship dead
|
|
71
|
+
OAuth buttons wired to routes that don't exist.
|
|
72
|
+
|
|
73
|
+
## Out of scope (V2)
|
|
74
|
+
|
|
75
|
+
- 2FA / passkeys — `@voltro/plugin-auth` has the primitives (`/auth/mfa/*`,
|
|
76
|
+
`/auth/passkey/*`); the pages follow.
|
|
77
|
+
- Bot defence (hCaptcha / Turnstile) — behind an env flag, render the widget only
|
|
78
|
+
when configured.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Web app for the {{projectName}} project — the AUTH surface. Every page is a
|
|
2
|
+
// static form that POSTs to the sibling api's `/auth/*` routes (contributed by
|
|
3
|
+
// @voltro/plugin-auth), which set the HttpOnly session cookie. Other apps in the
|
|
4
|
+
// project (dashboard, cms, portal) link here for sign-in so the auth UI lives in
|
|
5
|
+
// ONE place.
|
|
6
|
+
//
|
|
7
|
+
// The `apis` mapping names the api whose `/auth/*` routes the dev/serve pipeline
|
|
8
|
+
// proxies to and whose typed rpc surface codegen pulls (this template calls the
|
|
9
|
+
// HTTP routes directly, so it needs no rpc — the mapping is what wires the proxy
|
|
10
|
+
// + the shared origin the session cookie is set on).
|
|
11
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
12
|
+
|
|
13
|
+
export const env = defineEnv({
|
|
14
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
15
|
+
// Where a successful sign-in lands when the request carries no safelisted
|
|
16
|
+
// `?next=`. Public (build-time) — never a secret in the browser bundle.
|
|
17
|
+
VOLTRO_PUBLIC_AUTH_HOME: envVar.string({ access: 'public', default: '/' }),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
type: 'web' as const,
|
|
22
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
23
|
+
port: {{port}},
|
|
24
|
+
theme: 'system' as const,
|
|
25
|
+
env,
|
|
26
|
+
// Bilingual by default (cookie-driven <I18nProvider>, SSR too).
|
|
27
|
+
locales: ['en', 'de'] as const,
|
|
28
|
+
defaultLocale: 'en' as const,
|
|
29
|
+
// The api whose /auth/* routes back these pages. Pair with the api-auth
|
|
30
|
+
// template: `voltro init my-app --api=api-auth --web=frontend-auth`.
|
|
31
|
+
apis: {
|
|
32
|
+
auth: { package: '@{{projectName}}/api' },
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"build": "voltro build .",
|
|
10
|
+
"start": "voltro start .",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "voltro test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@{{projectName}}/api": "0.29.0",
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/client": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/i18n": "0.29.0",
|
|
20
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
21
|
+
"@voltro/web": "0.29.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@voltro/devtools": "0.29.0",
|
|
27
|
+
"@types/react": "^19.0.0",
|
|
28
|
+
"@types/react-dom": "^19.0.0",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The centered auth card every page renders inside — brand, title, the form
|
|
2
|
+
// (children), an optional message, a footer link row, and the locale switcher.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
5
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
6
|
+
import { APP_NAME } from '../config'
|
|
7
|
+
|
|
8
|
+
const LOCALES = [
|
|
9
|
+
{ code: 'en', label: 'English' },
|
|
10
|
+
{ code: 'de', label: 'Deutsch' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export interface AuthShellProps {
|
|
14
|
+
readonly titleId: string
|
|
15
|
+
readonly children: ReactNode
|
|
16
|
+
readonly footer?: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AuthShell({ titleId, children, footer }: AuthShellProps): ReactNode {
|
|
20
|
+
const locale = useLocale()
|
|
21
|
+
const langLabel = useT('lang.label')
|
|
22
|
+
return (
|
|
23
|
+
<div className="auth-wrap">
|
|
24
|
+
<div className="auth-card">
|
|
25
|
+
<div className="auth-head">
|
|
26
|
+
<span className="brand">{APP_NAME}</span>
|
|
27
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
28
|
+
</div>
|
|
29
|
+
<h1><T id={titleId} /></h1>
|
|
30
|
+
{children}
|
|
31
|
+
{footer ? <div className="auth-footer">{footer}</div> : null}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A tiny, dependency-free password strength meter. Purely advisory client-side
|
|
2
|
+
// UX — the api is the authority on password policy. Scores 0–4 on length +
|
|
3
|
+
// character-class variety and renders a labelled bar.
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { useT } from '@voltro/i18n'
|
|
6
|
+
|
|
7
|
+
const score = (pw: string): number => {
|
|
8
|
+
if (pw.length === 0) return 0
|
|
9
|
+
let s = 0
|
|
10
|
+
if (pw.length >= 8) s++
|
|
11
|
+
if (pw.length >= 12) s++
|
|
12
|
+
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++
|
|
13
|
+
if (/\d/.test(pw) && /[^A-Za-z0-9]/.test(pw)) s++
|
|
14
|
+
return Math.min(s, 4)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const LEVEL_KEYS = ['pw.weak', 'pw.weak', 'pw.fair', 'pw.good', 'pw.strong'] as const
|
|
18
|
+
|
|
19
|
+
export function PasswordStrength({ value }: { readonly value: string }): ReactNode {
|
|
20
|
+
const s = score(value)
|
|
21
|
+
const label = useT(LEVEL_KEYS[s] ?? 'pw.weak')
|
|
22
|
+
if (value.length === 0) return null
|
|
23
|
+
return (
|
|
24
|
+
<div className="pw-meter" aria-hidden={false}>
|
|
25
|
+
<div className="pw-bars">
|
|
26
|
+
{[0, 1, 2, 3].map((i) => (
|
|
27
|
+
<span key={i} className={i < s ? `bar on lvl${s}` : 'bar'} />
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
<span className="pw-label">{label}</span>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Brand + defaults shown across the auth pages.
|
|
2
|
+
//
|
|
3
|
+
// Plain constants — not `publicEnv` — so the template typechecks BEFORE
|
|
4
|
+
// `voltro dev` generates the env types. `VOLTRO_PUBLIC_APP_NAME` /
|
|
5
|
+
// `VOLTRO_PUBLIC_AUTH_HOME` are still declared in app.config.ts (validated + in
|
|
6
|
+
// the env manifest); to read them typed instead, after one `voltro dev` swap
|
|
7
|
+
// these for `publicEnv.VOLTRO_PUBLIC_*` from '@voltro/env/public'.
|
|
8
|
+
export const APP_NAME = '{{capProjectName}}'
|
|
9
|
+
|
|
10
|
+
// Where a successful sign-in lands when no safelisted `?next=` is present.
|
|
11
|
+
export const AUTH_HOME = '/'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/* Plain CSS — no Tailwind, so there's no @source scanner to configure. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #ffffff;
|
|
4
|
+
--fg: #0a0a0a;
|
|
5
|
+
--muted: #6b7280;
|
|
6
|
+
--accent: #4f46e5;
|
|
7
|
+
--danger: #dc2626;
|
|
8
|
+
--border: #e5e7eb;
|
|
9
|
+
--panel: #f9fafb;
|
|
10
|
+
}
|
|
11
|
+
:root.dark {
|
|
12
|
+
--bg: #0a0a0a;
|
|
13
|
+
--fg: #fafafa;
|
|
14
|
+
--muted: #9ca3af;
|
|
15
|
+
--accent: #818cf8;
|
|
16
|
+
--danger: #f87171;
|
|
17
|
+
--border: #1f2937;
|
|
18
|
+
--panel: #111827;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--fg);
|
|
26
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
27
|
+
line-height: 1.55;
|
|
28
|
+
}
|
|
29
|
+
a { color: inherit; }
|
|
30
|
+
.muted { color: var(--muted); }
|
|
31
|
+
.spacer { flex: 1; }
|
|
32
|
+
|
|
33
|
+
/* Buttons + inputs */
|
|
34
|
+
.btn {
|
|
35
|
+
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
|
36
|
+
background: var(--accent); color: #fff; padding: 0.55rem 1rem;
|
|
37
|
+
font: inherit; font-weight: 600; text-decoration: none;
|
|
38
|
+
}
|
|
39
|
+
.btn:disabled { opacity: 0.6; cursor: default; }
|
|
40
|
+
.btn.ghost { background: transparent; color: var(--accent); border: 1px solid var(--border); }
|
|
41
|
+
.link { background: none; border: 0; color: var(--accent); cursor: pointer; font: inherit; padding: 0; }
|
|
42
|
+
input {
|
|
43
|
+
font: inherit; padding: 0.5rem 0.65rem; border: 1px solid var(--border);
|
|
44
|
+
border-radius: 8px; background: var(--bg); color: var(--fg); min-width: 0;
|
|
45
|
+
}
|
|
46
|
+
.error-text { color: var(--danger); }
|
|
47
|
+
|
|
48
|
+
/* Marketing */
|
|
49
|
+
.marketing header {
|
|
50
|
+
display: flex; align-items: center; gap: 1rem;
|
|
51
|
+
padding: 1rem 1.5rem; border-bottom: 1px solid var(--border);
|
|
52
|
+
}
|
|
53
|
+
.marketing .brand { font-weight: 700; text-decoration: none; }
|
|
54
|
+
.hero { max-width: 40rem; margin: 0 auto; padding: 3rem 1.5rem; }
|
|
55
|
+
.hero h1 { margin: 0 0 0.75rem; }
|
|
56
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.85rem; max-width: 22rem; margin-top: 1.25rem; }
|
|
57
|
+
.auth-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; }
|
|
58
|
+
|
|
59
|
+
/* Dashboard shell */
|
|
60
|
+
.dash { display: grid; grid-template-columns: 15rem 1fr; min-height: 100vh; }
|
|
61
|
+
.dash aside { border-right: 1px solid var(--border); padding: 1.25rem; background: var(--panel); }
|
|
62
|
+
.dash aside .brand { font-weight: 700; margin-bottom: 1rem; }
|
|
63
|
+
.dash nav { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
64
|
+
.dash nav a { text-decoration: none; color: var(--muted); padding: 0.35rem 0; }
|
|
65
|
+
.dash nav a[aria-current="page"] { color: var(--fg); font-weight: 600; }
|
|
66
|
+
.dash main { padding: 1.5rem 2rem; }
|
|
67
|
+
.topbar { display: flex; align-items: center; margin-bottom: 1rem; }
|
|
68
|
+
@media (max-width: 40rem) { .dash { grid-template-columns: 1fr; } .dash aside { border-right: 0; border-bottom: 1px solid var(--border); } }
|
|
69
|
+
|
|
70
|
+
/* Bits */
|
|
71
|
+
.row { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; margin-top: 0.5rem; }
|
|
72
|
+
.empty { color: var(--muted); margin-top: 1.5rem; }
|
|
73
|
+
.cards { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: 0.75rem; }
|
|
74
|
+
.card { border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; background: var(--panel); }
|
|
75
|
+
.card .value { font-weight: 600; }
|
|
76
|
+
.card .label { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
|
|
77
|
+
.paywall { border: 1px solid var(--accent); border-radius: 12px; padding: 1rem 1.25rem; margin-top: 1.25rem; background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
78
|
+
.paywall p { margin: 0.35rem 0 0.75rem; }
|
|
79
|
+
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; margin-top: 1rem; }
|
|
80
|
+
.facts dt { color: var(--muted); }
|
|
81
|
+
.facts dd { margin: 0; font-weight: 600; }
|
|
82
|
+
.rows { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
|
83
|
+
.rows li { display: flex; justify-content: space-between; align-items: center; border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.9rem; background: var(--panel); }
|
|
84
|
+
.tag { font-size: 0.8rem; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.55rem; }
|
|
85
|
+
code { background: color-mix(in oklab, var(--fg) 8%, transparent); padding: 0.1em 0.35em; border-radius: 4px; }
|
|
86
|
+
|
|
87
|
+
/* Auth card */
|
|
88
|
+
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.5rem; }
|
|
89
|
+
.auth-card { width: 100%; max-width: 24rem; border: 1px solid var(--border); border-radius: 14px; background: var(--panel); padding: 1.5rem 1.5rem 1.25rem; }
|
|
90
|
+
.auth-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; }
|
|
91
|
+
.auth-head .brand { font-weight: 700; }
|
|
92
|
+
.auth-card h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
|
93
|
+
.auth-card .auth-form { max-width: none; margin-top: 0; }
|
|
94
|
+
.auth-footer { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; margin-top: 1.1rem; padding-top: 0.9rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
|
|
95
|
+
.auth-footer a { color: var(--accent); text-decoration: none; }
|
|
96
|
+
.ok { color: var(--accent); }
|
|
97
|
+
|
|
98
|
+
/* Password strength meter */
|
|
99
|
+
.pw-meter { display: flex; align-items: center; gap: 0.5rem; margin-top: -0.25rem; }
|
|
100
|
+
.pw-bars { display: flex; gap: 3px; flex: 1; }
|
|
101
|
+
.pw-bars .bar { height: 5px; flex: 1; border-radius: 3px; background: var(--border); }
|
|
102
|
+
.pw-bars .bar.on { background: var(--danger); }
|
|
103
|
+
.pw-bars .bar.on.lvl3 { background: #d97706; }
|
|
104
|
+
.pw-bars .bar.on.lvl4 { background: var(--accent); }
|
|
105
|
+
.pw-label { font-size: 0.8rem; color: var(--muted); min-width: 3.2rem; text-align: right; }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Ambient declarations.
|
|
2
|
+
//
|
|
3
|
+
// `declare module '*.css'`: TypeScript with `moduleResolution: Bundler` rejects
|
|
4
|
+
// a bare `import './globals.css'` unless the asset module is declared. Vite
|
|
5
|
+
// resolves it at build time; the declaration is type-only.
|
|
6
|
+
declare module '*.css'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Thin wrappers over the api's `/auth/*` HTTP routes (@voltro/plugin-auth).
|
|
2
|
+
//
|
|
3
|
+
// State-changing auth routes are CSRF-protected (double-submit): `GET /auth/csrf`
|
|
4
|
+
// sets a cookie AND returns a token that must be echoed in the `x-csrf-token`
|
|
5
|
+
// header on the following POST. `postAuth` does that round-trip so each page is
|
|
6
|
+
// a one-liner. These call the SAME-ORIGIN api (the dev/serve pipeline proxies
|
|
7
|
+
// `/auth/*` to the api named in app.config), so the HttpOnly session cookie the
|
|
8
|
+
// api sets lands on this origin.
|
|
9
|
+
|
|
10
|
+
export interface AuthResponse {
|
|
11
|
+
readonly ok: boolean
|
|
12
|
+
readonly status: number
|
|
13
|
+
readonly data: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Fetch a CSRF token (also drops the csrf cookie into the jar). */
|
|
17
|
+
export const csrfToken = async (): Promise<string> => {
|
|
18
|
+
const res = await fetch('/auth/csrf')
|
|
19
|
+
const body = (await res.json()) as { csrfToken?: string }
|
|
20
|
+
return body.csrfToken ?? ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** POST a JSON body to an `/auth/*` route with the CSRF token attached. */
|
|
24
|
+
export const postAuth = async (path: string, body: Record<string, unknown>): Promise<AuthResponse> => {
|
|
25
|
+
const token = await csrfToken()
|
|
26
|
+
const res = await fetch(path, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': token },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
})
|
|
31
|
+
let data: unknown = null
|
|
32
|
+
try { data = await res.json() } catch { /* some routes return 202 with no body */ }
|
|
33
|
+
return { ok: res.ok, status: res.status, data }
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Pure unit test of the open-redirect guard — no DOM needed. The safelist is
|
|
2
|
+
// the security-load-bearing part of the auth flow, so it gets a direct test.
|
|
3
|
+
import { describe, expect, test } from 'vitest'
|
|
4
|
+
import { safeNext } from './redirect'
|
|
5
|
+
|
|
6
|
+
describe('safeNext', () => {
|
|
7
|
+
test('passes a same-origin absolute path through', () => {
|
|
8
|
+
expect(safeNext('/dashboard', '/')).toBe('/dashboard')
|
|
9
|
+
expect(safeNext('/a/b?x=1', '/')).toBe('/a/b?x=1')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('falls back to home for a missing value', () => {
|
|
13
|
+
expect(safeNext(null, '/home')).toBe('/home')
|
|
14
|
+
expect(safeNext(undefined, '/home')).toBe('/home')
|
|
15
|
+
expect(safeNext('', '/home')).toBe('/home')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('rejects cross-origin + protocol-relative + absolute URLs', () => {
|
|
19
|
+
expect(safeNext('https://evil.example/login', '/')).toBe('/')
|
|
20
|
+
expect(safeNext('//evil.example', '/')).toBe('/')
|
|
21
|
+
expect(safeNext('/\\evil.example', '/')).toBe('/')
|
|
22
|
+
expect(safeNext('javascript:alert(1)', '/')).toBe('/')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Open-redirect guard for the `?next=` parameter.
|
|
2
|
+
//
|
|
3
|
+
// After sign-in we send the browser to wherever it was headed — but a `next=`
|
|
4
|
+
// an attacker controls is a phishing vector (`?next=https://evil.example/login`
|
|
5
|
+
// that looks like us). So we accept ONLY same-origin, absolute-PATH targets:
|
|
6
|
+
// a value must start with a single `/` and NOT with `//` (a protocol-relative
|
|
7
|
+
// URL like `//evil.example` that browsers treat as cross-origin). Anything else
|
|
8
|
+
// falls back to `home`.
|
|
9
|
+
|
|
10
|
+
/** The safelisted redirect target, or `home` when `next` is missing/unsafe. */
|
|
11
|
+
export const safeNext = (next: string | null | undefined, home: string): string => {
|
|
12
|
+
if (!next) return home
|
|
13
|
+
// Must be an absolute path, and not protocol-relative (`//host`) or a
|
|
14
|
+
// backslash-smuggled variant (`/\evil`).
|
|
15
|
+
if (!next.startsWith('/') || next.startsWith('//') || next.startsWith('/\\')) return home
|
|
16
|
+
return next
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read + safelist `?next=` from the current URL (browser only). */
|
|
20
|
+
export const nextFromLocation = (home: string): string => {
|
|
21
|
+
if (typeof window === 'undefined') return home
|
|
22
|
+
return safeNext(new URLSearchParams(window.location.search).get('next'), home)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read a `?token=` from the current URL (browser only). */
|
|
26
|
+
export const tokenFromLocation = (): string | null => {
|
|
27
|
+
if (typeof window === 'undefined') return null
|
|
28
|
+
return new URLSearchParams(window.location.search).get('token')
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// German catalog. Mirrors every key in `en.ts` — `defineLocale<typeof en>()`
|
|
2
|
+
// fails the build if a key is missing. Translate the prose only.
|
|
3
|
+
import { defineLocale } from '@voltro/i18n'
|
|
4
|
+
import en from './en'
|
|
5
|
+
|
|
6
|
+
export default defineLocale<typeof en>()({
|
|
7
|
+
'meta.login.title': 'Anmelden',
|
|
8
|
+
'meta.signup.title': 'Konto erstellen',
|
|
9
|
+
'meta.forgot.title': 'Passwort zurücksetzen',
|
|
10
|
+
'meta.reset.title': 'Neues Passwort festlegen',
|
|
11
|
+
'meta.magic.title': 'Magischer Link',
|
|
12
|
+
'meta.verify.title': 'Wird geprüft',
|
|
13
|
+
'meta.logout.title': 'Abmeldung',
|
|
14
|
+
|
|
15
|
+
'lang.label': 'Sprache',
|
|
16
|
+
|
|
17
|
+
'auth.email': 'E-Mail',
|
|
18
|
+
'auth.password': 'Passwort',
|
|
19
|
+
'auth.newPassword': 'Neues Passwort',
|
|
20
|
+
'auth.pending': 'Bitte warten…',
|
|
21
|
+
'auth.error': 'Das hat nicht geklappt. Prüfe deine Angaben und versuche es erneut.',
|
|
22
|
+
|
|
23
|
+
'login.title': 'Anmelden',
|
|
24
|
+
'login.submit': 'Anmelden',
|
|
25
|
+
'login.forgot': 'Passwort vergessen?',
|
|
26
|
+
'login.magic': 'Link per E-Mail senden',
|
|
27
|
+
'login.toSignup': 'Konto erstellen',
|
|
28
|
+
|
|
29
|
+
'signup.title': 'Konto erstellen',
|
|
30
|
+
'signup.submit': 'Konto erstellen',
|
|
31
|
+
'signup.toLogin': 'Konto vorhanden? Anmelden',
|
|
32
|
+
|
|
33
|
+
'forgot.title': 'Passwort zurücksetzen',
|
|
34
|
+
'forgot.body': 'Gib deine E-Mail ein und wir senden dir einen Link zum Zurücksetzen.',
|
|
35
|
+
'forgot.submit': 'Link senden',
|
|
36
|
+
'forgot.sent': 'Falls zu dieser E-Mail ein Konto existiert, ist ein Link unterwegs.',
|
|
37
|
+
'forgot.toLogin': '← Zurück zur Anmeldung',
|
|
38
|
+
|
|
39
|
+
'reset.title': 'Neues Passwort festlegen',
|
|
40
|
+
'reset.body': 'Wähle ein neues Passwort für dein Konto.',
|
|
41
|
+
'reset.submit': 'Passwort aktualisieren',
|
|
42
|
+
'reset.done': 'Dein Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.',
|
|
43
|
+
'reset.failed': 'Dieser Link ist ungültig oder abgelaufen. Fordere einen neuen an.',
|
|
44
|
+
'reset.toLogin': '← Zurück zur Anmeldung',
|
|
45
|
+
|
|
46
|
+
'magic.title': 'Mit einem Link anmelden',
|
|
47
|
+
'magic.body': 'Wir senden dir einen einmaligen Anmeldelink — kein Passwort nötig.',
|
|
48
|
+
'magic.submit': 'Link senden',
|
|
49
|
+
'magic.sent': 'Falls zu dieser E-Mail ein Konto existiert, ist ein Anmeldelink unterwegs.',
|
|
50
|
+
'magic.toLogin': '← Zurück zur Anmeldung',
|
|
51
|
+
|
|
52
|
+
'verify.title': 'Dein Link wird geprüft',
|
|
53
|
+
'verify.pending': 'Einen Moment, wir melden dich an…',
|
|
54
|
+
'verify.success': 'Geschafft — du wirst weitergeleitet…',
|
|
55
|
+
'verify.failed': 'Dieser Link ist ungültig oder abgelaufen. Fordere einen neuen an.',
|
|
56
|
+
'verify.noToken': 'Diesem Link fehlt sein Token. Fordere einen neuen an.',
|
|
57
|
+
'verify.toLogin': '← Zurück zur Anmeldung',
|
|
58
|
+
|
|
59
|
+
'logout.title': 'Abmeldung',
|
|
60
|
+
'logout.body': 'Du wirst abgemeldet…',
|
|
61
|
+
|
|
62
|
+
'pw.weak': 'Schwach',
|
|
63
|
+
'pw.fair': 'Mittel',
|
|
64
|
+
'pw.good': 'Gut',
|
|
65
|
+
'pw.strong': 'Stark',
|
|
66
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Base catalog — the source of truth for this app's UI strings. Every key here
|
|
2
|
+
// MUST be mirrored in `de.ts`; `defineLocale<typeof en>()` enforces that parity
|
|
3
|
+
// at build time.
|
|
4
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
5
|
+
|
|
6
|
+
export default defineCatalog({
|
|
7
|
+
// Browser tab titles — locale-aware via each page's `meta({ locale })`.
|
|
8
|
+
'meta.login.title': 'Sign in',
|
|
9
|
+
'meta.signup.title': 'Create account',
|
|
10
|
+
'meta.forgot.title': 'Reset password',
|
|
11
|
+
'meta.reset.title': 'Set a new password',
|
|
12
|
+
'meta.magic.title': 'Magic link',
|
|
13
|
+
'meta.verify.title': 'Verifying',
|
|
14
|
+
'meta.logout.title': 'Signing out',
|
|
15
|
+
|
|
16
|
+
'lang.label': 'Language',
|
|
17
|
+
|
|
18
|
+
// ---- Shared field labels ----
|
|
19
|
+
'auth.email': 'Email',
|
|
20
|
+
'auth.password': 'Password',
|
|
21
|
+
'auth.newPassword': 'New password',
|
|
22
|
+
'auth.pending': 'Please wait…',
|
|
23
|
+
'auth.error': 'That didn’t work. Check your details and try again.',
|
|
24
|
+
|
|
25
|
+
// ---- Sign in ----
|
|
26
|
+
'login.title': 'Sign in',
|
|
27
|
+
'login.submit': 'Sign in',
|
|
28
|
+
'login.forgot': 'Forgot password?',
|
|
29
|
+
'login.magic': 'Email me a link',
|
|
30
|
+
'login.toSignup': 'Create an account',
|
|
31
|
+
|
|
32
|
+
// ---- Sign up ----
|
|
33
|
+
'signup.title': 'Create your account',
|
|
34
|
+
'signup.submit': 'Create account',
|
|
35
|
+
'signup.toLogin': 'Have an account? Sign in',
|
|
36
|
+
|
|
37
|
+
// ---- Forgot password ----
|
|
38
|
+
'forgot.title': 'Reset your password',
|
|
39
|
+
'forgot.body': 'Enter your email and we’ll send you a reset link.',
|
|
40
|
+
'forgot.submit': 'Send reset link',
|
|
41
|
+
'forgot.sent': 'If that email has an account, a reset link is on its way.',
|
|
42
|
+
'forgot.toLogin': '← Back to sign in',
|
|
43
|
+
|
|
44
|
+
// ---- Reset password ----
|
|
45
|
+
'reset.title': 'Set a new password',
|
|
46
|
+
'reset.body': 'Choose a new password for your account.',
|
|
47
|
+
'reset.submit': 'Update password',
|
|
48
|
+
'reset.done': 'Your password has been updated. You can sign in now.',
|
|
49
|
+
'reset.failed': 'That reset link is invalid or has expired. Request a new one.',
|
|
50
|
+
'reset.toLogin': '← Back to sign in',
|
|
51
|
+
|
|
52
|
+
// ---- Magic link ----
|
|
53
|
+
'magic.title': 'Sign in with a link',
|
|
54
|
+
'magic.body': 'We’ll email you a one-time sign-in link — no password needed.',
|
|
55
|
+
'magic.submit': 'Send me a link',
|
|
56
|
+
'magic.sent': 'If that email has an account, a sign-in link is on its way.',
|
|
57
|
+
'magic.toLogin': '← Back to sign in',
|
|
58
|
+
|
|
59
|
+
// ---- Verify (link consumer) ----
|
|
60
|
+
'verify.title': 'Verifying your link',
|
|
61
|
+
'verify.pending': 'One moment while we sign you in…',
|
|
62
|
+
'verify.success': 'You’re in — redirecting…',
|
|
63
|
+
'verify.failed': 'That link is invalid or has expired. Request a new one.',
|
|
64
|
+
'verify.noToken': 'This link is missing its token. Request a new one.',
|
|
65
|
+
'verify.toLogin': '← Back to sign in',
|
|
66
|
+
|
|
67
|
+
// ---- Logout ----
|
|
68
|
+
'logout.title': 'Signing out',
|
|
69
|
+
'logout.body': 'Signing you out…',
|
|
70
|
+
|
|
71
|
+
// ---- Password strength meter ----
|
|
72
|
+
'pw.weak': 'Weak',
|
|
73
|
+
'pw.fair': 'Fair',
|
|
74
|
+
'pw.good': 'Good',
|
|
75
|
+
'pw.strong': 'Strong',
|
|
76
|
+
} as const)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Catalog lookup OUTSIDE React — for a page's `meta({ locale })` locale-aware
|
|
2
|
+
// <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
|
|
3
|
+
// hands `meta` the active locale from the `voltro:lang` cookie. Inside React use
|
|
4
|
+
// `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
|
|
5
|
+
import en from './en'
|
|
6
|
+
import de from './de'
|
|
7
|
+
|
|
8
|
+
// Both catalogs share `en`'s keys (defineLocale enforces the mirror), so a
|
|
9
|
+
// known-key lookup returns `string` — which is what `PageMeta.title` requires
|
|
10
|
+
// under exactOptionalPropertyTypes.
|
|
11
|
+
export type Messages = Record<keyof typeof en, string>
|
|
12
|
+
|
|
13
|
+
export const getCatalog = (locale?: string): Messages =>
|
|
14
|
+
(locale === 'de' ? de : en) as Messages
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Forgot password — URL `/forgot`. POST /auth/password-reset { email } always
|
|
2
|
+
// returns 202 (no account enumeration — the same "check your email" whether or
|
|
3
|
+
// not the address exists). The api emails a link to `/reset?token=…` (delivery
|
|
4
|
+
// needs `sendEmail` wired on the api; without it the route still 202s).
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { useState, type FormEvent } from 'react'
|
|
7
|
+
import type { PageMeta } from '@voltro/web'
|
|
8
|
+
import { T, useT } from '@voltro/i18n'
|
|
9
|
+
import { AuthShell } from '../../components/AuthShell'
|
|
10
|
+
import { postAuth } from '../../lib/auth'
|
|
11
|
+
import { getCatalog } from '../../locales'
|
|
12
|
+
|
|
13
|
+
export const renderMode = 'static' as const
|
|
14
|
+
|
|
15
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
16
|
+
title: getCatalog(locale)['meta.forgot.title'],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export default function Forgot(): ReactNode {
|
|
20
|
+
const [email, setEmail] = useState('')
|
|
21
|
+
const [pending, setPending] = useState(false)
|
|
22
|
+
const [sent, setSent] = useState(false)
|
|
23
|
+
const emailLabel = useT('auth.email')
|
|
24
|
+
|
|
25
|
+
const onSubmit = (e: FormEvent): void => {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
setPending(true)
|
|
28
|
+
void postAuth('/auth/password-reset', { email })
|
|
29
|
+
.then(() => { setSent(true); setPending(false) })
|
|
30
|
+
.catch(() => { setSent(true); setPending(false) }) // still show the neutral message
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<AuthShell titleId="forgot.title" footer={<a href="/login"><T id="forgot.toLogin" /></a>}>
|
|
35
|
+
{sent ? (
|
|
36
|
+
<p className="ok" role="status"><T id="forgot.sent" /></p>
|
|
37
|
+
) : (
|
|
38
|
+
<form className="auth-form" onSubmit={onSubmit}>
|
|
39
|
+
<p className="muted"><T id="forgot.body" /></p>
|
|
40
|
+
<label>
|
|
41
|
+
<span><T id="auth.email" /></span>
|
|
42
|
+
<input type="email" required aria-label={emailLabel} value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
43
|
+
</label>
|
|
44
|
+
<button type="submit" className="btn" disabled={pending}>
|
|
45
|
+
<T id={pending ? 'auth.pending' : 'forgot.submit'} />
|
|
46
|
+
</button>
|
|
47
|
+
</form>
|
|
48
|
+
)}
|
|
49
|
+
</AuthShell>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Root layout — wraps EVERY page (marketing + dashboard). Thin: it loads the
|
|
2
|
+
// stylesheet and lets each AREA layout ((marketing)/layout.tsx,
|
|
3
|
+
// dashboard/layout.tsx) supply its own chrome. Layouts nest, so a dashboard
|
|
4
|
+
// page is wrapped root → dashboard.
|
|
5
|
+
//
|
|
6
|
+
// Don't render <html>/<head>/<body> here — the framework shell owns them.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import '../globals.css'
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
11
|
+
return <>{children}</>
|
|
12
|
+
}
|