@voltro/cli 0.27.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.
Files changed (284) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
  12. package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
  13. package/dist/dev-DeEJj5T4.js +3 -0
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
  25. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  26. package/dist/serveEntry.js +3 -3
  27. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.md +2 -1
  31. package/templates/agent-docs/_index.md +2 -1
  32. package/templates/agent-docs/_manifest.json +10 -1
  33. package/templates/agent-docs/ai.md +135 -0
  34. package/templates/agent-docs/cli.md +124 -6
  35. package/templates/agent-docs/configuration.md +32 -0
  36. package/templates/agent-docs/data.md +282 -0
  37. package/templates/agent-docs/database/migrations.md +47 -0
  38. package/templates/agent-docs/database/misc.md +57 -0
  39. package/templates/agent-docs/database/schema.md +3 -1
  40. package/templates/agent-docs/deployment.md +56 -0
  41. package/templates/agent-docs/internationalization.md +161 -1
  42. package/templates/agent-docs/local-first-mobile.md +414 -0
  43. package/templates/agent-docs/routing.md +93 -2
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/templates/apibackends.md +1 -1
  46. package/templates/agent-docs/testing.md +42 -0
  47. package/templates/agent-docs/whats-new.md +165 -131
  48. package/templates/agent-docs/workflows.md +11 -0
  49. package/templates/apps/api-ai/package.json +7 -7
  50. package/templates/apps/api-auth/package.json +8 -8
  51. package/templates/apps/api-backend/package.json +7 -7
  52. package/templates/apps/api-backend-deactivation/package.json +7 -7
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/package.json +9 -9
  55. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  56. package/templates/apps/api-backend-sqlite/README.md +38 -0
  57. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  58. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  59. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  60. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  61. package/templates/apps/api-backend-sqlite/package.json +30 -0
  62. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  63. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  64. package/templates/apps/api-backend-sqlite/template.json +6 -0
  65. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  66. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  67. package/templates/apps/api-backend-storage/package.json +8 -8
  68. package/templates/apps/api-cms/README.md +104 -0
  69. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  70. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  71. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  72. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  73. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  74. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  75. package/templates/apps/api-cms/app.config.ts +61 -0
  76. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  77. package/templates/apps/api-cms/content/index.ts +18 -0
  78. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  79. package/templates/apps/api-cms/database/schema.ts +64 -0
  80. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  81. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  82. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  83. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  84. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  85. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  86. package/templates/apps/api-cms/package.json +32 -0
  87. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  88. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  89. package/templates/apps/api-cms/template.json +6 -0
  90. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  91. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  92. package/templates/apps/api-cms/tsconfig.json +5 -0
  93. package/templates/apps/api-data-advanced/package.json +8 -8
  94. package/templates/apps/api-durable/package.json +8 -8
  95. package/templates/apps/api-feature-flags/package.json +9 -9
  96. package/templates/apps/api-governance/package.json +8 -8
  97. package/templates/apps/api-kv/package.json +8 -8
  98. package/templates/apps/api-moderation/package.json +8 -8
  99. package/templates/apps/api-observability/package.json +8 -8
  100. package/templates/apps/api-ratelimit/package.json +8 -8
  101. package/templates/apps/api-rbac/package.json +8 -8
  102. package/templates/apps/api-rest/package.json +7 -7
  103. package/templates/apps/api-saas/package.json +11 -11
  104. package/templates/apps/api-saas-starter/README.md +103 -0
  105. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  106. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  107. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  108. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  109. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  110. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  111. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  112. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  113. package/templates/apps/api-saas-starter/package.json +32 -0
  114. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  115. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  116. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  117. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  118. package/templates/apps/api-saas-starter/template.json +6 -0
  119. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  120. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  121. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/README.md +88 -0
  124. package/templates/apps/api-status/app.config.ts +36 -0
  125. package/templates/apps/api-status/authz.ts +33 -0
  126. package/templates/apps/api-status/database/schema.ts +70 -0
  127. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  128. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  129. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  130. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  131. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  132. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  133. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  134. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  135. package/templates/apps/api-status/package.json +30 -0
  136. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  137. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  138. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  139. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  140. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  141. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  142. package/templates/apps/api-status/template.json +6 -0
  143. package/templates/apps/api-status/tests/status.test.ts +70 -0
  144. package/templates/apps/api-status/tsconfig.json +5 -0
  145. package/templates/apps/api-versioning/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/package.json +6 -6
  148. package/templates/apps/edge-functions/package.json +2 -2
  149. package/templates/apps/frontend-admin/package.json +8 -8
  150. package/templates/apps/frontend-app/package.json +8 -8
  151. package/templates/apps/frontend-auth/README.md +78 -0
  152. package/templates/apps/frontend-auth/app.config.ts +34 -0
  153. package/templates/apps/frontend-auth/package.json +32 -0
  154. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  155. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  156. package/templates/apps/frontend-auth/src/config.ts +11 -0
  157. package/templates/apps/frontend-auth/src/globals.css +105 -0
  158. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  159. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  160. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  161. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  162. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  163. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  164. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  165. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  166. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  167. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  168. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  169. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  170. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  171. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  172. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  173. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  174. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  175. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  176. package/templates/apps/frontend-auth/template.json +6 -0
  177. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  178. package/templates/apps/frontend-blank/package.json +7 -7
  179. package/templates/apps/frontend-cms/README.md +47 -0
  180. package/templates/apps/frontend-cms/app.config.ts +37 -0
  181. package/templates/apps/frontend-cms/package.json +33 -0
  182. package/templates/apps/frontend-cms/src/config.ts +8 -0
  183. package/templates/apps/frontend-cms/src/globals.css +105 -0
  184. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  185. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  186. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  187. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  188. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  192. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  193. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  194. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  195. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  196. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  197. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  198. package/templates/apps/frontend-cms/template.json +6 -0
  199. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  200. package/templates/apps/frontend-contact/package.json +7 -7
  201. package/templates/apps/frontend-dashboard/package.json +7 -7
  202. package/templates/apps/frontend-docs/package.json +7 -7
  203. package/templates/apps/frontend-i18n/package.json +6 -6
  204. package/templates/apps/frontend-landing/package.json +7 -7
  205. package/templates/apps/frontend-portal/README.md +71 -0
  206. package/templates/apps/frontend-portal/app.config.ts +37 -0
  207. package/templates/apps/frontend-portal/package.json +32 -0
  208. package/templates/apps/frontend-portal/src/config.ts +8 -0
  209. package/templates/apps/frontend-portal/src/globals.css +93 -0
  210. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  212. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  213. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  214. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  223. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  224. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  225. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  226. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  227. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  228. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  229. package/templates/apps/frontend-portal/template.json +6 -0
  230. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  231. package/templates/apps/frontend-saas/README.md +73 -0
  232. package/templates/apps/frontend-saas/app.config.ts +46 -0
  233. package/templates/apps/frontend-saas/package.json +32 -0
  234. package/templates/apps/frontend-saas/src/config.ts +8 -0
  235. package/templates/apps/frontend-saas/src/globals.css +85 -0
  236. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  237. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  238. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  239. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  240. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  242. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  243. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  244. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  250. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  251. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  252. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  253. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  254. package/templates/apps/frontend-saas/template.json +6 -0
  255. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  256. package/templates/apps/frontend-spa/package.json +7 -7
  257. package/templates/apps/frontend-ssr/package.json +7 -7
  258. package/templates/apps/frontend-ssr-api/package.json +8 -8
  259. package/templates/apps/frontend-static-blog/package.json +6 -6
  260. package/templates/apps/frontend-status/README.md +51 -0
  261. package/templates/apps/frontend-status/app.config.ts +39 -0
  262. package/templates/apps/frontend-status/package.json +32 -0
  263. package/templates/apps/frontend-status/src/config.ts +7 -0
  264. package/templates/apps/frontend-status/src/globals.css +70 -0
  265. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  266. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  267. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  268. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  269. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  270. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  271. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  272. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  273. package/templates/apps/frontend-status/template.json +6 -0
  274. package/templates/apps/frontend-status/tsconfig.json +11 -0
  275. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  276. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  277. package/dist/apiBuild-BrjrVJJh.js +0 -2
  278. package/dist/dbCommand-DrzXimKf.js +0 -2
  279. package/dist/dev-DNkso403.js +0 -3
  280. package/dist/fileConventions-3bffWssN.js +0 -30
  281. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  282. package/dist/inspect-gt8bq-Tz.js +0 -2
  283. package/dist/manifestBuild-ifczArzr.js +0 -2
  284. package/dist/serveCommand-DfkisVWP.js +0 -1310
@@ -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
+ }