@voltro/cli 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +513 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
- 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-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
- package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
- package/dist/dev-DeEJj5T4.js +3 -0
- 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/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +3 -3
- 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/deployment.md +56 -0
- 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/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +165 -131
- 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-BrjrVJJh.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-DNkso403.js +0 -3
- 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-DfkisVWP.js +0 -1310
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The editor — URL `/`. Introspects the api's registered content types
|
|
2
|
+
// (`content.types`) and generates a form per type with @voltro/cms's
|
|
3
|
+
// `<ContentForm>`; lists a type's drafts live (`content.list`), saves them
|
|
4
|
+
// (`content.saveDraft`, with per-field validation surfaced inline), and
|
|
5
|
+
// publishes / unpublishes them. One page, no dynamic routes — the selected type
|
|
6
|
+
// and the row being edited are component state.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
|
9
|
+
import type { PageMeta } from '@voltro/web'
|
|
10
|
+
import { useAction, useMutation, useSubscription } from '@voltro/client'
|
|
11
|
+
import { ContentForm } from '@voltro/cms/web'
|
|
12
|
+
import { T, useT } from '@voltro/i18n'
|
|
13
|
+
import { getCatalog } from '../../locales'
|
|
14
|
+
import {
|
|
15
|
+
toContentFormType,
|
|
16
|
+
type ContentRow,
|
|
17
|
+
type ContentTypeDescriptor,
|
|
18
|
+
type SaveResult,
|
|
19
|
+
} from '../../lib/api'
|
|
20
|
+
|
|
21
|
+
export const renderMode = 'ssr' as const
|
|
22
|
+
|
|
23
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
24
|
+
title: getCatalog(locale)['meta.editor.title'],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
interface Violation { readonly field: string; readonly rule: string; readonly message: string }
|
|
28
|
+
|
|
29
|
+
export default function Editor(): ReactNode {
|
|
30
|
+
const types = useAction<Record<string, never>, { types: ReadonlyArray<ContentTypeDescriptor> }>(
|
|
31
|
+
'cms', 'content.types',
|
|
32
|
+
)
|
|
33
|
+
const getOne = useAction<{ type: string; id: string; status: string }, { row: Record<string, unknown> | null }>(
|
|
34
|
+
'cms', 'content.get',
|
|
35
|
+
)
|
|
36
|
+
const save = useMutation<{ type: string; values: Record<string, unknown> }, SaveResult>('cms', 'content.saveDraft')
|
|
37
|
+
const publish = useMutation<{ type: string; id: string }, { ok: boolean }>('cms', 'content.publish')
|
|
38
|
+
const unpublish = useMutation<{ type: string; id: string }, { ok: boolean }>('cms', 'content.unpublish')
|
|
39
|
+
|
|
40
|
+
const [typeList, setTypeList] = useState<ReadonlyArray<ContentTypeDescriptor>>([])
|
|
41
|
+
const [active, setActive] = useState<string | null>(null)
|
|
42
|
+
const [values, setValues] = useState<Record<string, unknown>>({})
|
|
43
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
44
|
+
const [violations, setViolations] = useState<ReadonlyArray<Violation>>([])
|
|
45
|
+
const [saved, setSaved] = useState(false)
|
|
46
|
+
|
|
47
|
+
// Load the content types once; select the first.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
void types.run({}).then((r) => {
|
|
50
|
+
setTypeList(r.types)
|
|
51
|
+
setActive((cur) => cur ?? r.types[0]?.name ?? null)
|
|
52
|
+
})
|
|
53
|
+
}, []) // mount-only fetch of the content-type descriptors
|
|
54
|
+
|
|
55
|
+
const activeType = typeList.find((t) => t.name === active) ?? null
|
|
56
|
+
|
|
57
|
+
// Live drafts of the active type. Memoised input so a stable object identity
|
|
58
|
+
// avoids resubscribing every render; an unset/unknown type yields an empty list.
|
|
59
|
+
const listInput = useMemo(() => ({ type: active ?? '—', status: 'draft' as const }), [active])
|
|
60
|
+
const { data: rows } = useSubscription<ReadonlyArray<ContentRow>>('cms', 'content.list', listInput)
|
|
61
|
+
const drafts = rows ?? []
|
|
62
|
+
|
|
63
|
+
const resetForm = (): void => { setValues({}); setEditingId(null); setViolations([]) }
|
|
64
|
+
|
|
65
|
+
const onSelectType = (name: string): void => { setActive(name); resetForm(); setSaved(false) }
|
|
66
|
+
|
|
67
|
+
const onEdit = (id: string): void => {
|
|
68
|
+
if (active === null) return
|
|
69
|
+
void getOne.run({ type: active, id, status: 'draft' }).then((r) => {
|
|
70
|
+
if (r.row) { setValues(r.row); setEditingId(id); setViolations([]); setSaved(false) }
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onSubmit = (e: FormEvent): void => {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
if (active === null) return
|
|
77
|
+
const payload = editingId ? { ...values, id: editingId } : values
|
|
78
|
+
void save.mutate({ type: active, values: payload }).then((res) => {
|
|
79
|
+
if (res.ok) { resetForm(); setSaved(true) } else { setViolations(res.violations); setSaved(false) }
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const onPublish = (id: string): void => { if (active) void publish.mutate({ type: active, id }) }
|
|
84
|
+
const onUnpublish = (id: string): void => { if (active) void unpublish.mutate({ type: active, id }) }
|
|
85
|
+
|
|
86
|
+
const statusKey = (s: string): 'editor.status.draft' | 'editor.status.published' | 'editor.status.archived' =>
|
|
87
|
+
s === 'published' ? 'editor.status.published' : s === 'archived' ? 'editor.status.archived' : 'editor.status.draft'
|
|
88
|
+
|
|
89
|
+
if (typeList.length === 0) {
|
|
90
|
+
return <section className="cms-main"><p className="empty"><T id="editor.form.loading" /></p></section>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="cms-grid">
|
|
95
|
+
{/* Left: type chooser + live draft list */}
|
|
96
|
+
<section className="cms-list">
|
|
97
|
+
<h2 className="label"><T id="editor.types.heading" /></h2>
|
|
98
|
+
<nav className="cms-types">
|
|
99
|
+
{typeList.map((t) => (
|
|
100
|
+
<button
|
|
101
|
+
key={t.name} type="button"
|
|
102
|
+
className={t.name === active ? 'chip active' : 'chip'}
|
|
103
|
+
onClick={() => onSelectType(t.name)}
|
|
104
|
+
>
|
|
105
|
+
{t.displayName}
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</nav>
|
|
109
|
+
|
|
110
|
+
<h3 className="label" style={{ marginTop: '1rem' }}>
|
|
111
|
+
<T id="editor.list.heading" values={{ type: activeType?.pluralName ?? '' }} />
|
|
112
|
+
</h3>
|
|
113
|
+
{drafts.length === 0 ? (
|
|
114
|
+
<p className="empty"><T id="editor.list.empty" /></p>
|
|
115
|
+
) : (
|
|
116
|
+
<ul className="rows">
|
|
117
|
+
{drafts.map((row) => (
|
|
118
|
+
<li key={row.id}>
|
|
119
|
+
<span className="grow">{row.title ?? row.slug ?? row.id}</span>
|
|
120
|
+
<span className="tag"><T id={statusKey(row.status)} /></span>
|
|
121
|
+
<button type="button" className="link" onClick={() => onEdit(row.id)}><T id="editor.row.edit" /></button>
|
|
122
|
+
<button type="button" className="link" onClick={() => onPublish(row.id)}><T id="editor.row.publish" /></button>
|
|
123
|
+
<button type="button" className="link" onClick={() => onUnpublish(row.id)}><T id="editor.row.unpublish" /></button>
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
)}
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
{/* Right: the auto-generated form */}
|
|
131
|
+
<section className="cms-editor">
|
|
132
|
+
<h2>
|
|
133
|
+
{editingId
|
|
134
|
+
? <T id="editor.form.editing" />
|
|
135
|
+
: <T id="editor.form.new" values={{ type: activeType?.displayName ?? '' }} />}
|
|
136
|
+
</h2>
|
|
137
|
+
{activeType ? (
|
|
138
|
+
<form onSubmit={onSubmit}>
|
|
139
|
+
<ContentForm contentType={toContentFormType(activeType)} value={values} onChange={setValues} />
|
|
140
|
+
{violations.length > 0 ? (
|
|
141
|
+
<ul className="violations" role="alert">
|
|
142
|
+
{violations.map((v) => <li key={v.field + v.rule}>{v.field}: {v.message}</li>)}
|
|
143
|
+
</ul>
|
|
144
|
+
) : null}
|
|
145
|
+
{saved ? <p className="ok"><T id="editor.form.saved" /></p> : null}
|
|
146
|
+
<div className="row" style={{ marginTop: '0.75rem' }}>
|
|
147
|
+
<button type="submit" className="btn" disabled={save.pending}>
|
|
148
|
+
<T id={save.pending ? 'editor.form.saving' : 'editor.form.save'} />
|
|
149
|
+
</button>
|
|
150
|
+
{editingId ? (
|
|
151
|
+
<button type="button" className="btn ghost" onClick={resetForm}><T id="editor.form.cancel" /></button>
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
</form>
|
|
155
|
+
) : null}
|
|
156
|
+
</section>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The auth form. We assert config + that it renders email/password fields and
|
|
4
|
+
// toggles between sign-in and sign-up. The network submit (CSRF + POST /auth/*)
|
|
5
|
+
// is exercised against a running api by `voltro dev`, not unit-mocked here.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
8
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
10
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
11
|
+
import enCatalog from '../../locales/en'
|
|
12
|
+
|
|
13
|
+
const { default: Login, meta, renderMode } = await import('./page')
|
|
14
|
+
|
|
15
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
16
|
+
|
|
17
|
+
let container: HTMLDivElement
|
|
18
|
+
let root: Root
|
|
19
|
+
|
|
20
|
+
const render = (node: ReactNode): void => {
|
|
21
|
+
container = document.createElement('div')
|
|
22
|
+
document.body.appendChild(container)
|
|
23
|
+
act(() => {
|
|
24
|
+
root = createRoot(container)
|
|
25
|
+
root.render(
|
|
26
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (root) act(() => root.unmount())
|
|
33
|
+
container?.remove()
|
|
34
|
+
document.body.innerHTML = ''
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('login — config', () => {
|
|
38
|
+
test('is a static page titled Sign in', () => {
|
|
39
|
+
expect(renderMode).toBe('static')
|
|
40
|
+
expect(meta({ locale: 'en' }).title).toBe('Sign in')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('login — form', () => {
|
|
45
|
+
test('renders email + password inputs and defaults to sign-in', () => {
|
|
46
|
+
render(createElement(Login))
|
|
47
|
+
expect(container.querySelector('input[type="email"]')).not.toBeNull()
|
|
48
|
+
expect(container.querySelector('input[type="password"]')).not.toBeNull()
|
|
49
|
+
expect(container.querySelector('h1')?.textContent).toBe('Sign in to the CMS')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('toggling switches to the create-account heading', () => {
|
|
53
|
+
render(createElement(Login))
|
|
54
|
+
const toggle = container.querySelector('.link')!
|
|
55
|
+
act(() => { toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
|
56
|
+
expect(container.querySelector('h1')?.textContent).toBe('Create an editor account')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Sign in / sign up — URL `/login`. A REAL auth form: it POSTs to the api's
|
|
2
|
+
// `/auth/*` routes (contributed by @voltro/plugin-auth on api-cms), which set
|
|
3
|
+
// the HttpOnly session cookie. State-changing auth routes are CSRF-protected, so
|
|
4
|
+
// we fetch a token first. On success we do a FULL navigation so the editor's SSR
|
|
5
|
+
// gate re-runs with the freshly-set cookie.
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { useState, type FormEvent } from 'react'
|
|
8
|
+
import type { PageMeta } from '@voltro/web'
|
|
9
|
+
import { T, useT } from '@voltro/i18n'
|
|
10
|
+
import { getCatalog } from '../../locales'
|
|
11
|
+
|
|
12
|
+
export const renderMode = 'static' as const
|
|
13
|
+
|
|
14
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
15
|
+
title: getCatalog(locale)['meta.login.title'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type Mode = 'signIn' | 'signUp'
|
|
19
|
+
|
|
20
|
+
async function submitAuth(mode: Mode, email: string, password: string): Promise<boolean> {
|
|
21
|
+
const csrf = (await fetch('/auth/csrf').then((r) => r.json())) as { csrfToken: string }
|
|
22
|
+
const path = mode === 'signUp' ? '/auth/sign-up' : '/auth/sign-in'
|
|
23
|
+
const res = await fetch(path, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf.csrfToken },
|
|
26
|
+
body: JSON.stringify({ email, password }),
|
|
27
|
+
})
|
|
28
|
+
return res.ok
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function Login(): ReactNode {
|
|
32
|
+
const [mode, setMode] = useState<Mode>('signIn')
|
|
33
|
+
const [email, setEmail] = useState('')
|
|
34
|
+
const [password, setPassword] = useState('')
|
|
35
|
+
const [pending, setPending] = useState(false)
|
|
36
|
+
const [failed, setFailed] = useState(false)
|
|
37
|
+
const emailLabel = useT('auth.email')
|
|
38
|
+
const passwordLabel = useT('auth.password')
|
|
39
|
+
|
|
40
|
+
const onSubmit = (e: FormEvent): void => {
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
setPending(true)
|
|
43
|
+
setFailed(false)
|
|
44
|
+
void submitAuth(mode, email, password)
|
|
45
|
+
.then((ok) => {
|
|
46
|
+
if (ok) {
|
|
47
|
+
const from = new URLSearchParams(window.location.search).get('from') ?? '/'
|
|
48
|
+
window.location.assign(from)
|
|
49
|
+
} else {
|
|
50
|
+
setFailed(true)
|
|
51
|
+
setPending(false)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {
|
|
55
|
+
setFailed(true)
|
|
56
|
+
setPending(false)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<section className="hero">
|
|
62
|
+
<h1><T id={mode === 'signUp' ? 'auth.signUp.heading' : 'auth.signIn.heading'} /></h1>
|
|
63
|
+
<form className="auth-form" onSubmit={onSubmit}>
|
|
64
|
+
<label>
|
|
65
|
+
<span><T id="auth.email" /></span>
|
|
66
|
+
<input
|
|
67
|
+
type="email" required aria-label={emailLabel} value={email}
|
|
68
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
<label>
|
|
72
|
+
<span><T id="auth.password" /></span>
|
|
73
|
+
<input
|
|
74
|
+
type="password" required aria-label={passwordLabel} value={password}
|
|
75
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
76
|
+
/>
|
|
77
|
+
</label>
|
|
78
|
+
{failed ? <p className="error-text"><T id="auth.error" /></p> : null}
|
|
79
|
+
<button type="submit" className="btn" disabled={pending}>
|
|
80
|
+
<T id={pending ? 'auth.pending' : mode === 'signUp' ? 'auth.signUp.submit' : 'auth.signIn.submit'} />
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
<p style={{ marginTop: '1rem' }}>
|
|
84
|
+
<button
|
|
85
|
+
type="button" className="link"
|
|
86
|
+
onClick={() => { setMode((m) => (m === 'signIn' ? 'signUp' : 'signIn')); setFailed(false) }}
|
|
87
|
+
>
|
|
88
|
+
<T id={mode === 'signIn' ? 'auth.toggle.toSignUp' : 'auth.toggle.toSignIn'} />
|
|
89
|
+
</button>
|
|
90
|
+
</p>
|
|
91
|
+
</section>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "frontend-cms",
|
|
3
|
+
"kind": "web",
|
|
4
|
+
"summary": "The CMS editor UI for the api-cms backend: a sign-in page (POST /auth/*), an SSR auth gate (session.me → redirect anonymous editors to /login), and a content editor that fetches the registered content types (content.types) and auto-generates a form per type with @voltro/cms's <ContentForm> — create/edit drafts (content.saveDraft, validation surfaced inline), live-list them (content.list), and publish/unpublish. Bilingual (en/de).",
|
|
5
|
+
"tags": ["web", "frontend", "cms", "editor", "content", "auth", "ssr", "ContentForm"]
|
|
6
|
+
}
|
|
@@ -15,18 +15,18 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@effect/platform": "^0.97.0",
|
|
18
|
-
"@voltro/cli": "0.
|
|
19
|
-
"@voltro/client": "0.
|
|
20
|
-
"@voltro/env": "0.
|
|
21
|
-
"@voltro/i18n": "0.
|
|
22
|
-
"@voltro/serverless": "0.
|
|
23
|
-
"@voltro/web": "0.
|
|
18
|
+
"@voltro/cli": "0.29.0",
|
|
19
|
+
"@voltro/client": "0.29.0",
|
|
20
|
+
"@voltro/env": "0.29.0",
|
|
21
|
+
"@voltro/i18n": "0.29.0",
|
|
22
|
+
"@voltro/serverless": "0.29.0",
|
|
23
|
+
"@voltro/web": "0.29.0",
|
|
24
24
|
"effect": "^3.22.0",
|
|
25
25
|
"react": "^19.0.0",
|
|
26
26
|
"react-dom": "^19.0.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@voltro/devtools": "0.
|
|
29
|
+
"@voltro/devtools": "0.29.0",
|
|
30
30
|
"@types/react": "^19.0.0",
|
|
31
31
|
"@types/react-dom": "^19.0.0",
|
|
32
32
|
"typescript": "^6.0.3",
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/client": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/i18n": "0.29.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
20
|
+
"@voltro/web": "0.29.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.29.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/client": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/i18n": "0.29.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
20
|
+
"@voltro/web": "0.29.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.29.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/web": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/client": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/i18n": "0.29.0",
|
|
19
|
+
"@voltro/web": "0.29.0",
|
|
20
20
|
"react": "^19.0.0",
|
|
21
21
|
"react-dom": "^19.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/devtools": "0.
|
|
24
|
+
"@voltro/devtools": "0.29.0",
|
|
25
25
|
"@types/react": "^19.0.0",
|
|
26
26
|
"@types/react-dom": "^19.0.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/client": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/i18n": "0.29.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
20
|
+
"@voltro/web": "0.29.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.29.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — billing & account portal
|
|
2
|
+
|
|
3
|
+
The lean "manage your account" surface customers visit outside the product:
|
|
4
|
+
plan, payment method, invoices, API keys, profile. Consumes the
|
|
5
|
+
**api-saas-starter** backend; billing is plugin-orchestrated, so the template
|
|
6
|
+
never renders an invoice or holds a card. Bilingual (en/de).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
voltro init my-saas --api=api-saas-starter --web=frontend-portal
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Pages
|
|
13
|
+
|
|
14
|
+
| URL | Reads / drives | Notes |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `/` | `billing.subscription` | overview: plan, status, renewal + quick links |
|
|
17
|
+
| `/billing` | `billing.subscription`, `billing.portalUrl`, `billing.previewChange`, `billing.changePlan` | plan card; card management redirects to the provider portal; a plan change QUOTES the provider's proration before applying |
|
|
18
|
+
| `/invoices` | `billing.invoices` | list; "View" / "PDF" link out to the provider's hosted pages |
|
|
19
|
+
| `/api-keys` | `GET/POST /v1/api-keys*` | the framework's built-in key management (see below) |
|
|
20
|
+
| `/profile` | `session.me`, `/auth/password-reset`, `/auth/sign-out` | identity, self-serve password reset, sign out |
|
|
21
|
+
| `/login` | `POST /auth/*` | sign-in / sign-up |
|
|
22
|
+
|
|
23
|
+
`(portal)/layout.tsx` is the SSR auth gate — it asks `session.me` server-side and
|
|
24
|
+
RedirectErrors an anonymous visitor to `/login`.
|
|
25
|
+
|
|
26
|
+
## API keys — one config step on the API
|
|
27
|
+
|
|
28
|
+
The API-keys page drives the framework's **built-in** management surface
|
|
29
|
+
(`/v1/api-keys`), which the API exposes only when it sets `apiKeys: true` in its
|
|
30
|
+
`app.config.ts`. **api-saas-starter does not enable it by default**, so out of the
|
|
31
|
+
box this page shows a clear "enable it" notice rather than a broken table. To make
|
|
32
|
+
it live:
|
|
33
|
+
|
|
34
|
+
1. On the API: `export default { …, apiKeys: true }`.
|
|
35
|
+
2. Grant the signed-in user an issuing scope (`apikey:issue:self`) or `admin` —
|
|
36
|
+
the management routes are admin-scoped by design (keys are org credentials).
|
|
37
|
+
|
|
38
|
+
The raw key is shown exactly **once** at mint time (hash-stored, unrecoverable).
|
|
39
|
+
The page degrades on `404` (not enabled), `401/403` (no scope), and errors.
|
|
40
|
+
|
|
41
|
+
## What's intentionally NOT here
|
|
42
|
+
|
|
43
|
+
Per the lean brief: **no** support-ticket system and **no** team/member
|
|
44
|
+
management — those belong in the product's own dashboard or a dedicated tool.
|
|
45
|
+
Seat changes stay with the dashboard (`billing.changeSeats`); this portal is plan,
|
|
46
|
+
payment, invoices, keys, profile.
|
|
47
|
+
|
|
48
|
+
## Files
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
app.config.ts web app; apis.app → @{{projectName}}/api (api-saas-starter)
|
|
52
|
+
src/lib/api.ts wire shapes (subscription/invoice/apiKey) + formatMoney
|
|
53
|
+
src/pages/
|
|
54
|
+
login/page.tsx(.test) sign-in / sign-up (POST /auth/*)
|
|
55
|
+
(portal)/layout.tsx(.test) shell + SSR auth gate (session.me)
|
|
56
|
+
(portal)/page.tsx overview
|
|
57
|
+
(portal)/billing/page.tsx(.test) plan + payment method + quoted plan change
|
|
58
|
+
(portal)/invoices/page.tsx invoice history (links out)
|
|
59
|
+
(portal)/api-keys/page.tsx(.test) framework key management (graceful)
|
|
60
|
+
(portal)/profile/page.tsx identity + password reset + sign out
|
|
61
|
+
(portal)/error.tsx · not-found.tsx
|
|
62
|
+
src/locales/{en,de}.ts bilingual catalogs (parity-enforced)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Going to production
|
|
66
|
+
|
|
67
|
+
- **Real Stripe** — configure `billingPlugin({ provider: 'stripe', … })` on the
|
|
68
|
+
API; `billing.portalUrl` then opens Stripe's real hosted portal and
|
|
69
|
+
`billing.invoices` returns real `hostedUrl`/`pdfUrl` links.
|
|
70
|
+
- **Email** — wire `sendEmail` on the API so the profile password-reset actually
|
|
71
|
+
delivers.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Web app for the {{projectName}} project — the customer BILLING + ACCOUNT
|
|
2
|
+
// portal over the sibling `api-saas-starter` api. Slim by design: the screens a
|
|
3
|
+
// customer visits to manage their account OUTSIDE the product — plan, payment
|
|
4
|
+
// method, invoices, API keys, profile.
|
|
5
|
+
//
|
|
6
|
+
// - `/login` — static sign-in that POSTs to the api's `/auth/*`.
|
|
7
|
+
// - The authed portal in the SILENT route group `src/pages/(portal)/`. Its
|
|
8
|
+
// `layout.tsx` loader is the auth gate (asks the api's `session.me`,
|
|
9
|
+
// RedirectErrors an anonymous visitor to /login). Its pages are
|
|
10
|
+
// `renderMode: 'ssr'` so the gate runs per request.
|
|
11
|
+
//
|
|
12
|
+
// Billing is plugin-orchestrated: `billing.subscription` / `billing.invoices`
|
|
13
|
+
// are live reads, and card management / plan changes go through the provider via
|
|
14
|
+
// `billing.portalUrl` / `billing.changePlan` — the template never renders an
|
|
15
|
+
// invoice or holds a card itself.
|
|
16
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
17
|
+
|
|
18
|
+
export const env = defineEnv({
|
|
19
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'web' as const,
|
|
24
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
25
|
+
port: {{port}},
|
|
26
|
+
theme: 'system' as const,
|
|
27
|
+
env,
|
|
28
|
+
locales: ['en', 'de'] as const,
|
|
29
|
+
defaultLocale: 'en' as const,
|
|
30
|
+
// The api this portal consumes. Pair with api-saas-starter:
|
|
31
|
+
// `voltro init my-saas --api=api-saas-starter --web=frontend-portal`.
|
|
32
|
+
// The hooks pass 'app' as the lookup key; codegen pulls its typed rpc surface
|
|
33
|
+
// (session.me + the billing plugin's `billing.*`).
|
|
34
|
+
apis: {
|
|
35
|
+
app: { package: '@{{projectName}}/api' },
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"build": "voltro build .",
|
|
10
|
+
"start": "voltro start .",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "voltro test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@{{projectName}}/api": "0.29.0",
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/client": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/i18n": "0.29.0",
|
|
20
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
21
|
+
"@voltro/web": "0.29.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@voltro/devtools": "0.29.0",
|
|
27
|
+
"@types/react": "^19.0.0",
|
|
28
|
+
"@types/react-dom": "^19.0.0",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The brand name shown in the marketing header + dashboard sidebar.
|
|
2
|
+
//
|
|
3
|
+
// A plain constant — not `publicEnv` — so the template typechecks BEFORE
|
|
4
|
+
// `voltro dev` generates the env types. `VOLTRO_PUBLIC_APP_NAME` is still
|
|
5
|
+
// declared in app.config.ts (validated + in the env manifest); to read it typed
|
|
6
|
+
// instead, after one `voltro dev` swap this for
|
|
7
|
+
// `publicEnv.VOLTRO_PUBLIC_APP_NAME` from '@voltro/env/public'.
|
|
8
|
+
export const APP_NAME = '{{capProjectName}}'
|