create-ailk 0.21.0 → 0.23.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/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* (h) the email sign-in link (#7435): with "magic-link" active the form
|
|
22
22
|
* renders beside the providers (or alone); a `?error=` from a failed
|
|
23
23
|
* link maps to fixed copy and is never echoed.
|
|
24
|
+
* (i) the hinted email (#8757 D9): a valid single `?email=` is the starting
|
|
25
|
+
* value of every email input of the card; an invalid or repeated value,
|
|
26
|
+
* or none, leaves them empty as before. It signs no one in.
|
|
24
27
|
*
|
|
25
28
|
* `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
|
|
26
29
|
* the real en.json, so `signIn` copy is asserted by its real strings.
|
|
@@ -58,6 +61,21 @@ jest.mock("better-auth/react", () => ({
|
|
|
58
61
|
}),
|
|
59
62
|
}));
|
|
60
63
|
|
|
64
|
+
// #8790 D2 — the spoke back link. The hub's secret and the registry read are
|
|
65
|
+
// the two boundaries the page crosses; the continuation itself is REAL
|
|
66
|
+
// (minted below with the same test secret), so the sealed-ref path is covered.
|
|
67
|
+
const TEST_SECRET = "test-secret-for-the-sign-in-page-suite";
|
|
68
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts is Better Auth's server singleton (a DB/HTTP boundary)
|
|
69
|
+
jest.mock("../../../../../auth", () => ({
|
|
70
|
+
__esModule: true,
|
|
71
|
+
resolveSecret: () => TEST_SECRET,
|
|
72
|
+
}));
|
|
73
|
+
const mockClientInfo = jest.fn();
|
|
74
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
75
|
+
__esModule: true,
|
|
76
|
+
hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
|
|
77
|
+
}));
|
|
78
|
+
import { mintContinuation } from "../../../../api/auth/hub/continuation";
|
|
61
79
|
import en from "../../../../../messages/en.json";
|
|
62
80
|
import Page, { generateMetadata } from "../page";
|
|
63
81
|
|
|
@@ -124,7 +142,9 @@ describe("/sign-in page", () => {
|
|
|
124
142
|
expect(screen.queryByTestId("auth-sso-github")).toBeNull();
|
|
125
143
|
expect(screen.queryByTestId("auth-submit")).toBeNull();
|
|
126
144
|
expect(screen.queryByTestId("auth-email")).toBeNull();
|
|
127
|
-
expect(
|
|
145
|
+
expect(
|
|
146
|
+
screen.getByText(en.signIn.notConfiguredSubhead),
|
|
147
|
+
).toBeInTheDocument();
|
|
128
148
|
expect(mockSocial).not.toHaveBeenCalled();
|
|
129
149
|
});
|
|
130
150
|
|
|
@@ -144,7 +164,9 @@ describe("/sign-in page", () => {
|
|
|
144
164
|
mockSocial.mockResolvedValue({ data: null, error: { message: "nope" } });
|
|
145
165
|
await renderPage("/account");
|
|
146
166
|
await user.click(screen.getByTestId("auth-sso-google"));
|
|
147
|
-
expect(
|
|
167
|
+
expect(
|
|
168
|
+
await screen.findByText(en.signIn.providerFailed),
|
|
169
|
+
).toBeInTheDocument();
|
|
148
170
|
});
|
|
149
171
|
|
|
150
172
|
it("(e) a protocol-relative ?next= falls back to /account", async () => {
|
|
@@ -213,27 +235,49 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
|
|
|
213
235
|
it("(h) with google + magic-link: the Google button and 'Email me a sign-in link'", async () => {
|
|
214
236
|
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
215
237
|
await renderPage("/account");
|
|
216
|
-
|
|
217
|
-
expect(screen.getByTestId("auth-
|
|
238
|
+
// Google sits under the "or" as a social button, no longer an SSO stack.
|
|
239
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
240
|
+
expect(screen.queryByTestId("auth-sso-google")).toBeNull();
|
|
241
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
218
242
|
en.signIn.emailLinkSubmit,
|
|
219
243
|
);
|
|
220
244
|
expect(en.signIn.emailLinkSubmit).toBe("Email me a sign-in link");
|
|
221
|
-
|
|
245
|
+
// One "or" divider between the primary and Google (#8790 D5).
|
|
246
|
+
expect(screen.getAllByTestId("auth-divider")).toHaveLength(1);
|
|
247
|
+
expect(screen.queryByTestId("auth-email-link-divider")).toBeNull();
|
|
222
248
|
});
|
|
223
249
|
|
|
224
250
|
it("(h) with magic-link only: the form renders and the not-configured subhead does not", async () => {
|
|
225
251
|
mockGetActiveProviderIds.mockReturnValue(["magic-link"]);
|
|
226
252
|
await renderPage();
|
|
227
|
-
expect(screen.getByTestId("auth-email
|
|
253
|
+
expect(screen.getByTestId("auth-email")).toBeInTheDocument();
|
|
228
254
|
expect(screen.queryByText(en.signIn.notConfiguredSubhead)).toBeNull();
|
|
229
|
-
|
|
255
|
+
// "Welcome back" stands alone — no subhead (#8790 D3).
|
|
256
|
+
expect(screen.getByText(en.signIn.heading)).toBeInTheDocument();
|
|
257
|
+
expect(en.signIn.heading).toBe("Welcome back");
|
|
258
|
+
expect(screen.queryByTestId("auth-method-switch")).toBeNull();
|
|
230
259
|
});
|
|
231
260
|
|
|
232
261
|
it("(h) beside the password form when the password method is active too", async () => {
|
|
233
262
|
mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
|
|
234
263
|
await renderPage("/account");
|
|
264
|
+
// The sign-in link leads; the password is one quiet switch away, in place
|
|
265
|
+
// (#8790 D4), and the typed email survives the switch.
|
|
266
|
+
expect(screen.queryByTestId("auth-password")).toBeNull();
|
|
267
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
268
|
+
en.signIn.emailLinkSubmit,
|
|
269
|
+
);
|
|
270
|
+
const user = userEvent.setup();
|
|
271
|
+
await user.type(screen.getByTestId("auth-email"), "member@example.com");
|
|
272
|
+
await user.click(screen.getByTestId("auth-method-switch"));
|
|
235
273
|
expect(screen.getByTestId("auth-password")).toBeInTheDocument();
|
|
236
|
-
expect(screen.getByTestId("auth-email
|
|
274
|
+
expect(screen.getByTestId("auth-email")).toHaveValue("member@example.com");
|
|
275
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
276
|
+
en.signIn.submit,
|
|
277
|
+
);
|
|
278
|
+
expect(screen.getByTestId("auth-method-switch")).toHaveTextContent(
|
|
279
|
+
en.signIn.useEmailLink,
|
|
280
|
+
);
|
|
237
281
|
});
|
|
238
282
|
|
|
239
283
|
it.each(["INVALID_TOKEN", "new_user_signup_disabled"])(
|
|
@@ -263,3 +307,111 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
|
|
|
263
307
|
expect(screen.queryByTestId("sign-in-link-notice")).toBeNull();
|
|
264
308
|
});
|
|
265
309
|
});
|
|
310
|
+
|
|
311
|
+
describe("/sign-in page — the spoke back link (#8790 D2)", () => {
|
|
312
|
+
beforeEach(() => {
|
|
313
|
+
mockClientInfo.mockReset();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("names the spoke from the registry when the continuation opens", async () => {
|
|
317
|
+
mockClientInfo.mockResolvedValue({
|
|
318
|
+
ok: true,
|
|
319
|
+
body: {
|
|
320
|
+
clientId: "spoke_1",
|
|
321
|
+
name: "Wendi Henry",
|
|
322
|
+
origin: "https://wendihenry.com",
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
const ref = mintContinuation(
|
|
326
|
+
"client_id=spoke_1&response_type=code",
|
|
327
|
+
TEST_SECRET,
|
|
328
|
+
);
|
|
329
|
+
await renderPage(`/api/auth/hub/continue/${ref}`);
|
|
330
|
+
expect(mockClientInfo).toHaveBeenCalledWith("spoke_1");
|
|
331
|
+
const back = screen.getByTestId("auth-back-link");
|
|
332
|
+
expect(back).toHaveAttribute("href", "https://wendihenry.com/account");
|
|
333
|
+
// This suite's next-intl shim returns messages uninterpolated, so the
|
|
334
|
+
// label's shape is what is asserted here; the ICU fill is next-intl's.
|
|
335
|
+
expect(back).toHaveTextContent(/^Back to/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("renders no back link when the registry does not know the client", async () => {
|
|
339
|
+
mockClientInfo.mockResolvedValue({ ok: false, status: 404 });
|
|
340
|
+
const ref = mintContinuation("client_id=spoke_gone", TEST_SECRET);
|
|
341
|
+
await renderPage(`/api/auth/hub/continue/${ref}`);
|
|
342
|
+
expect(screen.queryByTestId("auth-back-link")).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("renders no back link on a direct visit", async () => {
|
|
346
|
+
await renderPage("/account");
|
|
347
|
+
expect(screen.queryByTestId("auth-back-link")).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("renders no back link, and no error, when the continuation cannot be opened", async () => {
|
|
351
|
+
// A continuation-shaped `next` whose ref is garbage: the secret load or
|
|
352
|
+
// the open throws, and the page degrades to the direct-visit render.
|
|
353
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
354
|
+
await renderPage("/api/auth/hub/continue/not-a-real-continuation");
|
|
355
|
+
expect(screen.queryByTestId("auth-back-link")).toBeNull();
|
|
356
|
+
expect(screen.getByTestId("auth-email")).toBeInTheDocument();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("/sign-in page — the hinted email (#8757 D9)", () => {
|
|
361
|
+
async function renderWithEmail(email?: string | string[]): Promise<void> {
|
|
362
|
+
const el = await Page({
|
|
363
|
+
params: Promise.resolve({ locale: "en" }),
|
|
364
|
+
searchParams: Promise.resolve({
|
|
365
|
+
next: "/account",
|
|
366
|
+
...(email === undefined ? {} : { email }),
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
render(el);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function emailInputs(): HTMLInputElement[] {
|
|
373
|
+
return Array.from(
|
|
374
|
+
document.querySelectorAll<HTMLInputElement>('input[type="email"]'),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
// One email input on the card (#8790 D4): the sign-in link and the password share it.
|
|
380
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("(i) ?email=a%40b.example → the card's email input has that value", async () => {
|
|
384
|
+
// Next hands the page the decoded value.
|
|
385
|
+
await renderWithEmail("a@b.example");
|
|
386
|
+
const inputs = emailInputs();
|
|
387
|
+
expect(inputs).toHaveLength(1);
|
|
388
|
+
expect(screen.getByTestId("auth-email")).toHaveValue("a@b.example");
|
|
389
|
+
for (const input of inputs) expect(input).toHaveValue("a@b.example");
|
|
390
|
+
// A starting value only — nothing was sent.
|
|
391
|
+
expect(mockEmail).not.toHaveBeenCalled();
|
|
392
|
+
expect(mockSocial).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it.each([
|
|
396
|
+
["an invalid value (?email=nope)", "nope"],
|
|
397
|
+
[
|
|
398
|
+
"a repeated value (?email=a%40b.example&email=c%40d.example)",
|
|
399
|
+
["a@b.example", "c@d.example"],
|
|
400
|
+
],
|
|
401
|
+
["a value carrying a query separator", "a@b.example&next=/x"],
|
|
402
|
+
["no parameter", undefined],
|
|
403
|
+
] as const)(
|
|
404
|
+
"(i) %s → empty email inputs, as today",
|
|
405
|
+
async (_label, email) => {
|
|
406
|
+
await renderWithEmail(
|
|
407
|
+
email === undefined || typeof email === "string" ? email : [...email],
|
|
408
|
+
);
|
|
409
|
+
const inputs = emailInputs();
|
|
410
|
+
expect(inputs).toHaveLength(1);
|
|
411
|
+
for (const input of inputs) {
|
|
412
|
+
expect(input).toHaveValue("");
|
|
413
|
+
expect(input).not.toHaveAttribute("value");
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
});
|
|
@@ -53,8 +53,10 @@ function props(overrides: Partial<SignInCardProps> = {}): SignInCardProps {
|
|
|
53
53
|
|
|
54
54
|
async function requestLink(email = "member@example.com"): Promise<void> {
|
|
55
55
|
const user = userEvent.setup();
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// #8790 D4 — one field, one action: the sign-in link is the card's own
|
|
57
|
+
// email field and primary, not a second form.
|
|
58
|
+
await user.type(screen.getByTestId("auth-email"), email);
|
|
59
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
beforeEach(() => {
|
|
@@ -89,7 +91,7 @@ describe("SignInCard — email sign-in link (#7435)", () => {
|
|
|
89
91
|
expect(back).toHaveAttribute("href", "/sign-in?next=%2Faccount");
|
|
90
92
|
expect(document.body.textContent).not.toContain("member@example.com");
|
|
91
93
|
expect(document.body.textContent).not.toContain("Follow the link we just sent");
|
|
92
|
-
expect(screen.queryByTestId("auth-
|
|
94
|
+
expect(screen.queryByTestId("auth-submit")).toBeNull();
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
it("lands rateLimited on the field for a 429", async () => {
|
|
@@ -102,7 +104,7 @@ describe("SignInCard — email sign-in link (#7435)", () => {
|
|
|
102
104
|
expect(
|
|
103
105
|
await screen.findByText(EMAIL_LINK_LABELS.rateLimited),
|
|
104
106
|
).toBeInTheDocument();
|
|
105
|
-
expect(screen.getByTestId("auth-
|
|
107
|
+
expect(screen.getByTestId("auth-submit")).toBeInTheDocument();
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it("lands failed on the field for any other error", async () => {
|
|
@@ -127,9 +129,9 @@ describe("SignInCard — email sign-in link (#7435)", () => {
|
|
|
127
129
|
mockFetch.mockReturnValue(new Promise((r) => (resolve = r)));
|
|
128
130
|
const user = userEvent.setup();
|
|
129
131
|
render(<SignInCard {...props()} />);
|
|
130
|
-
await user.type(screen.getByTestId("auth-email
|
|
131
|
-
await user.click(screen.getByTestId("auth-
|
|
132
|
-
await user.click(screen.getByTestId("auth-
|
|
132
|
+
await user.type(screen.getByTestId("auth-email"), "member@example.com");
|
|
133
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
134
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
133
135
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
134
136
|
resolve({ data: { status: true }, error: null });
|
|
135
137
|
expect(await screen.findByText(EMAIL_LINK_LABELS.sentHeading)).toBeInTheDocument();
|
|
@@ -144,13 +146,14 @@ describe("SignInCard — email sign-in link (#7435)", () => {
|
|
|
144
146
|
|
|
145
147
|
it("renders the form alone, without the not-configured subhead, when it is the only method", () => {
|
|
146
148
|
render(<SignInCard {...props({ providers: [] })} />);
|
|
147
|
-
expect(screen.getByTestId("auth-email
|
|
149
|
+
expect(screen.getByTestId("auth-email")).toBeInTheDocument();
|
|
150
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent("Email me a sign-in link");
|
|
148
151
|
expect(screen.queryByText("Sign-in is not configured.")).toBeNull();
|
|
149
152
|
});
|
|
150
153
|
|
|
151
154
|
it("renders no email-link form when emailLink is false", () => {
|
|
152
155
|
render(<SignInCard {...props({ emailLink: false, providers: [] })} />);
|
|
153
|
-
expect(screen.queryByTestId("auth-
|
|
156
|
+
expect(screen.queryByTestId("auth-submit")).toBeNull();
|
|
154
157
|
expect(screen.getByText("Sign-in is not configured.")).toBeInTheDocument();
|
|
155
158
|
});
|
|
156
159
|
});
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* fails verification lands back here with Better Auth's `?error=`, which maps
|
|
12
12
|
* to FIXED copy; the raw value is never rendered.
|
|
13
13
|
*
|
|
14
|
+
* Hinted email (#8757 D9): a single `?email=` that `accountEmailHintSchema`
|
|
15
|
+
* accepts is the starting value of every email input of the card (an account
|
|
16
|
+
* link from a lead's thank-you or invite email). A refused or repeated value
|
|
17
|
+
* is dropped. Only a field's starting value — it signs no one in.
|
|
18
|
+
*
|
|
14
19
|
* Since #5963 the page lives in the `(auth)` route group — the URL is
|
|
15
20
|
* unchanged (a group contributes no path segment) but the frame is
|
|
16
21
|
* `AuthShell`, not the marketing Nav + Footer. The shell owns the brand mark
|
|
@@ -22,6 +27,7 @@
|
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { localeHref } from "@working-theory/ui";
|
|
30
|
+
import { accountEmailHintSchema } from "@working-theory/validation";
|
|
25
31
|
import type { Metadata } from "next";
|
|
26
32
|
import { getTranslations } from "next-intl/server";
|
|
27
33
|
import type { ReactElement } from "react";
|
|
@@ -31,13 +37,34 @@ import {
|
|
|
31
37
|
resetSendConfigured,
|
|
32
38
|
} from "../../../../lib/auth-env";
|
|
33
39
|
|
|
40
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
41
|
+
import { Link, Text } from "@working-theory/ui/primitives";
|
|
42
|
+
import { siteLogo } from "../../../../lib/site-logo";
|
|
43
|
+
import {
|
|
44
|
+
CONTINUE_PATH,
|
|
45
|
+
openContinuation,
|
|
46
|
+
} from "../../../api/auth/hub/continuation";
|
|
34
47
|
import { resolveNextPath } from "./next-path";
|
|
35
48
|
import { SignInCard, type SignInProvider } from "./sign-in-card";
|
|
36
49
|
import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
|
|
37
50
|
|
|
38
51
|
interface Props {
|
|
39
52
|
params: Promise<{ locale: string }>;
|
|
40
|
-
searchParams: Promise<{
|
|
53
|
+
searchParams: Promise<{
|
|
54
|
+
next?: string | string[];
|
|
55
|
+
error?: string | string[];
|
|
56
|
+
email?: string | string[];
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* #8757 D9 — the hinted email, read once: a repeated `?email=` (an array) or a
|
|
62
|
+
* value the hint schema refuses is dropped, never echoed.
|
|
63
|
+
*/
|
|
64
|
+
function hintedEmail(raw: string | string[] | undefined): string | undefined {
|
|
65
|
+
if (typeof raw !== "string") return undefined;
|
|
66
|
+
const parsed = accountEmailHintSchema.safeParse(raw);
|
|
67
|
+
return parsed.success ? parsed.data : undefined;
|
|
41
68
|
}
|
|
42
69
|
|
|
43
70
|
/**
|
|
@@ -67,12 +94,46 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
67
94
|
return { title: t("title"), description: t("description") };
|
|
68
95
|
}
|
|
69
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The spoke a hub sign-in continuation came from (#8790 D2), or `null`: not
|
|
99
|
+
* a continuation, a continuation that does not open (expired, forged), a
|
|
100
|
+
* client id the registry does not know, or no API. The spoke's identity is
|
|
101
|
+
* read from the registry by `client_id` — never from a query parameter.
|
|
102
|
+
*/
|
|
103
|
+
async function spokeFromNext(
|
|
104
|
+
next: string,
|
|
105
|
+
): Promise<{ name: string; origin: string } | null> {
|
|
106
|
+
const prefix = `${CONTINUE_PATH}/`;
|
|
107
|
+
if (!next.startsWith(prefix)) return null;
|
|
108
|
+
let query: string | null;
|
|
109
|
+
try {
|
|
110
|
+
// Lazy: `auth.ts` is Better Auth's server module. Only a continuation
|
|
111
|
+
// needs its secret, so a direct visit — and every test of one — never
|
|
112
|
+
// loads it (the `(authed)` gate takes the same care).
|
|
113
|
+
const { resolveSecret } = await import("../../../../auth");
|
|
114
|
+
query = openContinuation(next.slice(prefix.length), resolveSecret());
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (query === null) return null;
|
|
119
|
+
const clientId = new URLSearchParams(query).get("client_id");
|
|
120
|
+
if (!clientId) return null;
|
|
121
|
+
try {
|
|
122
|
+
const result = await hubSignIn.clientInfo(clientId);
|
|
123
|
+
return result.ok
|
|
124
|
+
? { name: result.body.name, origin: result.body.origin }
|
|
125
|
+
: null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
70
131
|
export default async function SignInPage({
|
|
71
132
|
params,
|
|
72
133
|
searchParams,
|
|
73
134
|
}: Props): Promise<ReactElement> {
|
|
74
135
|
const { locale } = await params;
|
|
75
|
-
const { next: rawNext, error } = await searchParams;
|
|
136
|
+
const { next: rawNext, error, email: rawEmail } = await searchParams;
|
|
76
137
|
const text = await resolveSignInCardText(locale);
|
|
77
138
|
|
|
78
139
|
const active = getActiveProviderIds();
|
|
@@ -87,9 +148,50 @@ export default async function SignInPage({
|
|
|
87
148
|
const signInHref = `${localeHref(locale, "/sign-in")}?next=${encodeURIComponent(next)}`;
|
|
88
149
|
// Only the email link produces `?error=` here; with it off, today's render.
|
|
89
150
|
const linkNotice = emailLink ? linkNoticeFor(error, text) : undefined;
|
|
151
|
+
const defaultEmail = hintedEmail(rawEmail);
|
|
90
152
|
|
|
153
|
+
const spoke = await spokeFromNext(next);
|
|
154
|
+
// The acknowledgement (#8790 D6) — the hub's own policy pages, through the
|
|
155
|
+
// inline link variant; the card never hardcodes a path.
|
|
156
|
+
// `caption` is the muted footnote role; a default `Text` would re-impose
|
|
157
|
+
// body size and the foreground colour over the card's footnote wrapper.
|
|
158
|
+
const footnote = (
|
|
159
|
+
<Text as="p" variant="caption">
|
|
160
|
+
{text.acknowledgement.lead}{" "}
|
|
161
|
+
<Link variant="inline" href={localeHref(locale, "/terms")}>
|
|
162
|
+
{text.acknowledgement.terms}
|
|
163
|
+
</Link>
|
|
164
|
+
,{" "}
|
|
165
|
+
<Link variant="inline" href={localeHref(locale, "/privacy")}>
|
|
166
|
+
{text.acknowledgement.privacy}
|
|
167
|
+
</Link>
|
|
168
|
+
, and{" "}
|
|
169
|
+
<Link variant="inline" href={localeHref(locale, "/working-agreement")}>
|
|
170
|
+
{text.acknowledgement.workingAgreement}
|
|
171
|
+
</Link>
|
|
172
|
+
.
|
|
173
|
+
</Text>
|
|
174
|
+
);
|
|
91
175
|
return (
|
|
92
176
|
<SignInCard
|
|
177
|
+
logo={siteLogo()}
|
|
178
|
+
{...(spoke
|
|
179
|
+
? {
|
|
180
|
+
backLink: {
|
|
181
|
+
href: `${spoke.origin}/account`,
|
|
182
|
+
label: text.backTo(spoke.name),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {})}
|
|
186
|
+
{...(text.prospect
|
|
187
|
+
? {
|
|
188
|
+
prospect: {
|
|
189
|
+
...text.prospect,
|
|
190
|
+
href: localeHref(locale, "/sign-up"),
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
: {})}
|
|
194
|
+
footnote={footnote}
|
|
93
195
|
providers={providers}
|
|
94
196
|
method={method}
|
|
95
197
|
next={next}
|
|
@@ -109,6 +211,7 @@ export default async function SignInPage({
|
|
|
109
211
|
errorCallbackURL={signInHref}
|
|
110
212
|
signInHref={signInHref}
|
|
111
213
|
{...(linkNotice !== undefined ? { linkNotice } : {})}
|
|
214
|
+
{...(defaultEmail !== undefined ? { defaultEmail } : {})}
|
|
112
215
|
emailLinkLabels={text.emailLinkLabels}
|
|
113
216
|
/>
|
|
114
217
|
);
|
|
@@ -45,15 +45,20 @@
|
|
|
45
45
|
* the same for an address with and without an account, so a success swaps to
|
|
46
46
|
* a sent state that never names the address. The gate composes this card too
|
|
47
47
|
* (`(authed)/sign-in-gate.tsx`).
|
|
48
|
+
*
|
|
49
|
+
* Hinted email (#8757 D9): the page hands in `defaultEmail` (already checked
|
|
50
|
+
* by `accountEmailHintSchema`); it reaches AuthCard as the starting value of
|
|
51
|
+
* every email input. Absent → AuthCard's render is unchanged.
|
|
48
52
|
*/
|
|
49
53
|
|
|
50
54
|
import {
|
|
55
|
+
AuthBackLink,
|
|
51
56
|
AuthCard,
|
|
52
57
|
type AuthCardLabels,
|
|
53
58
|
type AuthSubmitData,
|
|
54
59
|
} from "@working-theory/ui/blocks";
|
|
55
60
|
import { Alert, AlertDescription } from "@working-theory/ui/primitives";
|
|
56
|
-
import { useRef, useState, type ReactElement } from "react";
|
|
61
|
+
import { useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
57
62
|
|
|
58
63
|
import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
|
|
59
64
|
|
|
@@ -98,6 +103,16 @@ export interface SignInCardProps {
|
|
|
98
103
|
/** Fixed copy for a failed link (`?error=` mapped by the page), shown above the card. */
|
|
99
104
|
linkNotice?: string;
|
|
100
105
|
emailLinkLabels: SignInEmailLinkLabels;
|
|
106
|
+
/** #8757 D9 — the validated `?email=` hint, the email inputs' starting value. */
|
|
107
|
+
defaultEmail?: string;
|
|
108
|
+
/** The site's mark, centred in the card at 48px (#8790 D3). */
|
|
109
|
+
logo?: ReactNode;
|
|
110
|
+
/** "← Back to <spoke>" when the visitor came from a registered spoke (#8790 D2). */
|
|
111
|
+
backLink?: { href: string; label: string };
|
|
112
|
+
/** The prospect line a site turns on (#8790 D3). */
|
|
113
|
+
prospect?: { prompt: string; linkLabel: string; href: string };
|
|
114
|
+
/** The acknowledgement sentence(s) at the card's foot (#8790 D6). */
|
|
115
|
+
footnote?: ReactNode;
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
export function SignInCard({
|
|
@@ -114,6 +129,11 @@ export function SignInCard({
|
|
|
114
129
|
signInHref,
|
|
115
130
|
linkNotice,
|
|
116
131
|
emailLinkLabels,
|
|
132
|
+
defaultEmail,
|
|
133
|
+
logo,
|
|
134
|
+
backLink,
|
|
135
|
+
prospect,
|
|
136
|
+
footnote,
|
|
117
137
|
}: SignInCardProps): ReactElement {
|
|
118
138
|
const { errors, providerError, launchProvider, submitPassword } =
|
|
119
139
|
useAuthCardFlow(next, {
|
|
@@ -128,6 +148,12 @@ export function SignInCard({
|
|
|
128
148
|
|
|
129
149
|
const notConfigured =
|
|
130
150
|
method === "sso" && providers.length === 0 && !emailLink;
|
|
151
|
+
// One field, one action (#8790 D4): the sign-in link first when it is on,
|
|
152
|
+
// the password as the in-place alternative when the email provider is on.
|
|
153
|
+
const methods: ("magic-link" | "password")[] = [
|
|
154
|
+
...(emailLink ? (["magic-link"] as const) : []),
|
|
155
|
+
...(method === "password" ? (["password"] as const) : []),
|
|
156
|
+
];
|
|
131
157
|
|
|
132
158
|
const providerButtons = providers.map((provider) => ({
|
|
133
159
|
provider,
|
|
@@ -198,6 +224,7 @@ export function SignInCard({
|
|
|
198
224
|
* in empty space, first input at 470px" defect the auth route group fixes.
|
|
199
225
|
*/
|
|
200
226
|
<div className="flex flex-col gap-4" data-testid="sign-in-page">
|
|
227
|
+
{backLink ? <AuthBackLink href={backLink.href} label={backLink.label} /> : null}
|
|
201
228
|
{method === "sso" && providerError ? (
|
|
202
229
|
<Alert variant="error" data-testid="sign-in-provider-error">
|
|
203
230
|
<AlertDescription>{providerError}</AlertDescription>
|
|
@@ -212,18 +239,24 @@ export function SignInCard({
|
|
|
212
239
|
intent="sign-in"
|
|
213
240
|
method={method}
|
|
214
241
|
footer="none"
|
|
242
|
+
{...(logo !== undefined ? { logo } : {})}
|
|
215
243
|
{...(forgotHref !== undefined ? { forgotHref } : {})}
|
|
216
244
|
{...(forgot !== undefined ? { forgot } : {})}
|
|
217
|
-
|
|
245
|
+
// "Welcome back" stands alone (#8790 D3): no subhead unless the card
|
|
246
|
+
// is inert and must say why.
|
|
247
|
+
subhead={notConfigured ? notConfiguredSubhead : ""}
|
|
248
|
+
{...(prospect !== undefined ? { prospect } : {})}
|
|
249
|
+
{...(footnote !== undefined ? { footnote } : {})}
|
|
218
250
|
providers={providerButtons}
|
|
219
251
|
onSubmit={handleSubmit}
|
|
220
252
|
errors={
|
|
221
253
|
emailLinkError ? { ...errors, emailLink: emailLinkError } : errors
|
|
222
254
|
}
|
|
223
255
|
labels={labels}
|
|
256
|
+
{...(defaultEmail !== undefined ? { defaultEmail } : {})}
|
|
257
|
+
{...(methods.length > 0 ? { methodSwitch: { methods } } : {})}
|
|
224
258
|
{...(emailLink
|
|
225
259
|
? {
|
|
226
|
-
emailLink: "form" as const,
|
|
227
260
|
onEmailLink: (email: string) => void requestEmailLink(email),
|
|
228
261
|
}
|
|
229
262
|
: {})}
|
|
@@ -13,6 +13,17 @@ import type { SignInEmailLinkLabels } from "./sign-in-card";
|
|
|
13
13
|
|
|
14
14
|
export interface SignInCardText {
|
|
15
15
|
labels: AuthCardLabels;
|
|
16
|
+
/** "Back to {site}" — the spoke's name goes in (#8790 D2). */
|
|
17
|
+
backTo: (site: string) => string;
|
|
18
|
+
/** The acknowledgement sentence's parts (#8790 D6). */
|
|
19
|
+
acknowledgement: {
|
|
20
|
+
lead: string;
|
|
21
|
+
terms: string;
|
|
22
|
+
privacy: string;
|
|
23
|
+
workingAgreement: string;
|
|
24
|
+
};
|
|
25
|
+
/** The prospect line, when the site's messages carry it (#8790 D3). */
|
|
26
|
+
prospect?: { prompt: string; linkLabel: string };
|
|
16
27
|
notConfiguredSubhead: string;
|
|
17
28
|
errorLabels: { signInFailed: string; providerFailed: string };
|
|
18
29
|
emailLinkLabels: SignInEmailLinkLabels;
|
|
@@ -20,6 +31,25 @@ export interface SignInCardText {
|
|
|
20
31
|
emailLinkExpired: string;
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
type HasKey = Parameters<Awaited<ReturnType<typeof getTranslations>>["has"]>[0];
|
|
35
|
+
function optional(
|
|
36
|
+
t: Awaited<ReturnType<typeof getTranslations>>,
|
|
37
|
+
key: string,
|
|
38
|
+
): boolean {
|
|
39
|
+
// A partial `getTranslations` mock (the (authed) gate suite's) carries no
|
|
40
|
+
// `has`; an absent `has` reads as an absent key, never a throw.
|
|
41
|
+
return typeof t.has === "function" && t.has(key as HasKey);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function prospect(
|
|
45
|
+
prompt: string,
|
|
46
|
+
linkLabel: string,
|
|
47
|
+
): { prospect: { prompt: string; linkLabel: string } } | null {
|
|
48
|
+
return prompt.trim() && linkLabel.trim()
|
|
49
|
+
? { prospect: { prompt, linkLabel } }
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
|
|
23
53
|
export async function resolveSignInCardText(
|
|
24
54
|
locale: string,
|
|
25
55
|
): Promise<SignInCardText> {
|
|
@@ -27,7 +57,6 @@ export async function resolveSignInCardText(
|
|
|
27
57
|
return {
|
|
28
58
|
labels: {
|
|
29
59
|
signInHeading: t("heading"),
|
|
30
|
-
signInSubhead: t("subhead"),
|
|
31
60
|
ssoPrefix: t("ssoPrefix"),
|
|
32
61
|
google: t("google"),
|
|
33
62
|
github: t("github"),
|
|
@@ -38,7 +67,24 @@ export async function resolveSignInCardText(
|
|
|
38
67
|
orContinue: t("orContinue"),
|
|
39
68
|
emailLinkSubmit: t("emailLinkSubmit"),
|
|
40
69
|
emailLinkDivider: t("emailLinkDivider"),
|
|
70
|
+
useEmailLink: t("useEmailLink"),
|
|
71
|
+
emailPlaceholder: t("emailPlaceholder"),
|
|
72
|
+
},
|
|
73
|
+
backTo: (site) => t("backTo", { site }),
|
|
74
|
+
acknowledgement: {
|
|
75
|
+
lead: t("acknowledgement"),
|
|
76
|
+
terms: t("termsLink"),
|
|
77
|
+
privacy: t("privacyLink"),
|
|
78
|
+
workingAgreement: t("workingAgreementLink"),
|
|
41
79
|
},
|
|
80
|
+
// The prospect line (#8790 D3) is OFF until a site ADDS both keys to its
|
|
81
|
+
// messages — the shipped catalog carries neither (every leaf must be a
|
|
82
|
+
// non-empty string), so presence is the switch. The keys are optional
|
|
83
|
+
// extensions of the typed catalog, hence the cast on `has`.
|
|
84
|
+
...(optional(t, "prospectPrompt") && optional(t, "prospectLink")
|
|
85
|
+
? prospect(t("prospectPrompt" as never), t("prospectLink" as never))
|
|
86
|
+
: {}),
|
|
87
|
+
|
|
42
88
|
notConfiguredSubhead: t("notConfiguredSubhead"),
|
|
43
89
|
errorLabels: {
|
|
44
90
|
signInFailed: t("signInFailed"),
|