@voltro/cli 0.28.0 → 0.30.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 +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -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 +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -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-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/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/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- 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/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- 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-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- 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/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- 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/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- 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,124 @@
|
|
|
1
|
+
// API keys — URL `/api-keys`. Drives the framework's BUILT-IN key management
|
|
2
|
+
// surface (`GET/POST /v1/api-keys*`), which the api exposes when it sets
|
|
3
|
+
// `apiKeys: true`. Keys are hash-stored; the raw token is shown exactly ONCE at
|
|
4
|
+
// mint time and is unrecoverable after.
|
|
5
|
+
//
|
|
6
|
+
// This surface is admin-scoped and off by default, so the page DEGRADES: a 404
|
|
7
|
+
// means the api hasn't enabled `apiKeys`, a 401/403 means the signed-in user
|
|
8
|
+
// lacks the scope. It tells the operator exactly what to turn on rather than
|
|
9
|
+
// showing a broken table. (api-saas-starter does not enable it out of the box —
|
|
10
|
+
// see the README.)
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import { useEffect, useState, type FormEvent } from 'react'
|
|
13
|
+
import type { PageMeta } from '@voltro/web'
|
|
14
|
+
import { T, useT } from '@voltro/i18n'
|
|
15
|
+
import { getCatalog } from '../../../locales/index'
|
|
16
|
+
import type { ApiKey } from '../../../lib/api'
|
|
17
|
+
|
|
18
|
+
export const renderMode = 'ssr' as const
|
|
19
|
+
|
|
20
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
21
|
+
title: getCatalog(locale)['meta.apiKeys.title'],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
type State = 'loading' | 'ready' | 'notEnabled' | 'noAccess' | 'error'
|
|
25
|
+
|
|
26
|
+
const BASE = '/v1/api-keys'
|
|
27
|
+
|
|
28
|
+
export default function ApiKeys(): ReactNode {
|
|
29
|
+
const [state, setState] = useState<State>('loading')
|
|
30
|
+
const [keys, setKeys] = useState<ReadonlyArray<ApiKey>>([])
|
|
31
|
+
const [name, setName] = useState('')
|
|
32
|
+
const [minted, setMinted] = useState<string | null>(null)
|
|
33
|
+
const [pending, setPending] = useState(false)
|
|
34
|
+
const nameLabel = useT('apiKeys.name.placeholder')
|
|
35
|
+
|
|
36
|
+
const load = (): void => {
|
|
37
|
+
void fetch(BASE, { headers: { accept: 'application/json' } })
|
|
38
|
+
.then(async (res) => {
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
const body = (await res.json()) as { keys: ReadonlyArray<ApiKey> }
|
|
41
|
+
setKeys(body.keys ?? [])
|
|
42
|
+
setState('ready')
|
|
43
|
+
} else if (res.status === 404) setState('notEnabled')
|
|
44
|
+
else if (res.status === 401 || res.status === 403) setState('noAccess')
|
|
45
|
+
else setState('error')
|
|
46
|
+
})
|
|
47
|
+
.catch(() => setState('error'))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
useEffect(load, [])
|
|
51
|
+
|
|
52
|
+
const onIssue = (e: FormEvent): void => {
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
const trimmed = name.trim()
|
|
55
|
+
if (trimmed === '') return
|
|
56
|
+
setPending(true)
|
|
57
|
+
setMinted(null)
|
|
58
|
+
void fetch(`${BASE}/issue`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ name: trimmed }),
|
|
62
|
+
})
|
|
63
|
+
.then(async (res) => {
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const body = (await res.json()) as { token: string }
|
|
66
|
+
setMinted(body.token)
|
|
67
|
+
setName('')
|
|
68
|
+
load()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.finally(() => setPending(false))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onRevoke = (id: string): void => {
|
|
75
|
+
void fetch(`${BASE}/revoke`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ id }),
|
|
79
|
+
}).then(() => load())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<section>
|
|
84
|
+
<h1><T id="apiKeys.heading" /></h1>
|
|
85
|
+
|
|
86
|
+
{state === 'loading' ? <p className="empty"><T id="apiKeys.loading" /></p> : null}
|
|
87
|
+
{state === 'notEnabled' ? <p className="notice" role="status"><T id="apiKeys.notEnabled" /></p> : null}
|
|
88
|
+
{state === 'noAccess' ? <p className="notice" role="status"><T id="apiKeys.noAccess" /></p> : null}
|
|
89
|
+
{state === 'error' ? <p className="error-text" role="alert"><T id="apiKeys.error" /></p> : null}
|
|
90
|
+
|
|
91
|
+
{state === 'ready' ? (
|
|
92
|
+
<>
|
|
93
|
+
<form className="row" onSubmit={onIssue} style={{ marginTop: '0.5rem' }}>
|
|
94
|
+
<input aria-label={nameLabel} placeholder={nameLabel} value={name} onChange={(e) => setName(e.target.value)} />
|
|
95
|
+
<button type="submit" className="btn" disabled={pending}><T id="apiKeys.issue" /></button>
|
|
96
|
+
</form>
|
|
97
|
+
|
|
98
|
+
{minted ? (
|
|
99
|
+
<div className="paywall" role="alert" style={{ marginTop: '1rem' }}>
|
|
100
|
+
<strong><T id="apiKeys.minted.title" /></strong>
|
|
101
|
+
<p className="muted"><T id="apiKeys.minted.body" /></p>
|
|
102
|
+
<code className="token">{minted}</code>
|
|
103
|
+
</div>
|
|
104
|
+
) : null}
|
|
105
|
+
|
|
106
|
+
{keys.length === 0 ? (
|
|
107
|
+
<p className="empty"><T id="apiKeys.empty" /></p>
|
|
108
|
+
) : (
|
|
109
|
+
<ul className="rows" style={{ marginTop: '1.25rem' }}>
|
|
110
|
+
{keys.map((k) => (
|
|
111
|
+
<li key={k.id}>
|
|
112
|
+
<span className="grow">{k.name} <code>{k.keyPrefix}…</code></span>
|
|
113
|
+
{k.revokedAt
|
|
114
|
+
? <span className="tag"><T id="apiKeys.revoked" /></span>
|
|
115
|
+
: <button type="button" className="link" onClick={() => onRevoke(k.id)}><T id="apiKeys.revoke" /></button>}
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
)}
|
|
120
|
+
</>
|
|
121
|
+
) : null}
|
|
122
|
+
</section>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The billing page. We mock `@voltro/client` (the live subscription + the three
|
|
4
|
+
// billing actions) and assert: the current plan renders, and the plan-change
|
|
5
|
+
// flow quotes the provider's proration BEFORE it can be confirmed (preview →
|
|
6
|
+
// confirm), not a locally-estimated figure.
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
9
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
11
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
12
|
+
import enCatalog from '../../../locales/en'
|
|
13
|
+
|
|
14
|
+
const subData = vi.fn<() => { data: unknown }>()
|
|
15
|
+
const previewRun = vi.fn<(input: unknown) => Promise<unknown>>()
|
|
16
|
+
|
|
17
|
+
vi.mock('@voltro/client', () => ({
|
|
18
|
+
useSubscription: () => subData(),
|
|
19
|
+
useAction: (_api: string, tag: string) => ({
|
|
20
|
+
run: tag === 'billing.previewChange' ? previewRun : vi.fn().mockResolvedValue({ url: 'https://provider/portal' }),
|
|
21
|
+
pending: false,
|
|
22
|
+
}),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const { default: Billing, meta, renderMode } = await import('./page')
|
|
26
|
+
|
|
27
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
28
|
+
|
|
29
|
+
let container: HTMLDivElement
|
|
30
|
+
let root: Root
|
|
31
|
+
|
|
32
|
+
const render = (node: ReactNode): void => {
|
|
33
|
+
container = document.createElement('div')
|
|
34
|
+
document.body.appendChild(container)
|
|
35
|
+
act(() => {
|
|
36
|
+
root = createRoot(container)
|
|
37
|
+
root.render(
|
|
38
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
subData.mockReset()
|
|
45
|
+
subData.mockReturnValue({ data: { subscription: { plan: 'free', status: 'active', quantity: 1, currentPeriodEnd: null, cancelAt: null } } })
|
|
46
|
+
previewRun.mockReset()
|
|
47
|
+
previewRun.mockResolvedValue({ plan: 'pro', quantity: 1, prorationMinor: 900, currency: 'usd' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (root) act(() => root.unmount())
|
|
52
|
+
container?.remove()
|
|
53
|
+
document.body.innerHTML = ''
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('billing — config', () => {
|
|
57
|
+
test('is an SSR page titled Billing (so the parent gate runs per request)', () => {
|
|
58
|
+
expect(renderMode).toBe('ssr')
|
|
59
|
+
expect(meta({ locale: 'en' }).title).toBe('Billing')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('billing — render + change flow', () => {
|
|
64
|
+
test('shows the current plan', () => {
|
|
65
|
+
render(createElement(Billing))
|
|
66
|
+
expect(container.textContent).toContain('free')
|
|
67
|
+
expect(container.textContent).toContain('Current plan')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('previewing the upgrade quotes the provider proration before confirm appears', async () => {
|
|
71
|
+
render(createElement(Billing))
|
|
72
|
+
// Before preview: the button is "Preview upgrade", not "Confirm".
|
|
73
|
+
expect(container.textContent).toContain('Preview upgrade')
|
|
74
|
+
expect(container.textContent).not.toContain('Confirm upgrade')
|
|
75
|
+
|
|
76
|
+
const previewBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Preview upgrade')!
|
|
77
|
+
await act(async () => {
|
|
78
|
+
previewBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
79
|
+
await Promise.resolve(); await Promise.resolve()
|
|
80
|
+
})
|
|
81
|
+
expect(previewRun).toHaveBeenCalledWith({ plan: 'pro' })
|
|
82
|
+
// The quote is shown and now the confirm button is available.
|
|
83
|
+
expect(container.textContent).toContain('settles at about')
|
|
84
|
+
expect(container.textContent).toContain('Confirm upgrade')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Billing — URL `/billing`. The current plan (live from `billing.subscription`),
|
|
2
|
+
// a card-management button that redirects to the PROVIDER's hosted portal
|
|
3
|
+
// (`billing.portalUrl`), and a plan change that QUOTES the provider's prorated
|
|
4
|
+
// settlement first (`billing.previewChange`) before applying it
|
|
5
|
+
// (`billing.changePlan`) — never a locally-estimated figure.
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
import type { PageMeta } from '@voltro/web'
|
|
9
|
+
import { useSubscription, useAction } from '@voltro/client'
|
|
10
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
11
|
+
import { getCatalog } from '../../../locales/index'
|
|
12
|
+
import { formatMoney, type ChangePreview, type SubscriptionState } from '../../../lib/api'
|
|
13
|
+
|
|
14
|
+
export const renderMode = 'ssr' as const
|
|
15
|
+
|
|
16
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
17
|
+
title: getCatalog(locale)['meta.billing.title'],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The plan a free tenant upgrades to — matches api-saas-starter's `plans`.
|
|
21
|
+
const UPGRADE_PLAN = 'pro'
|
|
22
|
+
|
|
23
|
+
export default function Billing(): ReactNode {
|
|
24
|
+
const locale = useLocale()
|
|
25
|
+
const { data } = useSubscription<SubscriptionState>('app', 'billing.subscription')
|
|
26
|
+
const portal = useAction<{ returnUrl: string }, { url: string }>('app', 'billing.portalUrl')
|
|
27
|
+
const preview = useAction<{ plan?: string; quantity?: number }, ChangePreview>('app', 'billing.previewChange')
|
|
28
|
+
const change = useAction<{ plan: string }, ChangePreview>('app', 'billing.changePlan')
|
|
29
|
+
|
|
30
|
+
const [quote, setQuote] = useState<ChangePreview | null>(null)
|
|
31
|
+
const [changed, setChanged] = useState(false)
|
|
32
|
+
const sub = data?.subscription ?? null
|
|
33
|
+
const freeLabel = useT('billing.plan.free')
|
|
34
|
+
const busy = portal.pending || preview.pending || change.pending
|
|
35
|
+
|
|
36
|
+
const onManageCard = (): void => {
|
|
37
|
+
const here = typeof window === 'undefined' ? '/billing' : window.location.href
|
|
38
|
+
void portal.run({ returnUrl: here })
|
|
39
|
+
.then((r) => { if (r && typeof window !== 'undefined') window.location.assign(r.url) })
|
|
40
|
+
.catch(() => { /* surfaced via portal.error */ })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onPreview = (): void => {
|
|
44
|
+
setChanged(false)
|
|
45
|
+
void preview.run({ plan: UPGRADE_PLAN }).then((q) => setQuote(q)).catch(() => setQuote(null))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const onConfirm = (): void => {
|
|
49
|
+
void change.run({ plan: UPGRADE_PLAN }).then(() => { setChanged(true); setQuote(null) }).catch(() => { /* change.error */ })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section>
|
|
54
|
+
<h1><T id="billing.heading" /></h1>
|
|
55
|
+
|
|
56
|
+
<dl className="facts">
|
|
57
|
+
<dt><T id="billing.plan.label" /></dt>
|
|
58
|
+
<dd>{sub?.plan ?? freeLabel}</dd>
|
|
59
|
+
<dt><T id="billing.status.label" /></dt>
|
|
60
|
+
<dd>{sub?.status ?? '—'}</dd>
|
|
61
|
+
<dt><T id="billing.seats.label" /></dt>
|
|
62
|
+
<dd>{sub?.quantity ?? 1}</dd>
|
|
63
|
+
</dl>
|
|
64
|
+
|
|
65
|
+
<div className="row" style={{ marginTop: '1.25rem' }}>
|
|
66
|
+
<button type="button" className="btn ghost" disabled={busy} onClick={onManageCard}>
|
|
67
|
+
<T id="billing.card.manage" />
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="paywall" style={{ marginTop: '1.5rem' }}>
|
|
72
|
+
<strong><T id="billing.change.title" /></strong>
|
|
73
|
+
<p className="muted"><T id="billing.change.body" /></p>
|
|
74
|
+
{quote ? (
|
|
75
|
+
<p role="status">
|
|
76
|
+
<T
|
|
77
|
+
id="billing.change.quote"
|
|
78
|
+
values={{ amount: formatMoney(Math.abs(quote.prorationMinor), quote.currency, locale) }}
|
|
79
|
+
/>
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
{changed ? <p className="ok" role="status"><T id="billing.change.done" /></p> : null}
|
|
83
|
+
<div className="row">
|
|
84
|
+
{quote ? (
|
|
85
|
+
<button type="button" className="btn" disabled={busy} onClick={onConfirm}>
|
|
86
|
+
<T id={change.pending ? 'billing.pending' : 'billing.change.confirm'} />
|
|
87
|
+
</button>
|
|
88
|
+
) : (
|
|
89
|
+
<button type="button" className="btn" disabled={busy} onClick={onPreview}>
|
|
90
|
+
<T id={preview.pending ? 'billing.pending' : 'billing.change.preview'} />
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</section>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Error boundary scoped to the portal subtree. A render error (or a loader
|
|
2
|
+
// rejection that ISN'T a RedirectError / NotFoundError) bubbles here.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { ErrorBoundaryProps } from '@voltro/web'
|
|
5
|
+
import { T } from '@voltro/i18n'
|
|
6
|
+
|
|
7
|
+
export default function PortalError({ error, reset }: ErrorBoundaryProps): ReactNode {
|
|
8
|
+
return (
|
|
9
|
+
<div className="dash">
|
|
10
|
+
<main>
|
|
11
|
+
<h1><T id="error.heading" /></h1>
|
|
12
|
+
<p className="muted">{String(error)}</p>
|
|
13
|
+
<p style={{ marginTop: '1rem' }}>
|
|
14
|
+
<button type="button" className="btn" onClick={reset}><T id="error.retry" /></button>
|
|
15
|
+
</p>
|
|
16
|
+
</main>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Invoices — URL `/invoices`. Lists the tenant's invoice history live from
|
|
2
|
+
// `billing.invoices`. Each row's amount/status is shown; "View" / "PDF" link OUT
|
|
3
|
+
// to the provider's own hosted pages (`hostedUrl` / `pdfUrl`) — the portal never
|
|
4
|
+
// renders an invoice document itself.
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import type { PageMeta } from '@voltro/web'
|
|
7
|
+
import { useSubscription } from '@voltro/client'
|
|
8
|
+
import { T, useLocale } from '@voltro/i18n'
|
|
9
|
+
import { getCatalog } from '../../../locales/index'
|
|
10
|
+
import { formatMoney, type Invoice } from '../../../lib/api'
|
|
11
|
+
|
|
12
|
+
export const renderMode = 'ssr' as const
|
|
13
|
+
|
|
14
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
15
|
+
title: getCatalog(locale)['meta.invoices.title'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export default function Invoices(): ReactNode {
|
|
19
|
+
const locale = useLocale()
|
|
20
|
+
const { data } = useSubscription<{ invoices: ReadonlyArray<Invoice> }>('app', 'billing.invoices')
|
|
21
|
+
const invoices = data?.invoices ?? []
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<section>
|
|
25
|
+
<h1><T id="invoices.heading" /></h1>
|
|
26
|
+
{invoices.length === 0 ? (
|
|
27
|
+
<p className="empty"><T id="invoices.empty" /></p>
|
|
28
|
+
) : (
|
|
29
|
+
<ul className="rows" style={{ marginTop: '1.25rem' }}>
|
|
30
|
+
{invoices.map((inv) => (
|
|
31
|
+
<li key={inv.providerInvoiceId}>
|
|
32
|
+
<span className="grow">
|
|
33
|
+
{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}
|
|
34
|
+
</span>
|
|
35
|
+
<span>{formatMoney(inv.amountMinor, inv.currency, locale)}</span>
|
|
36
|
+
<span className="tag">{inv.status}</span>
|
|
37
|
+
{inv.hostedUrl ? <a href={inv.hostedUrl} target="_blank" rel="noopener noreferrer"><T id="invoices.view" /></a> : null}
|
|
38
|
+
{inv.pdfUrl ? <a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer"><T id="invoices.pdf" /></a> : null}
|
|
39
|
+
</li>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
)}
|
|
43
|
+
</section>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The portal auth GATE, tested at the loader. Server-side (a `query` fn is
|
|
2
|
+
// present) it asks the api's `session.me`: an anonymous identity must throw
|
|
3
|
+
// RedirectError to /login; a `user` identity resolves with the tenant. On a
|
|
4
|
+
// client navigation (`query` undefined) it resolves permissively.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
7
|
+
import { RedirectError } from '@voltro/web'
|
|
8
|
+
import { loader } from './layout'
|
|
9
|
+
import type { Identity } from '../../lib/api'
|
|
10
|
+
|
|
11
|
+
const queryReturning = (identity: Identity) =>
|
|
12
|
+
vi.fn(async (_tag: string, _input: unknown) => identity as unknown)
|
|
13
|
+
|
|
14
|
+
describe('portal gate — loader', () => {
|
|
15
|
+
test('redirects an anonymous caller to /login', async () => {
|
|
16
|
+
const query = queryReturning({ type: 'anonymous', id: null, tenantId: null })
|
|
17
|
+
await expect(loader({ query } as never)).rejects.toBeInstanceOf(RedirectError)
|
|
18
|
+
expect(query).toHaveBeenCalledWith('session.me', {})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('lets a signed-in user through and returns the tenant', async () => {
|
|
22
|
+
const query = queryReturning({ type: 'user', id: 'u_1', tenantId: 'acme' })
|
|
23
|
+
const data = await loader({ query } as never)
|
|
24
|
+
expect(data).toEqual({ tenantId: 'acme' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('client navigation (no server query fn) resolves without re-gating', async () => {
|
|
28
|
+
const data = await loader({ query: undefined } as never)
|
|
29
|
+
expect(data).toEqual({ tenantId: null })
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The portal shell — and the AUTH GATE. This layout wraps every page in the
|
|
2
|
+
// silent `(portal)` group, and its `loader` runs server-side BEFORE the pages
|
|
3
|
+
// render: it asks the api's `session.me` and throws RedirectError to /login for
|
|
4
|
+
// anyone not signed in. The pages under here are `renderMode: 'ssr'` precisely so
|
|
5
|
+
// this runs per request.
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { RedirectError, useLoaderData, useLocation, type LoaderFn } from '@voltro/web'
|
|
8
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
9
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
10
|
+
import { APP_NAME } from '../../config'
|
|
11
|
+
import type { Identity } from '../../lib/api'
|
|
12
|
+
|
|
13
|
+
const LOCALES = [
|
|
14
|
+
{ code: 'en', label: 'English' },
|
|
15
|
+
{ code: 'de', label: 'Deutsch' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
interface PortalData {
|
|
19
|
+
readonly tenantId: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const loader: LoaderFn<PortalData> = async ({ query }) => {
|
|
23
|
+
if (query) {
|
|
24
|
+
const me = await query<Identity>('session.me', {})
|
|
25
|
+
if (me.type !== 'user') {
|
|
26
|
+
throw new RedirectError('/login?from=/')
|
|
27
|
+
}
|
|
28
|
+
return { tenantId: me.tenantId }
|
|
29
|
+
}
|
|
30
|
+
return { tenantId: null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function PortalLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
34
|
+
useLoaderData<PortalData>()
|
|
35
|
+
const pathname = useLocation()
|
|
36
|
+
const locale = useLocale()
|
|
37
|
+
const langLabel = useT('lang.label')
|
|
38
|
+
const navItem = (href: string, label: ReactNode): ReactNode => (
|
|
39
|
+
<a href={href} aria-current={pathname === href ? 'page' : undefined}>{label}</a>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="dash">
|
|
44
|
+
<aside>
|
|
45
|
+
<div className="brand">{APP_NAME}</div>
|
|
46
|
+
<nav>
|
|
47
|
+
{navItem('/', <T id="nav.overview" />)}
|
|
48
|
+
{navItem('/billing', <T id="nav.billing" />)}
|
|
49
|
+
{navItem('/invoices', <T id="nav.invoices" />)}
|
|
50
|
+
{navItem('/api-keys', <T id="nav.apiKeys" />)}
|
|
51
|
+
{navItem('/profile', <T id="nav.profile" />)}
|
|
52
|
+
</nav>
|
|
53
|
+
</aside>
|
|
54
|
+
<main>
|
|
55
|
+
<div className="topbar">
|
|
56
|
+
<span className="spacer" />
|
|
57
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
58
|
+
</div>
|
|
59
|
+
{children}
|
|
60
|
+
</main>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Scoped 404 for the portal subtree.
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { T } from '@voltro/i18n'
|
|
4
|
+
|
|
5
|
+
export default function PortalNotFound(): ReactNode {
|
|
6
|
+
return (
|
|
7
|
+
<div className="dash">
|
|
8
|
+
<main>
|
|
9
|
+
<h1><T id="notFound.heading" /></h1>
|
|
10
|
+
<p className="muted"><T id="notFound.body" /></p>
|
|
11
|
+
<p style={{ marginTop: '1rem' }}><a href="/"><T id="notFound.back" /></a></p>
|
|
12
|
+
</main>
|
|
13
|
+
</div>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Overview — URL `/`. A glance at the account: the current plan (live from
|
|
2
|
+
// `billing.subscription`), renewal date, and quick links into the deeper pages.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { PageMeta } from '@voltro/web'
|
|
5
|
+
import { useSubscription } from '@voltro/client'
|
|
6
|
+
import { T, useT } from '@voltro/i18n'
|
|
7
|
+
import { getCatalog } from '../../locales'
|
|
8
|
+
import type { SubscriptionState } from '../../lib/api'
|
|
9
|
+
|
|
10
|
+
export const renderMode = 'ssr' as const
|
|
11
|
+
|
|
12
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
13
|
+
title: getCatalog(locale)['meta.overview.title'],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export default function Overview(): ReactNode {
|
|
17
|
+
const { data } = useSubscription<SubscriptionState>('app', 'billing.subscription')
|
|
18
|
+
const sub = data?.subscription ?? null
|
|
19
|
+
const freeLabel = useT('billing.plan.free')
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section>
|
|
23
|
+
<h1><T id="overview.heading" /></h1>
|
|
24
|
+
<dl className="facts">
|
|
25
|
+
<dt><T id="billing.plan.label" /></dt>
|
|
26
|
+
<dd>{sub?.plan ?? freeLabel}</dd>
|
|
27
|
+
<dt><T id="billing.status.label" /></dt>
|
|
28
|
+
<dd>{sub?.status ?? '—'}</dd>
|
|
29
|
+
<dt><T id="overview.renews" /></dt>
|
|
30
|
+
<dd>{sub?.currentPeriodEnd ? new Date(sub.currentPeriodEnd).toLocaleDateString() : '—'}</dd>
|
|
31
|
+
</dl>
|
|
32
|
+
<ul className="cards" style={{ marginTop: '1.5rem' }}>
|
|
33
|
+
<li className="card"><a href="/billing"><T id="nav.billing" /></a><div className="label"><T id="overview.link.billing" /></div></li>
|
|
34
|
+
<li className="card"><a href="/invoices"><T id="nav.invoices" /></a><div className="label"><T id="overview.link.invoices" /></div></li>
|
|
35
|
+
<li className="card"><a href="/api-keys"><T id="nav.apiKeys" /></a><div className="label"><T id="overview.link.apiKeys" /></div></li>
|
|
36
|
+
</ul>
|
|
37
|
+
</section>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Profile — URL `/profile`. Shows the signed-in identity (from the gate's
|
|
2
|
+
// `session.me`, re-read here), a self-contained password-change (requests a reset
|
|
3
|
+
// link via the api's `/auth/password-reset` route), and sign out. Kept
|
|
4
|
+
// deliberately thin — the portal is account admin, not a settings sprawl.
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { useEffect, useState, type FormEvent } from 'react'
|
|
7
|
+
import type { PageMeta } from '@voltro/web'
|
|
8
|
+
import { useAction } from '@voltro/client'
|
|
9
|
+
import { T, useT } from '@voltro/i18n'
|
|
10
|
+
import { getCatalog } from '../../../locales/index'
|
|
11
|
+
import type { Identity } from '../../../lib/api'
|
|
12
|
+
|
|
13
|
+
export const renderMode = 'ssr' as const
|
|
14
|
+
|
|
15
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
16
|
+
title: getCatalog(locale)['meta.profile.title'],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const csrf = async (): Promise<string> => {
|
|
20
|
+
const body = (await fetch('/auth/csrf').then((r) => r.json())) as { csrfToken?: string }
|
|
21
|
+
return body.csrfToken ?? ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function Profile(): ReactNode {
|
|
25
|
+
const me = useAction<Record<string, never>, Identity>('app', 'session.me')
|
|
26
|
+
const [ident, setIdent] = useState<Identity | null>(null)
|
|
27
|
+
const [email, setEmail] = useState('')
|
|
28
|
+
const [sent, setSent] = useState(false)
|
|
29
|
+
const emailLabel = useT('profile.cp.email')
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
void me.run({}).then(setIdent).catch(() => setIdent(null))
|
|
33
|
+
}, []) // read the identity once on mount
|
|
34
|
+
|
|
35
|
+
const onChangePassword = (e: FormEvent): void => {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
void csrf()
|
|
38
|
+
.then((token) =>
|
|
39
|
+
fetch('/auth/password-reset', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': token },
|
|
42
|
+
body: JSON.stringify({ email }),
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.finally(() => setSent(true))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const signOut = (): void => {
|
|
49
|
+
void csrf()
|
|
50
|
+
.then((token) => fetch('/auth/sign-out', { method: 'POST', headers: { 'x-csrf-token': token } }))
|
|
51
|
+
.finally(() => window.location.assign('/login'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<section>
|
|
56
|
+
<h1><T id="profile.heading" /></h1>
|
|
57
|
+
<dl className="facts">
|
|
58
|
+
<dt><T id="profile.id" /></dt>
|
|
59
|
+
<dd><code>{ident?.id ?? '—'}</code></dd>
|
|
60
|
+
<dt><T id="profile.tenant" /></dt>
|
|
61
|
+
<dd><code>{ident?.tenantId ?? '—'}</code></dd>
|
|
62
|
+
</dl>
|
|
63
|
+
|
|
64
|
+
<h2 style={{ marginTop: '1.5rem' }}><T id="profile.cp.heading" /></h2>
|
|
65
|
+
{sent ? (
|
|
66
|
+
<p className="ok" role="status"><T id="profile.cp.sent" /></p>
|
|
67
|
+
) : (
|
|
68
|
+
<form className="row" onSubmit={onChangePassword}>
|
|
69
|
+
<input type="email" required aria-label={emailLabel} placeholder={emailLabel} value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
70
|
+
<button type="submit" className="btn ghost"><T id="profile.cp.submit" /></button>
|
|
71
|
+
</form>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<div className="row" style={{ marginTop: '1.5rem' }}>
|
|
75
|
+
<button type="button" className="btn" onClick={signOut}><T id="profile.signOut" /></button>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|