@voltro/cli 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
- package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
- package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/whats-new.md +162 -181
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/* Portal extras */
|
|
88
|
+
.rows .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
89
|
+
.rows li { gap: 0.6rem; }
|
|
90
|
+
.notice { border: 1px solid var(--accent); border-radius: 10px; padding: 0.85rem 1rem; background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
91
|
+
.ok { color: var(--accent); }
|
|
92
|
+
.token { display: block; margin-top: 0.5rem; padding: 0.6rem 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; word-break: break-all; font-size: 0.85rem; }
|
|
93
|
+
.cards .card a { font-weight: 600; text-decoration: none; color: var(--accent); }
|
|
@@ -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,62 @@
|
|
|
1
|
+
// The shapes this portal reads off the api-saas-starter backend, kept LOCAL so
|
|
2
|
+
// the pages are self-contained. They mirror the api's descriptor outputs
|
|
3
|
+
// (`session.me`, `billing.subscription`, `billing.invoices`) and the framework's
|
|
4
|
+
// `/v1/api-keys` REST surface.
|
|
5
|
+
|
|
6
|
+
// The caller's resolved identity, from `session.me`. The SSR gate reads it.
|
|
7
|
+
export interface Identity {
|
|
8
|
+
readonly type: string
|
|
9
|
+
readonly id: string | null
|
|
10
|
+
readonly tenantId: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// `billing.subscription` streams `{ subscription: … | null }` — null before the
|
|
14
|
+
// tenant has any subscription row (they're on the implicit free plan).
|
|
15
|
+
export interface SubscriptionState {
|
|
16
|
+
readonly subscription: {
|
|
17
|
+
readonly plan: string
|
|
18
|
+
readonly status: string
|
|
19
|
+
readonly quantity: number
|
|
20
|
+
readonly currentPeriodEnd: string | null
|
|
21
|
+
readonly cancelAt: string | null
|
|
22
|
+
} | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One row of `billing.invoices`. `hostedUrl` / `pdfUrl` are the PROVIDER's own
|
|
26
|
+
// pages — the portal links out, it never renders an invoice.
|
|
27
|
+
export interface Invoice {
|
|
28
|
+
readonly providerInvoiceId: string
|
|
29
|
+
readonly amountMinor: number
|
|
30
|
+
readonly currency: string
|
|
31
|
+
readonly status: string
|
|
32
|
+
readonly createdAt: string | null
|
|
33
|
+
readonly hostedUrl: string | null
|
|
34
|
+
readonly pdfUrl: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `billing.previewChange` — the provider's prorated settlement for a change.
|
|
38
|
+
export interface ChangePreview {
|
|
39
|
+
readonly plan: string
|
|
40
|
+
readonly quantity: number
|
|
41
|
+
readonly prorationMinor: number
|
|
42
|
+
readonly currency: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One key from `GET /v1/api-keys` (the framework's built-in management surface).
|
|
46
|
+
export interface ApiKey {
|
|
47
|
+
readonly id: string
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly keyPrefix: string
|
|
50
|
+
readonly createdAt: number
|
|
51
|
+
readonly lastUsedAt: number | null
|
|
52
|
+
readonly revokedAt: number | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Minor units (cents) → a display string. Intl handles the currency + locale.
|
|
56
|
+
export const formatMoney = (minor: number, currency: string, locale: string): string => {
|
|
57
|
+
try {
|
|
58
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(minor / 100)
|
|
59
|
+
} catch {
|
|
60
|
+
return `${(minor / 100).toFixed(2)} ${currency.toUpperCase()}`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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; keep ICU
|
|
3
|
+
// placeholders (`{amount}`) identical to en.ts.
|
|
4
|
+
import { defineLocale } from '@voltro/i18n'
|
|
5
|
+
import en from './en'
|
|
6
|
+
|
|
7
|
+
export default defineLocale<typeof en>()({
|
|
8
|
+
'meta.login.title': 'Anmelden',
|
|
9
|
+
'meta.overview.title': 'Konto',
|
|
10
|
+
'meta.billing.title': 'Abrechnung',
|
|
11
|
+
'meta.invoices.title': 'Rechnungen',
|
|
12
|
+
'meta.apiKeys.title': 'API-Schlüssel',
|
|
13
|
+
'meta.profile.title': 'Profil',
|
|
14
|
+
|
|
15
|
+
'lang.label': 'Sprache',
|
|
16
|
+
|
|
17
|
+
'auth.signIn.heading': 'Anmelden',
|
|
18
|
+
'auth.signUp.heading': 'Konto erstellen',
|
|
19
|
+
'auth.email': 'E-Mail',
|
|
20
|
+
'auth.password': 'Passwort',
|
|
21
|
+
'auth.signIn.submit': 'Anmelden',
|
|
22
|
+
'auth.signUp.submit': 'Konto erstellen',
|
|
23
|
+
'auth.toggle.toSignUp': 'Kein Konto? Erstelle eins',
|
|
24
|
+
'auth.toggle.toSignIn': 'Konto vorhanden? Anmelden',
|
|
25
|
+
'auth.pending': 'Bitte warten…',
|
|
26
|
+
'auth.error': 'Das hat nicht geklappt. Prüfe deine Angaben und versuche es erneut.',
|
|
27
|
+
|
|
28
|
+
'nav.overview': 'Übersicht',
|
|
29
|
+
'nav.billing': 'Abrechnung',
|
|
30
|
+
'nav.invoices': 'Rechnungen',
|
|
31
|
+
'nav.apiKeys': 'API-Schlüssel',
|
|
32
|
+
'nav.profile': 'Profil',
|
|
33
|
+
|
|
34
|
+
'overview.heading': 'Kontoübersicht',
|
|
35
|
+
'overview.renews': 'Verlängert am',
|
|
36
|
+
'overview.link.billing': 'Plan & Zahlungsmethode',
|
|
37
|
+
'overview.link.invoices': 'Rechnungsverlauf',
|
|
38
|
+
'overview.link.apiKeys': 'API-Schlüssel verwalten',
|
|
39
|
+
|
|
40
|
+
'billing.heading': 'Abrechnung',
|
|
41
|
+
'billing.plan.label': 'Aktueller Plan',
|
|
42
|
+
'billing.plan.free': 'Kostenlos',
|
|
43
|
+
'billing.status.label': 'Status',
|
|
44
|
+
'billing.seats.label': 'Sitze',
|
|
45
|
+
'billing.card.manage': 'Zahlungsmethode verwalten →',
|
|
46
|
+
'billing.change.title': 'Plan ändern',
|
|
47
|
+
'billing.change.body': 'Upgrade auf Pro. Wir nennen den genauen anteiligen Betrag vor der Abbuchung.',
|
|
48
|
+
'billing.change.preview': 'Upgrade ansehen',
|
|
49
|
+
'billing.change.quote': 'Diese Änderung kostet heute etwa {amount}.',
|
|
50
|
+
'billing.change.confirm': 'Upgrade bestätigen',
|
|
51
|
+
'billing.change.done': 'Dein Plan wurde aktualisiert.',
|
|
52
|
+
'billing.pending': 'Wird ausgeführt…',
|
|
53
|
+
|
|
54
|
+
'invoices.heading': 'Rechnungen',
|
|
55
|
+
'invoices.empty': 'Noch keine Rechnungen.',
|
|
56
|
+
'invoices.view': 'Ansehen',
|
|
57
|
+
'invoices.pdf': 'PDF',
|
|
58
|
+
|
|
59
|
+
'apiKeys.heading': 'API-Schlüssel',
|
|
60
|
+
'apiKeys.loading': 'Wird geladen…',
|
|
61
|
+
'apiKeys.notEnabled': 'API-Schlüssel sind für diese API nicht aktiviert. Setze `apiKeys: true` in der app.config.ts der API, um die eingebaute Schlüsselverwaltung zu aktivieren.',
|
|
62
|
+
'apiKeys.noAccess': 'Dein Konto darf keine API-Schlüssel verwalten. Dafür ist ein Admin-Scope nötig.',
|
|
63
|
+
'apiKeys.error': 'API-Schlüssel konnten nicht geladen werden. Bitte erneut versuchen.',
|
|
64
|
+
'apiKeys.name.placeholder': 'Schlüsselname (z. B. CI-Deploy)',
|
|
65
|
+
'apiKeys.issue': 'Schlüssel erstellen',
|
|
66
|
+
'apiKeys.minted.title': 'Kopiere deinen Schlüssel jetzt',
|
|
67
|
+
'apiKeys.minted.body': 'Der vollständige Schlüssel wird nur dieses eine Mal angezeigt. Bewahre ihn sicher auf.',
|
|
68
|
+
'apiKeys.empty': 'Noch keine Schlüssel — erstelle oben deinen ersten.',
|
|
69
|
+
'apiKeys.revoke': 'Widerrufen',
|
|
70
|
+
'apiKeys.revoked': 'widerrufen',
|
|
71
|
+
|
|
72
|
+
'profile.heading': 'Profil',
|
|
73
|
+
'profile.id': 'Benutzer-ID',
|
|
74
|
+
'profile.tenant': 'Organisation',
|
|
75
|
+
'profile.cp.heading': 'Passwort ändern',
|
|
76
|
+
'profile.cp.email': 'E-Mail deines Kontos',
|
|
77
|
+
'profile.cp.submit': 'Reset-Link senden',
|
|
78
|
+
'profile.cp.sent': 'Falls zu dieser E-Mail ein Konto existiert, ist ein Reset-Link unterwegs.',
|
|
79
|
+
'profile.signOut': 'Abmelden',
|
|
80
|
+
|
|
81
|
+
'error.heading': 'Etwas ist schiefgelaufen',
|
|
82
|
+
'error.retry': 'Erneut versuchen',
|
|
83
|
+
'notFound.heading': 'Nicht gefunden',
|
|
84
|
+
'notFound.body': 'Diese Seite existiert nicht.',
|
|
85
|
+
'notFound.back': '← Zurück zur Übersicht',
|
|
86
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
4
|
+
|
|
5
|
+
export default defineCatalog({
|
|
6
|
+
// Browser tab titles.
|
|
7
|
+
'meta.login.title': 'Sign in',
|
|
8
|
+
'meta.overview.title': 'Account',
|
|
9
|
+
'meta.billing.title': 'Billing',
|
|
10
|
+
'meta.invoices.title': 'Invoices',
|
|
11
|
+
'meta.apiKeys.title': 'API keys',
|
|
12
|
+
'meta.profile.title': 'Profile',
|
|
13
|
+
|
|
14
|
+
'lang.label': 'Language',
|
|
15
|
+
|
|
16
|
+
// ---- Auth form (/login) ----
|
|
17
|
+
'auth.signIn.heading': 'Sign in',
|
|
18
|
+
'auth.signUp.heading': 'Create your account',
|
|
19
|
+
'auth.email': 'Email',
|
|
20
|
+
'auth.password': 'Password',
|
|
21
|
+
'auth.signIn.submit': 'Sign in',
|
|
22
|
+
'auth.signUp.submit': 'Create account',
|
|
23
|
+
'auth.toggle.toSignUp': 'No account? Create one',
|
|
24
|
+
'auth.toggle.toSignIn': 'Have an account? Sign in',
|
|
25
|
+
'auth.pending': 'Please wait…',
|
|
26
|
+
'auth.error': 'That didn’t work. Check your details and try again.',
|
|
27
|
+
|
|
28
|
+
// ---- Sidebar nav ----
|
|
29
|
+
'nav.overview': 'Overview',
|
|
30
|
+
'nav.billing': 'Billing',
|
|
31
|
+
'nav.invoices': 'Invoices',
|
|
32
|
+
'nav.apiKeys': 'API keys',
|
|
33
|
+
'nav.profile': 'Profile',
|
|
34
|
+
|
|
35
|
+
// ---- Overview ----
|
|
36
|
+
'overview.heading': 'Account overview',
|
|
37
|
+
'overview.renews': 'Renews',
|
|
38
|
+
'overview.link.billing': 'Plan & payment method',
|
|
39
|
+
'overview.link.invoices': 'Invoice history',
|
|
40
|
+
'overview.link.apiKeys': 'Manage API keys',
|
|
41
|
+
|
|
42
|
+
// ---- Billing ----
|
|
43
|
+
'billing.heading': 'Billing',
|
|
44
|
+
'billing.plan.label': 'Current plan',
|
|
45
|
+
'billing.plan.free': 'Free',
|
|
46
|
+
'billing.status.label': 'Status',
|
|
47
|
+
'billing.seats.label': 'Seats',
|
|
48
|
+
'billing.card.manage': 'Manage payment method →',
|
|
49
|
+
'billing.change.title': 'Change plan',
|
|
50
|
+
'billing.change.body': 'Upgrade to Pro. We’ll quote the exact prorated amount before charging.',
|
|
51
|
+
'billing.change.preview': 'Preview upgrade',
|
|
52
|
+
'billing.change.quote': 'This change settles at about {amount} today.',
|
|
53
|
+
'billing.change.confirm': 'Confirm upgrade',
|
|
54
|
+
'billing.change.done': 'Your plan has been updated.',
|
|
55
|
+
'billing.pending': 'Working…',
|
|
56
|
+
|
|
57
|
+
// ---- Invoices ----
|
|
58
|
+
'invoices.heading': 'Invoices',
|
|
59
|
+
'invoices.empty': 'No invoices yet.',
|
|
60
|
+
'invoices.view': 'View',
|
|
61
|
+
'invoices.pdf': 'PDF',
|
|
62
|
+
|
|
63
|
+
// ---- API keys ----
|
|
64
|
+
'apiKeys.heading': 'API keys',
|
|
65
|
+
'apiKeys.loading': 'Loading…',
|
|
66
|
+
'apiKeys.notEnabled': 'API keys aren’t enabled on this API. Set `apiKeys: true` in the API’s app.config.ts to turn on the built-in key management surface.',
|
|
67
|
+
'apiKeys.noAccess': 'Your account doesn’t have permission to manage API keys. An admin scope is required.',
|
|
68
|
+
'apiKeys.error': 'Couldn’t load API keys. Please try again.',
|
|
69
|
+
'apiKeys.name.placeholder': 'Key name (e.g. CI deploy)',
|
|
70
|
+
'apiKeys.issue': 'Create key',
|
|
71
|
+
'apiKeys.minted.title': 'Copy your key now',
|
|
72
|
+
'apiKeys.minted.body': 'This is the only time the full key is shown. Store it somewhere safe.',
|
|
73
|
+
'apiKeys.empty': 'No keys yet — create your first above.',
|
|
74
|
+
'apiKeys.revoke': 'Revoke',
|
|
75
|
+
'apiKeys.revoked': 'revoked',
|
|
76
|
+
|
|
77
|
+
// ---- Profile ----
|
|
78
|
+
'profile.heading': 'Profile',
|
|
79
|
+
'profile.id': 'User ID',
|
|
80
|
+
'profile.tenant': 'Organization',
|
|
81
|
+
'profile.cp.heading': 'Change password',
|
|
82
|
+
'profile.cp.email': 'Your account email',
|
|
83
|
+
'profile.cp.submit': 'Email me a reset link',
|
|
84
|
+
'profile.cp.sent': 'If that email has an account, a reset link is on its way.',
|
|
85
|
+
'profile.signOut': 'Sign out',
|
|
86
|
+
|
|
87
|
+
// ---- Scoped fallbacks ----
|
|
88
|
+
'error.heading': 'Something went wrong',
|
|
89
|
+
'error.retry': 'Try again',
|
|
90
|
+
'notFound.heading': 'Not found',
|
|
91
|
+
'notFound.body': 'That page doesn’t exist.',
|
|
92
|
+
'notFound.back': '← Back to overview',
|
|
93
|
+
} 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,71 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The API-keys page. We stub `fetch` to the framework's `/v1/api-keys` surface
|
|
4
|
+
// and assert the two paths that matter for a template shipped against an api
|
|
5
|
+
// that does NOT enable keys by default: a 404 shows the "enable apiKeys" notice
|
|
6
|
+
// (degrade, don't break), and a 200 renders the key list + issue form.
|
|
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 { default: ApiKeys } = await import('./page')
|
|
15
|
+
|
|
16
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
17
|
+
|
|
18
|
+
let container: HTMLDivElement
|
|
19
|
+
let root: Root
|
|
20
|
+
|
|
21
|
+
const render = (node: ReactNode): void => {
|
|
22
|
+
container = document.createElement('div')
|
|
23
|
+
document.body.appendChild(container)
|
|
24
|
+
act(() => {
|
|
25
|
+
root = createRoot(container)
|
|
26
|
+
root.render(
|
|
27
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stubFetch = (status: number, body: unknown): void => {
|
|
33
|
+
;(globalThis as { fetch: unknown }).fetch = vi.fn(async () => ({
|
|
34
|
+
ok: status >= 200 && status < 300,
|
|
35
|
+
status,
|
|
36
|
+
json: async () => body,
|
|
37
|
+
})) as unknown as typeof fetch
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const flush = async (): Promise<void> => {
|
|
41
|
+
await act(async () => { await Promise.resolve(); await Promise.resolve() })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (root) act(() => root.unmount())
|
|
46
|
+
container?.remove()
|
|
47
|
+
document.body.innerHTML = ''
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
beforeEach(() => { vi.restoreAllMocks() })
|
|
51
|
+
|
|
52
|
+
describe('api-keys — graceful states', () => {
|
|
53
|
+
test('a 404 (apiKeys not enabled) shows the enable notice, not a broken table', async () => {
|
|
54
|
+
stubFetch(404, { error: 'not_found' })
|
|
55
|
+
render(createElement(ApiKeys))
|
|
56
|
+
await flush()
|
|
57
|
+
expect(container.querySelector('.notice')).not.toBeNull()
|
|
58
|
+
expect(container.textContent).toContain('aren’t enabled')
|
|
59
|
+
// No issue form when the surface is unavailable.
|
|
60
|
+
expect(container.querySelector('form')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a 200 renders the key list and the issue form', async () => {
|
|
64
|
+
stubFetch(200, { keys: [{ id: 'k1', name: 'CI', keyPrefix: 'voltro_ab', createdAt: 0, lastUsedAt: null, revokedAt: null }] })
|
|
65
|
+
render(createElement(ApiKeys))
|
|
66
|
+
await flush()
|
|
67
|
+
expect(container.querySelector('form')).not.toBeNull()
|
|
68
|
+
expect(container.textContent).toContain('CI')
|
|
69
|
+
expect(container.textContent).toContain('voltro_ab')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -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
|
+
})
|