diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4461f05..969b7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] tags: - '@constructive-io/ui@*' + - '@constructive-io/data@*' + - '@constructive-io/sheets@*' - '@constructive-io/schema-builder@*' pull_request: branches: [main] @@ -42,7 +44,32 @@ jobs: - name: Build shadcn registry run: pnpm build:registry + packages: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify packed packages in clean consumers + run: pnpm pack:check + pages: + needs: packages runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.gitignore b/.gitignore index 7a549a6..aac123f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Local proof and visual QA artifacts +.local/ diff --git a/AGENTS.md b/AGENTS.md index 5a65032..199bc89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,20 @@ # Constructive Blocks Repository Guide This public monorepo owns the Constructive Blocks documentation, the -`@constructive` shadcn registry, and the `@constructive-io/ui` and -`@constructive-io/schema-builder` packages. +`@constructive` shadcn registry, and the `@constructive-io/ui`, +`@constructive-io/data`, and `@constructive-io/sheets` packages. The local +schema-builder package remains in the workspace but is not part of the public +registry. ## Invariants -- Canonical source lives in `apps/blocks`, `packages/ui`, and - `packages/schema-builder`; do not edit generated registry output. +- Canonical public source lives in `apps/blocks`, `packages/ui`, + `packages/data`, and `packages/sheets`; do not edit generated registry output. - Keep the registry collision-free and `@constructive`-namespaced. -- Preserve registry requirements sidecars. +- Preserve the seven feature-pack manifests and four experimental preset + profiles installed under `.constructive/feature-packs`. - Never auto-discover or mutate sibling repositories from flow tooling. -- Keep generated SDK fixtures pruned and normal CI independent of live endpoints. +- Keep normal CI independent of generated SDKs and live endpoints. - Never add automated npm publishing; publish verified tarballs manually. ## Verification diff --git a/CLAUDE.md b/CLAUDE.md index 792e767..2088166 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,23 @@ # Constructive Blocks Repository Guide This public monorepo owns the Constructive Blocks documentation, the -`@constructive` shadcn registry, and the `@constructive-io/ui` and -`@constructive-io/schema-builder` packages. +`@constructive` shadcn registry, and the `@constructive-io/ui`, +`@constructive-io/data`, and `@constructive-io/sheets` packages. The local +schema-builder package remains available for development but is delisted from +the public registry. ## Invariants -- `apps/blocks`, `packages/ui`, and `packages/schema-builder` are canonical - source trees; generated registry output is never edited. +- `apps/blocks`, `packages/ui`, `packages/data`, and `packages/sheets` are + canonical public source trees; generated registry output is never edited. - The combined registry must remain collision-free and every install command must use the `@constructive` namespace. -- Registry requirements sidecars are part of the public contract. +- Feature-pack and preset manifests installed under + `.constructive/feature-packs` are part of the public contract. - Flow outputs outside this repository are generated only when explicit output environment variables are supplied. -- Generated SDK fixtures are intentionally pruned. Refresh them explicitly; - do not add complete SDK trees or make normal CI depend on live endpoints. +- Blocks use injected endpoints, sessions, and adapters. Do not add generated + SDK trees or make normal CI depend on live endpoints. - npm packages are versioned with Lerna and published manually from locally verified tarballs. Do not add automated npm publishing. diff --git a/README.md b/README.md index a9f19a5..6385524 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,87 @@ component documentation, and published React packages. - `apps/blocks` — primitive documentation and canonical block source. - `apps/registry` — private builder for the `@constructive` shadcn registry. - `packages/ui` — the `@constructive-io/ui` npm package and UI registry source. -- `packages/schema-builder` — the shared schema-builder npm package and registry source. +- `packages/data` — runtime GraphQL generation and the strict July 2026 `_meta` contract. +- `packages/sheets` — the metadata-driven application CRUD grid. +- `packages/schema-builder` — retained platform/operator tooling that is delisted from the public registry. The documentation site is published at . Registry JSON is served from `https://constructive-io.github.io/blocks/r/{name}.json`. -The npm and registry distributions are independent. After mapping the -`@constructive` namespace in `components.json`, either surface can be used on -its own: +The npm and registry distributions are independent. Console Kit source installs +expect an existing shadcn project; initialize one if the application does not +already contain `components.json`: + +```bash +pnpm dlx shadcn@4.13.1 init +``` + +Keep the generated alias configuration, then map the public registry in +`components.json` once: + +```json +{ + "registries": { + "@constructive": "https://constructive-io.github.io/blocks/r/{name}.json" + } +} +``` + +Then choose the smallest surface that owns the workflow you need: + +| Goal | Install root | +| --- | --- | +| Full tenant console with all seven packs | `console-kit-nextjs` | +| Backend-aligned official composition | `preset-auth-hardened`, `preset-b2b-storage`, or `preset-full` | +| Custom Console Kit composition | `console-kit-core` plus selected `console-module-*` items | +| Provider-neutral view with host-owned data and actions | `feature-pack-*` | +| Reusable primitive | npm subpath or namespaced primitive registry item | + +```bash +pnpm dlx shadcn@4.13.1 add @constructive/console-kit-nextjs +``` + +Render the installed umbrella with a secret-free tenant descriptor returned by +provisioning. Endpoint URLs are explicit because Console Kit never derives a +sibling host or silently crosses an authorization boundary: + +```tsx +'use client'; + +import { + ConstructiveConsoleKit, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/constructive'; + +const database = { + id: 'tenant_database_id', + name: 'Acme application', + endpoints: { + data: 'https://data.example.com/graphql', + auth: 'https://auth.example.com/graphql', + admin: 'https://admin.example.com/graphql' + } +} satisfies ConstructiveTenantDatabase; + +export function TenantConsole() { + return ; +} +``` + +During integration, pass +`showDiagnostics={process.env.NODE_ENV !== 'production'}` to expose endpoint and +capability evidence without shipping tenant diagnostics in the production UI. + +Use the npm surface for packaged primitives, or install editable primitive +source through the same namespace: ```bash pnpm add @constructive-io/ui pnpm dlx shadcn@4.13.1 add @constructive/button ``` -The package exposes its Tailwind foundation at +The UI package exposes its Tailwind foundation at `@constructive-io/ui/globals.css`. Registry installs copy the required UI source and Constructive theme into the consumer and do not install the npm package. Registry consumers require shadcn CLI 4.13.1 or newer. @@ -42,6 +107,50 @@ first-party executable tooling is TypeScript and runs through `tsx`. Use `pnpm check:full` when validating Storybook, registry installation, and publishable package artifacts together. +Console Kit and the feature packs are source-installed blocks. They accept +database-scoped endpoints, a host-owned session, and provider-neutral adapters; +they do not require generated SDKs or environment-specific global clients. A +Console Kit instance owns one Zustand store composed from separate navigation, +runtime, and adapter slices, which keeps state isolated across mounts and +server requests. + +## Agent inspection + +An agent can resolve an install without reading component source or contacting a +tenant. The inspector rebuilds the local aggregate registry, follows its exact +dependency graph, joins the installed packs to their canonical manifests, and +emits deterministic, discriminated JSON containing the CLI command, npm +dependencies, canonical preset profiles, registry targets and source +provenance, endpoint/capability/`_meta` requirements, degraded-state rules, +tenant/routing/store/auth contracts, and verification steps. A file target +classified as `shadcn-alias` resolves through the consumer aliases in +`components.json`; `project-root` targets install outside `src` at the project +root: + +```bash +pnpm --silent console-kit:inspect --list +pnpm --silent console-kit:inspect --item preset-b2b-storage +pnpm --silent console-kit:inspect --item console-module-storage --compact +pnpm check:console-kit-inspector +``` + +`--silent` keeps stdout valid JSON for piping into `jq` or another agent tool. +Unknown roots fail with the complete valid-choice list. The default command +rebuilds the aggregate registry from canonical local inputs; `--no-build` reads +the existing artifact and makes freshness the caller's responsibility. Once +workspace dependencies are installed, inspection contacts neither a tenant nor +the public registry. Installed compatibility manifests remain under +`.constructive/feature-packs`; they describe backend evidence and never grant +frontend or database authority. + +After installing into a consumer, run the plan's static verification commands +without tenant credentials. Registry maintainers can exercise every root in an +isolated generated consumer with the cross-platform smoke gate: + +```bash +pnpm --filter @constructive-io/registry smoke:install +``` + `pnpm pack:local` builds the public packages and writes publishable tarballs to the ignored `.artifacts/npm` directory. Consume those tarballs from downstream projects before publishing so validation exercises the real package contents. diff --git a/apps/blocks/README.md b/apps/blocks/README.md index 2792d64..b5056f6 100644 --- a/apps/blocks/README.md +++ b/apps/blocks/README.md @@ -1,8 +1,8 @@ # Blocks docs -This Next.js app is the clean documentation surface for the Constructive UI foundation. It intentionally exposes only -the landing page, setup guidance, and the 29 base primitive pages while the complete block catalog remains available -through the public registry. +This Next.js app documents the Constructive Blocks registry: the UI foundation, +29 base primitives, customer billing blocks, seven capability-aligned feature +packs, and Console Kit. The same primitive implementation is shown through two distribution modes: @@ -18,9 +18,20 @@ pnpm --filter blocks test pnpm --filter blocks build:pages ``` -`src/lib/base-primitives.ts` is the single docs catalog. `pnpm gen:check` validates that every entry has an npm export, -a registry item, and a preview that imports the npm subpath. The SDK fixture and mutation-selection checks remain in -place for the canonical block source under `src/blocks`. +`src/lib/base-primitives.ts`, `src/lib/billing-blocks.ts`, and +`src/lib/feature-packs.ts` are the documentation catalogs. `pnpm gen:check` +validates primitive distribution and keeps the feature-pack docs aligned with +their manifests, registry roots, and live previews. + +From the repository root, +`pnpm --silent console-kit:inspect --item ` +builds the aggregate registry and emits a deterministic agent-readable install +plan. `pnpm check:console-kit-inspector` verifies that every Console Kit core, +module, preset, umbrella, and standalone pack remains documented and resolves +to its exact registry closure, canonical compatibility manifests and preset +profiles. Inspector file entries distinguish consumer project-root targets from +targets resolved through the shadcn aliases in `components.json`, and retain +their canonical registry source paths for drift diagnosis. The static Pages build uses `/blocks` as its deployment base path. Publishing npm packages remains a separate manual release step. diff --git a/apps/blocks/catalog/blocks.json b/apps/blocks/catalog/blocks.json index ef6f2c3..bc28df8 100644 --- a/apps/blocks/catalog/blocks.json +++ b/apps/blocks/catalog/blocks.json @@ -1,841 +1,204 @@ { - "version": 1, + "version": 2, + "source": "apps/blocks/registry.json", "blocks": [ { - "name": "auth-sign-in-card", - "slug": "auth-sign-in-card", - "title": "Auth Sign In Card", - "category": "auth", - "type": "card", + "name": "billing-contracts", + "slug": "billing-contracts", + "title": "Billing Contracts", + "category": "billing", + "type": "library", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Email+password sign-in form; MFA-branch aware", - "specFile": "auth-sign-in-card.md" + "purpose": "Provider-neutral billing DTOs, resource states, and precision-safe formatters." }, { - "name": "auth-sign-in-page", - "slug": "auth-sign-in-page", - "title": "Auth Sign In Page", - "category": "auth", - "type": "page", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Next.js route composing sign-in-card + layout-kit", - "specFile": "auth-sign-in-page.md" - }, - { - "name": "auth-sign-up-card", - "slug": "auth-sign-up-card", - "title": "Auth Sign Up Card", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Email+password registration form", - "specFile": "auth-sign-up-card.md" - }, - { - "name": "auth-sign-up-page", - "slug": "auth-sign-up-page", - "title": "Auth Sign Up Page", - "category": "auth", - "type": "page", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Next.js route composing sign-up-card + layout-kit", - "specFile": "auth-sign-up-page.md" - }, - { - "name": "auth-forgot-password-card", - "slug": "auth-forgot-password-card", - "title": "Auth Forgot Password Card", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Requests password reset email", - "specFile": "auth-forgot-password-card.md" - }, - { - "name": "auth-forgot-password-page", - "slug": "auth-forgot-password-page", - "title": "Auth Forgot Password Page", - "category": "auth", - "type": "page", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Next.js route composing forgot-password-card", - "specFile": "auth-forgot-password-page.md" - }, - { - "name": "auth-reset-password-card", - "slug": "auth-reset-password-card", - "title": "Auth Reset Password Card", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "New-password form; consumes role_id+token from URL", - "specFile": "auth-reset-password-card.md" - }, - { - "name": "auth-reset-password-page", - "slug": "auth-reset-password-page", - "title": "Auth Reset Password Page", - "category": "auth", - "type": "page", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Next.js route composing reset-password-card", - "specFile": "auth-reset-password-page.md" - }, - { - "name": "auth-verify-email-page", - "slug": "auth-verify-email-page", - "title": "Auth Verify Email Page", - "category": "auth", - "type": "page", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Handles the ?email_id=&token= email-verification callback", - "specFile": "auth-verify-email-page.md" - }, - { - "name": "auth-verify-email-banner", - "slug": "auth-verify-email-banner", - "title": "Auth Verify Email Banner", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Inline prompt to verify email; shows send-again action", - "specFile": "auth-verify-email-banner.md" - }, - { - "name": "auth-change-password-form", - "slug": "auth-change-password-form", - "title": "Auth Change Password Form", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Current+new password form; step-up tier=medium gates submission", - "specFile": "auth-change-password-form.md" - }, - { - "name": "auth-sign-out-button", - "slug": "auth-sign-out-button", - "title": "Auth Sign Out Button", - "category": "auth", - "type": "primitive", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Single sign-out button; ends the session and clears local cache", - "specFile": "auth-sign-out-button.md" - }, - { - "name": "auth-anonymous-sign-in-button", - "slug": "auth-anonymous-sign-in-button", - "title": "Auth Anonymous Sign In Button", - "category": "auth", - "type": "primitive", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Single button; starts an anonymous guest session", - "specFile": "auth-anonymous-sign-in-button.md" - }, - { - "name": "auth-cross-origin-link", - "slug": "auth-cross-origin-link", - "title": "Auth Cross Origin Link", - "category": "auth", - "type": "primitive", + "name": "billing-ui", + "slug": "billing-ui", + "title": "Billing UI Helpers", + "category": "billing", + "type": "library", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Cross-origin token exchange UI; redirects to another origin with a one-time token", - "specFile": "auth-cross-origin-link.md" - }, - { - "name": "auth-social-buttons", - "slug": "auth-social-buttons", - "title": "Auth Social Buttons", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Dynamic OAuth provider buttons; queries identityProviders by default", - "specFile": "auth-social-buttons.md" - }, - { - "name": "auth-social-providers-grid", - "slug": "auth-social-providers-grid", - "title": "Auth Social Providers Grid", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "All enabled OAuth providers as a sign-in/sign-up grid; queries enabled identity providers", - "specFile": "auth-social-providers-grid.md" - }, - { - "name": "auth-mfa-totp-challenge", - "slug": "auth-mfa-totp-challenge", - "title": "Auth Mfa Totp Challenge", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "6-digit TOTP code input for the MFA challenge step", - "specFile": "auth-mfa-totp-challenge.md" - }, - { - "name": "auth-mfa-totp-challenge-page", - "slug": "auth-mfa-totp-challenge-page", - "title": "Auth Mfa Totp Challenge Page", - "category": "auth", - "type": "page", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Next.js route at /auth/mfa/totp; reads ?token= + ?redirect= and mounts the challenge card", - "specFile": "auth-mfa-totp-challenge-page.md" - }, - { - "name": "auth-passkey-enroll", - "slug": "auth-passkey-enroll", - "title": "Auth Passkey Enroll", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "WebAuthn passkey credential registration; override-driven enroll path", - "specFile": "auth-passkey-enroll.md" - }, - { - "name": "auth-passkey-sign-in", - "slug": "auth-passkey-sign-in", - "title": "Auth Passkey Sign In", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "WebAuthn passkey assertion for sign-in; override-driven verify path", - "specFile": "auth-passkey-sign-in.md" - }, - { - "name": "auth-passkey-management-list", - "slug": "auth-passkey-management-list", - "title": "Auth Passkey Management List", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Lists + renames + removes registered passkeys; step-up tier=high for delete", - "specFile": "auth-passkey-management-list.md" - }, - { - "name": "auth-step-up-dialog", - "slug": "auth-step-up-dialog", - "title": "Auth Step Up Dialog", - "category": "auth", - "type": "dialog", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Shared step-up verifier (password or TOTP); used by sensitive blocks", - "specFile": "auth-step-up-dialog.md" - }, - { - "name": "use-step-up", - "slug": "use-step-up", - "title": "Use Step Up", - "category": "auth", - "type": "hook", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "await stepUp({ tier: 'high' |'medium' }) imperative hook; wraps dialog trigger", - "specFile": "use-step-up.md" - }, - { - "name": "auth-account-profile-card", - "slug": "auth-account-profile-card", - "title": "Auth Account Profile Card", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Display name + profile picture upload", - "specFile": "auth-account-profile-card.md" - }, - { - "name": "auth-account-emails-list", - "slug": "auth-account-emails-list", - "title": "Auth Account Emails List", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Multi-email add/verify/set-primary/remove with resend verification", - "specFile": "auth-account-emails-list.md" - }, - { - "name": "auth-account-security-card", - "slug": "auth-account-security-card", - "title": "Auth Account Security Card", - "category": "auth", - "type": "card", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Password change + MFA status summary + passkeys overview", - "specFile": "auth-account-security-card.md" - }, - { - "name": "auth-account-sessions-list", - "slug": "auth-account-sessions-list", - "title": "Auth Account Sessions List", - "category": "auth", - "type": "card", - "status": "api-config-pending", - "statusLabel": "out-of-frontend-scope (API-config-pending)", - "purpose": "Lists active sessions with individual and revoke-all actions; sensitive revokes require step-up", - "specFile": "auth-account-sessions-list.md" - }, - { - "name": "auth-account-api-keys-list", - "slug": "auth-account-api-keys-list", - "title": "Auth Account Api Keys List", - "category": "auth", - "type": "card", - "status": "api-config-pending", - "statusLabel": "out-of-frontend-scope (API-config-pending)", - "purpose": "Lists user-scoped API keys with revoke and create-key actions", - "specFile": "auth-account-api-keys-list.md" - }, - { - "name": "auth-api-key-create-dialog", - "slug": "auth-api-key-create-dialog", - "title": "Auth Api Key Create Dialog", - "category": "auth", - "type": "dialog", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "API key creation flow; step-up tier=high gates submission", - "specFile": "auth-api-key-create-dialog.md" + "purpose": "Shared billing formatting and presentation helpers." }, { - "name": "auth-api-key-created-modal", - "slug": "auth-api-key-created-modal", - "title": "Auth Api Key Created Modal", - "category": "auth", - "type": "dialog", + "name": "billing-pricing-table", + "slug": "billing-pricing-table", + "title": "Billing Pricing Table", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "One-time display of new API key value", - "specFile": "auth-api-key-created-modal.md" + "purpose": "Responsive plan comparison and interval selection." }, { - "name": "auth-account-connected-accounts", - "slug": "auth-account-connected-accounts", - "title": "Auth Account Connected Accounts", - "category": "auth", - "type": "card", + "name": "billing-subscription-card", + "slug": "billing-subscription-card", + "title": "Billing Subscription Card", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Settings surface: linked OAuth providers with disconnect; step-up tier=medium for disconnect", - "specFile": "auth-account-connected-accounts.md" + "purpose": "Current-plan, subscription, provider, and payment summary." }, { - "name": "auth-account-danger-card", - "slug": "auth-account-danger-card", - "title": "Auth Account Danger Card", - "category": "auth", - "type": "card", + "name": "billing-entitlements-list", + "slug": "billing-entitlements-list", + "title": "Billing Entitlements List", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Delete account entry point; step-up tier=high; sends deletion email", - "specFile": "auth-account-danger-card.md" + "purpose": "Plan features, caps, quotas, and metered allowances." }, { - "name": "auth-account-deletion-confirm-page", - "slug": "auth-account-deletion-confirm-page", - "title": "Auth Account Deletion Confirm Page", - "category": "auth", - "type": "page", + "name": "billing-usage-overview", + "slug": "billing-usage-overview", + "title": "Billing Usage Overview", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Handles ?user_id=&token= deletion confirmation", - "specFile": "auth-account-deletion-confirm-page.md" + "purpose": "Current-period usage, allowance, credit pool, and overage states." }, { - "name": "auth-account-settings-page", - "slug": "auth-account-settings-page", - "title": "Auth Account Settings Page", - "category": "auth", - "type": "page", + "name": "billing-credits-card", + "slug": "billing-credits-card", + "title": "Billing Credits Card", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Tabs page composing all account section cards", - "specFile": "auth-account-settings-page.md" + "purpose": "Available credits grouped by compatible meter and unit." }, { - "name": "auth-invitation-acceptance-card", - "slug": "auth-invitation-acceptance-card", - "title": "Auth Invitation Acceptance Card", - "category": "auth", - "type": "card", + "name": "billing-usage-history", + "slug": "billing-usage-history", + "title": "Billing Usage History", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Invitation acceptance card for embedding in your own route; the page block composes it", - "specFile": "auth-invitation-acceptance-card.md" + "purpose": "Accessible, filterable, and paginated usage history." }, { - "name": "auth-invitation-acceptance-page", - "slug": "auth-invitation-acceptance-page", - "title": "Auth Invitation Acceptance Page", - "category": "auth", - "type": "page", + "name": "billing-activity-table", + "slug": "billing-activity-table", + "title": "Billing Activity Table", + "category": "billing", + "type": "block", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Token-gated invite claim page; composes invitation-acceptance-card", - "specFile": "auth-invitation-acceptance-page.md" + "purpose": "Filterable billing ledger activity with metadata details." }, { - "name": "auth-magic-link-request-card", - "slug": "auth-magic-link-request-card", - "title": "Auth Magic Link Request Card", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Magic-link email request form; override-driven send path", - "specFile": "auth-magic-link-request-card.md" - }, - { - "name": "auth-magic-link-sent-page", - "slug": "auth-magic-link-sent-page", - "title": "Auth Magic Link Sent Page", - "category": "auth", - "type": "page", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Post-request confirmation screen", - "specFile": "auth-magic-link-sent-page.md" - }, - { - "name": "auth-magic-link-callback-page", - "slug": "auth-magic-link-callback-page", - "title": "Auth Magic Link Callback Page", - "category": "auth", + "name": "billing-settings-page", + "slug": "billing-settings-page", + "title": "Billing Settings Page", + "category": "billing", "type": "page", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Handles the ?token= callback; override-driven verification path", - "specFile": "auth-magic-link-callback-page.md" - }, - { - "name": "auth-email-otp-request-card", - "slug": "auth-email-otp-request-card", - "title": "Auth Email Otp Request Card", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Email OTP send form; override-driven send path", - "specFile": "auth-email-otp-request-card.md" - }, - { - "name": "auth-email-otp-input", - "slug": "auth-email-otp-input", - "title": "Auth Email Otp Input", - "category": "auth", - "type": "primitive", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "6-digit OTP entry with auto-submit and resend; override-driven verify path", - "specFile": "auth-email-otp-input.md" - }, - { - "name": "auth-mfa-totp-enroll", - "slug": "auth-mfa-totp-enroll", - "title": "Auth Mfa Totp Enroll", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "TOTP enrollment: QR + manual key + confirm code; override-driven enroll path", - "specFile": "auth-mfa-totp-enroll.md" - }, - { - "name": "auth-mfa-totp-disable-confirm", - "slug": "auth-mfa-totp-disable-confirm", - "title": "Auth Mfa Totp Disable Confirm", - "category": "auth", - "type": "dialog", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Confirm + step-up tier=high before disabling TOTP; override-driven disable path", - "specFile": "auth-mfa-totp-disable-confirm.md" - }, - { - "name": "auth-mfa-backup-codes-display", - "slug": "auth-mfa-backup-codes-display", - "title": "Auth Mfa Backup Codes Display", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Shows freshly generated backup codes; one-time view with copy and download", - "specFile": "auth-mfa-backup-codes-display.md" - }, - { - "name": "auth-mfa-backup-codes-regenerate", - "slug": "auth-mfa-backup-codes-regenerate", - "title": "Auth Mfa Backup Codes Regenerate", - "category": "auth", - "type": "dialog", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Regenerates backup codes; step-up gated; override-driven regenerate path", - "specFile": "auth-mfa-backup-codes-regenerate.md" - }, - { - "name": "auth-account-phones-list", - "slug": "auth-account-phones-list", - "title": "Auth Account Phones List", - "category": "auth", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Phone number add/verify/remove", - "specFile": "auth-account-phones-list.md" - }, - { - "name": "auth-sso-setup-card", - "slug": "auth-sso-setup-card", - "title": "Auth Sso Setup Card", - "category": "auth", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "Enterprise SSO provider configuration card (OIDC and SAML)", - "specFile": "auth-sso-setup-card.md" - }, - { - "name": "auth-sso-sign-in-card", - "slug": "auth-sso-sign-in-card", - "title": "Auth Sso Sign In Card", - "category": "auth", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "SSO-initiated sign-in card with email-domain lookup", - "specFile": "auth-sso-sign-in-card.md" - }, - { - "name": "auth-domain-verification-step", - "slug": "auth-domain-verification-step", - "title": "Auth Domain Verification Step", - "category": "auth", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "DNS TXT record verification step for an SSO domain claim", - "specFile": "auth-domain-verification-step.md" - }, - { - "name": "user-avatar", - "slug": "user-avatar", - "title": "User Avatar", - "category": "user", - "type": "component", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Renders profile_picture (image domain) with initials fallback; round for person, square for org", - "specFile": "user-avatar.md" + "purpose": "Router-neutral composition of the customer billing blocks." }, { - "name": "user-context-switcher", - "slug": "user-context-switcher", - "title": "User Context Switcher", - "category": "user", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Switch the active acting-as context between personal account and orgs", - "specFile": "user-context-switcher.md" - }, - { - "name": "org-create-card", - "slug": "org-create-card", - "title": "Org Create Card", - "category": "org", - "type": "card", + "name": "feature-pack-data", + "slug": "feature-pack-data", + "title": "Data Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "New organization creation wizard; name, slug, and optional logo", - "specFile": "org-create-card.md" + "purpose": "Versioned _meta-driven table exploration and spreadsheet CRUD." }, { - "name": "org-members-list", - "slug": "org-members-list", - "title": "Org Members List", - "category": "org", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "List org members with role chips and remove action", - "specFile": "org-members-list.md" - }, - { - "name": "org-invite-dialog", - "slug": "org-invite-dialog", - "title": "Org Invite Dialog", - "category": "org", - "type": "dialog", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Send org membership invitation", - "specFile": "org-invite-dialog.md" - }, - { - "name": "org-roles-editor", - "slug": "org-roles-editor", - "title": "Org Roles Editor", - "category": "org", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Manage custom org roles + permissions", - "specFile": "org-roles-editor.md" - }, - { - "name": "org-settings-form", - "slug": "org-settings-form", - "title": "Org Settings Form", - "category": "org", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Org name, slug, avatar; delete-org (step-up)", - "specFile": "org-settings-form.md" - }, - { - "name": "org-app-memberships", - "slug": "org-app-memberships", - "title": "Org App Memberships", - "category": "org", - "type": "card", - "status": "backend-pending", - "statusLabel": "v1 (frontend ready, backend pending)", - "purpose": "Manage org's app-level memberships", - "specFile": "org-app-memberships.md" - }, - { - "name": "org-scim-token-generation-card", - "slug": "org-scim-token-generation-card", - "title": "Org Scim Token Generation Card", - "category": "org", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "Generate and revoke the SCIM bearer token for an identity provider", - "specFile": "org-scim-token-generation-card.md" - }, - { - "name": "org-scim-connections-list", - "slug": "org-scim-connections-list", - "title": "Org Scim Connections List", - "category": "org", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "Lists active SCIM provider connections for an organization", - "specFile": "org-scim-connections-list.md" - }, - { - "name": "org-scim-setup-guide", - "slug": "org-scim-setup-guide", - "title": "Org Scim Setup Guide", - "category": "org", - "type": "stub", - "status": "planned", - "statusLabel": "v2 (deferred)", - "purpose": "Provider-specific SCIM 2.0 provisioning setup guide", - "specFile": "org-scim-setup-guide.md" - }, - { - "name": "shell-sidebar", - "slug": "shell-sidebar", - "title": "Shell Sidebar", - "category": "shell", - "type": "layout", + "name": "feature-pack-auth", + "slug": "feature-pack-auth", + "title": "Authentication Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "App sidebar with nav + context switcher slot", - "specFile": "shell-sidebar.md" + "purpose": "Provider-neutral authentication and personal account management." }, { - "name": "shell-header", - "slug": "shell-header", - "title": "Shell Header", - "category": "shell", - "type": "layout", + "name": "feature-pack-users", + "slug": "feature-pack-users", + "title": "App access Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Top header bar with breadcrumbs + account menu slot", - "specFile": "shell-header.md" + "purpose": "Application member governance, lifecycle controls, invitations, access profiles, direct permission grants, and defaults." }, { - "name": "shell-account-menu", - "slug": "shell-account-menu", - "title": "Shell Account Menu", - "category": "shell", - "type": "menu", + "name": "feature-pack-organizations", + "slug": "feature-pack-organizations", + "title": "Organizations Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Dropdown: current user, switch context, sign out (uses auth-sign-out-button)", - "specFile": "shell-account-menu.md" + "purpose": "Tenant switching, member governance, invitations, profiles, permissions, defaults, hierarchy, settings, principals, and API keys." }, { - "name": "shell-command-palette", - "slug": "shell-command-palette", - "title": "Shell Command Palette", - "category": "shell", - "type": "overlay", + "name": "feature-pack-storage", + "slug": "feature-pack-storage", + "title": "Storage Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Cmd-K command palette with grouped, permission-filtered commands", - "specFile": "shell-command-palette.md" + "purpose": "Bucket, folder, object, upload, download, and deletion surfaces." }, { - "name": "shell-notifications", - "slug": "shell-notifications", - "title": "Shell Notifications", - "category": "shell", - "type": "panel", + "name": "feature-pack-billing", + "slug": "feature-pack-billing", + "title": "Billing Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Notification bell + panel", - "specFile": "shell-notifications.md" + "purpose": "Feature-pack root for plans, subscriptions, usage, credits, and activity." }, { - "name": "shell-breadcrumbs", - "slug": "shell-breadcrumbs", - "title": "Shell Breadcrumbs", - "category": "shell", - "type": "primitive", + "name": "feature-pack-notifications", + "slug": "feature-pack-notifications", + "title": "Notifications Feature Pack", + "category": "feature-pack", + "type": "feature-pack", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Route-aware breadcrumbs", - "specFile": "shell-breadcrumbs.md" + "purpose": "Application notification inbox and message actions." }, { - "name": "schema-builder-core", - "slug": "schema-builder-core", - "title": "Schema Builder — Core", - "category": "schema", - "type": "lib", + "name": "console-kit-core", + "slug": "console-kit-core", + "title": "Constructive Console Kit Core", + "category": "console", + "type": "application-kit", "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Shared config provider, schema selectors/store, read hooks, databases UI, and diagram substrate for the schema-builder family", - "specFile": "schema-builder-core.md" + "purpose": "Leaf-independent shell, runtime, discovery, and modular Zustand store." }, { - "name": "schema-builder-fields", - "slug": "schema-builder-fields", - "title": "Schema Builder — Structure", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Structure tab — the table editor for columns, types, defaults, and primary-key/unique constraints", - "specFile": "schema-builder-fields.md" + "name": "preset-auth-hardened", + "slug": "preset-auth-hardened", + "title": "Hardened Authentication Preset", + "category": "preset", + "type": "preset-profile", + "status": "experimental", + "purpose": "Maps auth:hardened to data, authentication, and users." }, { - "name": "schema-builder-relationships", - "slug": "schema-builder-relationships", - "title": "Schema Builder — Relationships", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Relationships tab — foreign keys plus the relation-provision and secure-table flows", - "specFile": "schema-builder-relationships.md" + "name": "preset-b2b-storage", + "slug": "preset-b2b-storage", + "title": "B2B with Storage Preset", + "category": "preset", + "type": "preset-profile", + "status": "experimental", + "purpose": "Maps b2b:storage to data, authentication, users, organizations, and storage." }, { - "name": "schema-builder-indexes", - "slug": "schema-builder-indexes", - "title": "Schema Builder — Indexes", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Indexes tab — create, edit, and drop a table's indexes", - "specFile": "schema-builder-indexes.md" - }, - { - "name": "schema-builder-policies", - "slug": "schema-builder-policies", - "title": "Schema Builder — Policies", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Policies tab — row-level security policy editing, table grants, and create-table-with-policies", - "specFile": "schema-builder-policies.md" - }, - { - "name": "schema-builder-tables", - "slug": "schema-builder-tables", - "title": "Schema Builder — Tables", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Tables sidebar — schema/table navigation tree, table metadata, and table create/rename/delete", - "specFile": "schema-builder-tables.md" - }, - { - "name": "schema-builder", - "slug": "schema-builder", - "title": "Schema Builder", - "category": "schema", - "type": "block", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "Composer shell wiring core + the Structure / Relationships / Indexes / Policies area blocks into one database schema editor", - "specFile": "schema-builder.md" - }, - { - "name": "chat", - "slug": "chat", - "title": "Chat Widget", - "category": "chat", - "type": "widget", - "status": "ready", - "statusLabel": "v1 (frontend ready)", - "purpose": "AI chat widget with page-context awareness, provider settings, tool approval UI, and floating or embedded presentation", - "specFile": "chat.md" - }, - { - "name": "lib/auth-errors", - "slug": "lib-auth-errors", - "title": "Auth errors (parseGraphQLError)", - "category": "lib", - "type": "registry:lib", - "status": "ready", - "statusLabel": "v1", - "purpose": "parseGraphQLError(err, options) — PostGraphile error code → user string", - "specFile": null + "name": "preset-full", + "slug": "preset-full", + "title": "Full Preset", + "category": "preset", + "type": "preset-profile", + "status": "experimental", + "purpose": "Maps the full backend preset to all seven feature packs." }, { - "name": "lib/auth/messages.ts", - "slug": "lib-auth-messages-ts", - "title": "Auth message types", - "category": "lib", - "type": "registry:lib", + "name": "console-kit-nextjs", + "slug": "console-kit-nextjs", + "title": "Constructive Console Kit for Next.js", + "category": "console", + "type": "application-kit", "status": "ready", - "statusLabel": "v1", - "purpose": "Shared message type helpers (base catalog type, merge utility)", - "specFile": null + "purpose": "Full-page Next.js console with semantic routing, dynamic tenant discovery, every feature pack, and one modular per-instance Zustand store." } ] } diff --git a/apps/blocks/components.json b/apps/blocks/components.json index 26ed6d4..93c92e0 100644 --- a/apps/blocks/components.json +++ b/apps/blocks/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "base-nova", "rsc": true, "tsx": true, "tailwind": { diff --git a/apps/blocks/e2e-live/console-kit.live.spec.ts b/apps/blocks/e2e-live/console-kit.live.spec.ts new file mode 100644 index 0000000..854a267 --- /dev/null +++ b/apps/blocks/e2e-live/console-kit.live.spec.ts @@ -0,0 +1,379 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; + +import { + authenticationErrorCodes, + createRow, + deleteRow, + graphQL, + listRows, + loadSchema, + loadSchemaAt, + rawGraphQL, + signIn, + signOut, + signUp, + updateRow, + type LiveSchema, + type LiveSession, + type LiveTable +} from './constructive-graphql'; +import { + endpointUrl, + loadProofContext, + type ProofCredentials, + type ProofTenant +} from './proof-context'; + +const proof = loadProofContext(); +const HEALTH_QUERY = 'query ConsoleKitNativeHealth { __typename }'; +const OFFICIAL_PROFILES = ['auth-hardened', 'b2b-storage', 'full'] as const; +const BROWSER_PROFILES = [...OFFICIAL_PROFILES, 'storage-routed'] as const; +const FEATURE_LABELS = [ + 'Data', + 'Authentication', + 'App access', + 'Organizations', + 'Storage', + 'Billing', + 'Notifications' +] as const; +const PROFILE_FEATURES = { + 'auth-hardened': ['Data', 'Authentication', 'App access'], + 'b2b-storage': ['Data', 'Authentication', 'App access', 'Organizations', 'Storage'], + full: FEATURE_LABELS, + 'storage-routed': ['Data', 'Authentication', 'App access', 'Organizations', 'Storage'] +} as const; + +function uniqueCredentials(base: ProofCredentials, purpose: string): ProofCredentials { + const [local, domain] = base.email.split('@'); + return { + email: `${local}+${purpose}-${randomUUID()}@${domain}`, + password: base.password + }; +} + +function projectTable(schema: LiveSchema): LiveTable { + const matches = schema.entries.filter(({ meta }) => + meta.query?.all === 'projects' || meta.name.toLowerCase() === 'project' + ); + expect(matches, '_meta must expose exactly one projects table.').toHaveLength(1); + return matches[0]!; +} + +async function createSession( + tenant: ProofTenant, + actor: 'owner' | 'peer', + purpose: string +): Promise> { + const credentials = uniqueCredentials(proof.credentials(tenant, actor), purpose); + const session = await signUp(tenant, credentials); + return { credentials, session }; +} + +async function expectTokenRejected(url: string, token: string): Promise { + const payload = await rawGraphQL(url, HEALTH_QUERY, {}, token); + expect(payload.data == null).toBe(true); + expect(authenticationErrorCodes(payload)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^(?:UNAUTHENTICATED|BAD_TOKEN_DEFINITION)$/u) + ]) + ); +} + +function proofPageUrl(profile: ProofTenant['profile']): string { + const url = new URL(proof.routeUrl); + url.searchParams.set('profile', profile); + url.hash = ''; + return url.toString(); +} + +test.describe.configure({ mode: 'serial' }); + +test('uses the exact native fixture matrix and metadata-discovered public routes', async () => { + expect(proof.status).toBe('ready'); + expect(proof.manifest.membershipFixtureMode).toBe('auto-approved-and-verified'); + expect(proof.manifest.databaseIds).toHaveLength(4); + expect(proof.tenants.map((tenant) => tenant.profile)).toEqual([ + 'auth-hardened', + 'b2b-storage', + 'full', + 'storage-routed' + ]); + + for (const tenant of proof.tenants) { + for (const kind of ['data', 'auth', 'admin'] as const) { + const url = endpointUrl(tenant, kind); + const endpoint = tenant.endpoints.find((candidate) => candidate.url === url); + expect(endpoint, `${tenant.profile} ${kind} route must come from the fixture manifest.`) + .toBeDefined(); + const payload = await rawGraphQL<{ __typename: string }>(url, HEALTH_QUERY); + expect(payload.errors).toBeUndefined(); + expect(payload.data?.__typename).toBe('Query'); + } + } + + expect(() => endpointUrl(proof.tenant('b2b-storage'), 'storage')) + .toThrow(/does not bind storage/u); + expect(() => endpointUrl(proof.tenant('full'), 'storage')) + .toThrow(/does not bind storage/u); + expect(() => endpointUrl(proof.tenant('full'), 'notifications')) + .toThrow(/no discovered public notifications endpoint/u); + expect(endpointUrl(proof.tenant('storage-routed'), 'storage')) + .toBe(endpointUrl(proof.tenant('storage-routed'), 'admin')); +}); + +test('renders the exact Console Kit composition for every native tenant profile', async ({ page }) => { + for (const profile of BROWSER_PROFILES) { + const tenant = proof.tenant(profile); + await page.goto(proofPageUrl(profile)); + + const root = page.getByTestId('console-kit-proof-root'); + await expect(root).toHaveAttribute('data-profile', profile); + await expect(root).toHaveAttribute('data-preset', tenant.preset); + await expect(root).toHaveAttribute('data-database-id', tenant.database.id); + await expect(root).toHaveAttribute('data-proof-status', 'ready'); + await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible(); + + const featureNavigation = page.locator('[data-slot="sidebar-container"]'); + await expect(featureNavigation).toBeVisible(); + const installed = new Set(PROFILE_FEATURES[profile]); + for (const label of FEATURE_LABELS) { + await expect(featureNavigation.getByRole('link', { name: label, exact: true })) + .toHaveCount(installed.has(label) ? 1 : 0); + } + await expect(featureNavigation.getByRole('link', { name: 'Data', exact: true })) + .toHaveAttribute('href', '#console-data'); + } +}); + +test('signs up, opens _meta-discovered data, signs out, and signs back in through Console Kit', async ({ page }) => { + const tenant = proof.tenant('auth-hardened'); + const credentials = uniqueCredentials(proof.credentials(tenant), 'browser-auth'); + await page.goto(proofPageUrl('auth-hardened')); + + await page.getByRole('button', { name: 'Create account', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Create an account', exact: true })) + .toBeVisible(); + await page.getByLabel('Email address').fill(credentials.email); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(credentials.password); + await page.getByRole('button', { name: 'Create account', exact: true }).click(); + + await expect(page.getByRole('heading', { name: 'Account', exact: true })) + .toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign out', exact: true })) + .toBeVisible(); + + await page.reload(); + await expect(page.getByRole('heading', { name: 'Account', exact: true })) + .toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign out', exact: true })) + .toBeVisible(); + + await page.getByRole('link', { name: 'App access', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'App access', exact: true })).toBeVisible(); + + await page.getByRole('link', { name: 'Data', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Data', exact: true })) + .toBeVisible(); + await expect(page.getByTestId('table-item').filter({ hasText: /projects?/iu })) + .toBeVisible(); + + await page.getByRole('link', { name: 'Authentication', exact: true }).click(); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible(); + + await page.getByLabel('Email address').fill(credentials.email); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(credentials.password); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Account', exact: true })) + .toBeVisible(); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible(); +}); + +for (const profile of OFFICIAL_PROFILES) { + test(`${profile}: signup, signin, session revocation, and direct-owner CRUD work through public GraphQL`, async () => { + const tenant = proof.tenant(profile); + const owner = await createSession(tenant, 'owner', 'crud'); + const peer = await createSession(tenant, 'peer', 'crud'); + const sessions = [owner.session, peer.session]; + let ownerRow: Readonly> | null = null; + + try { + const restored = await signIn(tenant, owner.credentials); + sessions.push(restored); + expect(restored.userId).toBe(owner.session.userId); + + const ownerSchema = await loadSchema(tenant, owner.session.token); + const peerSchema = await loadSchema(tenant, peer.session.token); + const ownerTable = projectTable(ownerSchema); + const peerTable = projectTable(peerSchema); + const marker = randomUUID(); + + ownerRow = await createRow( + tenant, + owner.session.token, + ownerSchema, + ownerTable, + { name: `Owner project ${marker}`, description: 'private', completed: false }, + ['id', 'name', 'description', 'completed', 'ownerId'] + ); + expect(ownerRow.ownerId).toBe(owner.session.userId); + + const ownerRows = await listRows( + tenant, + owner.session.token, + ownerSchema, + ownerTable, + ['id', 'name', 'ownerId'] + ); + expect(ownerRows.some((row) => row.id === ownerRow?.id)).toBe(true); + + const peerRows = await listRows( + tenant, + peer.session.token, + peerSchema, + peerTable, + ['id', 'name', 'ownerId'] + ); + expect(peerRows.some((row) => row.id === ownerRow?.id)).toBe(false); + + await expect( + updateRow( + tenant, + peer.session.token, + peerSchema, + peerTable, + ownerRow, + { description: 'peer overwrite' }, + ['id', 'description'] + ) + ).rejects.toThrow(); + await expect( + deleteRow(tenant, peer.session.token, peerSchema, peerTable, ownerRow) + ).rejects.toThrow(); + const afterPeerDelete = await listRows( + tenant, + owner.session.token, + ownerSchema, + ownerTable, + ['id', 'description'] + ); + expect(afterPeerDelete.some((row) => row.id === ownerRow?.id)).toBe(true); + + ownerRow = await updateRow( + tenant, + owner.session.token, + ownerSchema, + ownerTable, + ownerRow, + { description: 'owner update', completed: true }, + ['id', 'description', 'completed', 'ownerId'] + ); + expect(ownerRow).toMatchObject({ description: 'owner update', completed: true }); + await deleteRow(tenant, owner.session.token, ownerSchema, ownerTable, ownerRow); + ownerRow = null; + + let anonymousDenied = false; + try { + const rows = await listRows(tenant, '', ownerSchema, ownerTable, ['id']); + anonymousDenied = rows.length === 0; + } catch { + anonymousDenied = true; + } + expect(anonymousDenied, 'Anonymous callers must not read direct-owner projects.').toBe(true); + + await signOut(tenant, restored.token); + sessions.splice(sessions.indexOf(restored), 1); + await expectTokenRejected(endpointUrl(tenant, 'data'), restored.token); + } finally { + if (ownerRow) { + const schema = await loadSchema(tenant, owner.session.token).catch(() => null); + if (schema) { + await deleteRow( + tenant, + owner.session.token, + schema, + projectTable(schema), + ownerRow + ).catch(() => undefined); + } + } + await Promise.allSettled(sessions.map((session) => signOut(tenant, session.token))); + } + }); +} + +test('rejects invalid, cross-tenant, and revoked bearer tokens', async () => { + const first = proof.tenant('auth-hardened'); + const second = proof.tenant('b2b-storage'); + const firstActor = await createSession(first, 'owner', 'bearer'); + const secondActor = await createSession(second, 'owner', 'bearer'); + + try { + await expectTokenRejected(endpointUrl(first, 'data'), 'invalid.console.token'); + await expectTokenRejected(endpointUrl(second, 'data'), firstActor.session.token); + await signOut(second, secondActor.session.token); + await expectTokenRejected(endpointUrl(second, 'data'), secondActor.session.token); + } finally { + await Promise.allSettled([ + signOut(first, firstActor.session.token), + signOut(second, secondActor.session.token) + ]); + } +}); + +test('discovers routed Storage tables in _meta and keeps unsupported writes unavailable in Console Kit', async ({ page }) => { + const tenant = proof.tenant('storage-routed'); + const actor = await createSession(tenant, 'owner', 'storage'); + const storageUrl = endpointUrl(tenant, 'storage'); + + try { + const schema = await loadSchemaAt(storageUrl, actor.session.token); + const bucketTables = schema.entries.filter(({ meta }) => meta.storage?.isBucketsTable); + const fileTables = schema.entries.filter(({ meta }) => meta.storage?.isFilesTable); + expect(bucketTables, 'Storage requires one @storageBuckets table on the bound endpoint.') + .toHaveLength(1); + expect(fileTables, 'Storage requires one @storageFiles table on the same endpoint.') + .toHaveLength(1); + + const introspection = await graphQL<{ + __schema: { queryType: { fields: readonly { name: string }[] } | null }; + }>( + storageUrl, + 'query ConsoleKitStorageRoots { __schema { queryType { fields { name } } } }', + {}, + actor.session.token + ); + const queryNames = introspection.__schema.queryType?.fields.map(({ name }) => name) ?? []; + for (const { meta } of [...bucketTables, ...fileTables]) { + expect(meta.query?.all, `${meta.name} must publish a connection root in _meta.`).toBeTruthy(); + expect(queryNames).toContain(meta.query!.all!); + } + + await page.goto(proofPageUrl('storage-routed')); + await page.getByLabel('Email address').fill(actor.credentials.email); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(actor.credentials.password); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Account', exact: true })) + .toBeVisible(); + await page.getByRole('link', { name: 'Organizations', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Organizations', exact: true })) + .toBeVisible(); + await page.getByRole('link', { name: 'Storage', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible(); + await expect( + page.getByText('No storage buckets', { exact: true }) + .or(page.getByText('Buckets', { exact: true })) + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'New bucket', exact: true })).toHaveCount(0); + await expect(page.getByText('Upload files', { exact: true })).toHaveCount(0); + + await page.getByRole('link', { name: 'Authentication', exact: true }).click(); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible(); + } finally { + await signOut(tenant, actor.session.token).catch(() => undefined); + } +}); diff --git a/apps/blocks/e2e-live/constructive-graphql.ts b/apps/blocks/e2e-live/constructive-graphql.ts new file mode 100644 index 0000000..d6438e1 --- /dev/null +++ b/apps/blocks/e2e-live/constructive-graphql.ts @@ -0,0 +1,391 @@ +import { + META_QUERY_SOURCE, + assertMetaQuery, + buildPostGraphileCreate, + buildPostGraphileDelete, + buildPostGraphileUpdate, + buildSelect, + cleanTable, + resolveRowIdentity, + rowIdentityToPrimaryKey, + toCamelCasePlural, + toCamelCaseSingular, + toCreateMutationName, + toPatchFieldName, + toUpdateMutationName, + type CleanTable, + type MetaQuery, + type MetaTable, + type RowIdentityValue +} from '@constructive-io/data'; + +import { endpointUrl, type ProofCredentials, type ProofTenant } from './proof-context'; + +type GraphQLError = Readonly<{ + message?: string; + extensions?: Readonly<{ code?: string; [key: string]: unknown }>; +}>; + +export type GraphQLPayload = Readonly<{ + data?: TData | null; + errors?: readonly GraphQLError[]; +}>; + +export type GraphQLRequestFingerprint = Readonly<{ + origin?: string; + userAgent?: string; +}>; + +export type LiveSession = Readonly<{ + token: string; + userId: string; + sessionId: string | null; +}>; + +export type LiveTable = Readonly<{ + meta: MetaTable; + clean: CleanTable; +}>; + +export type LiveSchema = Readonly<{ + tables: readonly CleanTable[]; + entries: readonly LiveTable[]; + table(name: string): LiveTable; +}>; + +const SIGN_IN = /* GraphQL */ ` + mutation ConsoleKitProofSignIn($input: SignInInput!) { + signIn(input: $input) { + result { id userId accessToken accessTokenExpiresAt } + } + } +`; + +const SIGN_UP = /* GraphQL */ ` + mutation ConsoleKitProofSignUp($input: SignUpInput!) { + signUp(input: $input) { + result { id userId accessToken accessTokenExpiresAt } + } + } +`; + +const SIGN_OUT = /* GraphQL */ ` + mutation ConsoleKitProofSignOut($input: SignOutInput!) { + signOut(input: $input) { clientMutationId } + } +`; + +function documentSource(document: unknown): string { + if (typeof document === 'string') return document; + if (document && typeof document === 'object' && 'toString' in document) { + return String(document); + } + throw new Error('The generated GraphQL document could not be serialized.'); +} + +function errorSummary(errors: readonly GraphQLError[] | undefined): string { + if (!errors?.length) return 'unknown GraphQL error'; + return errors.map((error) => { + const code = error.extensions?.code; + return typeof code === 'string' ? code : 'GRAPHQL_ERROR'; + }).join(', '); +} + +export async function rawGraphQL( + url: string, + document: unknown, + variables: Readonly> = {}, + token?: string, + fingerprint: GraphQLRequestFingerprint = {} +): Promise> { + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Origin: fingerprint.origin ?? new URL(process.env.CONSOLE_KIT_BASE_URL ?? url).origin, + 'User-Agent': fingerprint.userAgent ?? 'constructive-console-kit-live-proof', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify({ query: documentSource(document), variables }) + }); + if (!response.ok) { + throw new Error(`GraphQL transport to ${url} returned HTTP ${response.status}.`); + } + + const payload: unknown = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('GraphQL transport returned an invalid JSON envelope.'); + } + return payload as GraphQLPayload; +} + +export async function graphQL( + url: string, + document: unknown, + variables: Readonly> = {}, + token?: string +): Promise { + const payload = await rawGraphQL(url, document, variables, token); + if (payload.errors?.length || payload.data == null) { + throw new Error(`GraphQL operation failed (${errorSummary(payload.errors)}).`); + } + return payload.data; +} + +function sessionFrom(data: unknown, field: 'signIn' | 'signUp'): LiveSession { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`${field} returned no session.`); + } + const mutation = (data as Record)[field]; + const result = mutation && typeof mutation === 'object' && !Array.isArray(mutation) + ? (mutation as Record).result + : null; + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error(`${field} returned no session.`); + } + const token = (result as Record).accessToken; + const userId = (result as Record).userId; + const sessionId = (result as Record).id; + if (typeof token !== 'string' || !token || typeof userId !== 'string' || !userId) { + throw new Error(`${field} returned an unusable session.`); + } + return { + token, + userId, + sessionId: typeof sessionId === 'string' ? sessionId : null + }; +} + +export async function signIn( + tenant: ProofTenant, + credentials: ProofCredentials +): Promise { + return signInAt(endpointUrl(tenant, 'auth'), credentials); +} + +export async function signInAt( + url: string, + credentials: ProofCredentials +): Promise { + const data = await graphQL>( + url, + SIGN_IN, + { input: { email: credentials.email, password: credentials.password, rememberMe: false } } + ); + return sessionFrom(data, 'signIn'); +} + +export async function signUp( + tenant: ProofTenant, + credentials: ProofCredentials +): Promise { + const data = await graphQL>( + endpointUrl(tenant, 'auth'), + SIGN_UP, + { input: { email: credentials.email, password: credentials.password, rememberMe: false } } + ); + return sessionFrom(data, 'signUp'); +} + +export async function signOut(tenant: ProofTenant, token: string): Promise { + await signOutAt(endpointUrl(tenant, 'auth'), token); +} + +export async function signOutAt(url: string, token: string): Promise { + await graphQL(url, SIGN_OUT, { input: {} }, token); +} + +export async function loadSchema(tenant: ProofTenant, token: string): Promise { + return loadSchemaAt(endpointUrl(tenant, 'data'), token); +} + +export async function loadSchemaAt(url: string, token: string): Promise { + const data = await graphQL( + url, + META_QUERY_SOURCE, + {}, + token + ); + assertMetaQuery(data); + const metaTables = (data._meta?.tables ?? []).filter( + (table): table is MetaTable => Boolean(table?.name) + ); + const tables = metaTables.map(cleanTable); + const entries = metaTables.map((meta, index): LiveTable => ({ + meta, + clean: tables[index]! + })); + + return { + tables, + entries, + table(name) { + const entry = entries.find(({ meta }) => meta.name === name); + if (!entry) throw new Error(`_meta did not expose table ${name}.`); + return entry; + } + }; +} + +export async function listRows( + tenant: ProofTenant, + token: string, + schema: LiveSchema, + table: LiveTable, + fields: readonly string[] +): Promise>[]> { + const document = buildSelect(table.clean, [...schema.tables], { + fieldSelection: { select: [...fields] } + }); + const rootField = toCamelCasePlural(table.clean.name, table.clean); + const data = await graphQL>( + endpointUrl(tenant, 'data'), + document, + {}, + token + ); + const connection = data[rootField]; + if (!connection || typeof connection !== 'object' || Array.isArray(connection)) { + throw new Error(`The ${rootField} query returned no connection.`); + } + const nodes = (connection as Record).nodes; + if (!Array.isArray(nodes)) throw new Error(`The ${rootField} query returned no nodes.`); + return nodes.filter( + (row): row is Readonly> => + Boolean(row) && typeof row === 'object' && !Array.isArray(row) + ); +} + +export async function createRow( + tenant: ProofTenant, + token: string, + schema: LiveSchema, + table: LiveTable, + input: Readonly>, + fields: readonly string[] +): Promise>> { + const request = createRequest(schema, table, input, fields); + const data = await graphQL>( + endpointUrl(tenant, 'data'), + request.document, + request.variables, + token + ); + const payload = data[request.mutation]; + const row = payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record)[request.singular] + : null; + if (!row || typeof row !== 'object' || Array.isArray(row)) { + throw new Error(`The ${request.mutation} mutation returned no row.`); + } + return row as Readonly>; +} + +export function createRequest( + schema: LiveSchema, + table: LiveTable, + input: Readonly>, + fields: readonly string[] +): Readonly<{ document: unknown; variables: Readonly>; mutation: string; singular: string }> { + const document = buildPostGraphileCreate(table.clean, [...schema.tables], { + fieldSelection: { select: [...fields] } + }); + const singular = toCamelCaseSingular(table.clean.name, table.clean); + const mutation = toCreateMutationName(table.clean.name, table.clean); + return { + document, + variables: { input: { [singular]: input } }, + mutation, + singular + }; +} + +function primaryKey(table: LiveTable, row: Readonly>): +Readonly> { + const resolution = resolveRowIdentity(table.meta, row); + if (resolution.status !== 'identified') { + throw new Error(`The ${table.meta.name} row has no usable primary-key identity.`); + } + return rowIdentityToPrimaryKey(resolution.identity); +} + +export function updateRequest( + schema: LiveSchema, + table: LiveTable, + row: Readonly>, + patch: Readonly>, + fields: readonly string[] +): Readonly<{ document: unknown; variables: Readonly>; mutation: string; singular: string }> { + const document = buildPostGraphileUpdate(table.clean, [...schema.tables], { + fieldSelection: { select: [...fields] } + }); + const singular = toCamelCaseSingular(table.clean.name, table.clean); + const mutation = toUpdateMutationName(table.clean.name, table.clean); + const patchField = toPatchFieldName(table.clean.name, table.clean); + return { + document, + variables: { input: { ...primaryKey(table, row), [patchField]: patch } }, + mutation, + singular + }; +} + +export function deleteRequest( + schema: LiveSchema, + table: LiveTable, + row: Readonly> +): Readonly<{ document: unknown; variables: Readonly> }> { + return { + document: buildPostGraphileDelete(table.clean, [...schema.tables]), + variables: { input: primaryKey(table, row) } + }; +} + +export async function updateRow( + tenant: ProofTenant, + token: string, + schema: LiveSchema, + table: LiveTable, + row: Readonly>, + patch: Readonly>, + fields: readonly string[] +): Promise>> { + const request = updateRequest(schema, table, row, patch, fields); + const data = await graphQL>( + endpointUrl(tenant, 'data'), + request.document, + request.variables, + token + ); + const payload = data[request.mutation]; + const updated = payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record)[request.singular] + : null; + if (!updated || typeof updated !== 'object' || Array.isArray(updated)) { + throw new Error(`The ${request.mutation} mutation returned no row.`); + } + return updated as Readonly>; +} + +export async function deleteRow( + tenant: ProofTenant, + token: string, + schema: LiveSchema, + table: LiveTable, + row: Readonly> +): Promise { + const request = deleteRequest(schema, table, row); + await graphQL( + endpointUrl(tenant, 'data'), + request.document, + request.variables, + token + ); +} + +export function authenticationErrorCodes(payload: GraphQLPayload): string[] { + return (payload.errors ?? []).flatMap((error) => + typeof error.extensions?.code === 'string' ? [error.extensions.code] : [] + ); +} diff --git a/apps/blocks/e2e-live/native-fixture-contract.ts b/apps/blocks/e2e-live/native-fixture-contract.ts new file mode 100644 index 0000000..dc00e11 --- /dev/null +++ b/apps/blocks/e2e-live/native-fixture-contract.ts @@ -0,0 +1,340 @@ +import { isAbsolute } from 'node:path'; + +export const NATIVE_FIXTURE_KIND = 'constructive-native-tenant-fixture' as const; +export const NATIVE_FIXTURE_VERSION = 1 as const; +export const OFFICIAL_PRESETS = ['auth:hardened', 'b2b:storage', 'full'] as const; +export const FIXTURE_PROFILES = [ + 'auth-hardened', + 'b2b-storage', + 'full', + 'storage-routed' +] as const; + +export type OfficialPreset = (typeof OFFICIAL_PRESETS)[number]; +export type FixtureProfile = (typeof FIXTURE_PROFILES)[number]; + +export type NativeEndpoint = Readonly<{ + apiId: string; + apiName: string; + domainId: string; + domain: string; + subdomain: string; + url: string; + schemas: readonly string[]; +}>; + +export type NativeFixtureTenant = Readonly<{ + profile: FixtureProfile; + preset: OfficialPreset; + presetMode: 'official' | 'official-with-storage-route'; + database: Readonly<{ + id: string; + name: string; + domain: string; + subdomain: string; + }>; + projects: Readonly<{ + tableId: string; + tableName: 'projects'; + }>; + endpoints: readonly NativeEndpoint[]; + capabilityBindings: Readonly<{ + storageApiName?: 'admin'; + }>; +}>; + +export type NativeFixtureManifest = Readonly<{ + version: typeof NATIVE_FIXTURE_VERSION; + kind: typeof NATIVE_FIXTURE_KIND; + status: 'provisioning' | 'ready' | 'failed' | 'cleaned'; + runId: string; + createdAt: string; + constructiveDbPath: string; + presetModulePath: string; + platformDatabase: string; + graphqlOrigin: string; + membershipFixtureMode: 'auto-approved-and-verified'; + databaseIds: readonly string[]; + cleanedDatabaseIds: readonly string[]; + tenants: readonly NativeFixtureTenant[]; +}>; + +export type FixtureRecord = Record; + +// PostgreSQL's uuid type validates the canonical 8-4-4-4-12 shape without +// requiring RFC version or variant bits. Constructive's deterministic IDs use +// that full space, so the fixture applies the same contract. +const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu; +export const FIXTURE_HOST_PART = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*$/iu; +const SECRET_KEY = /(?:token|password|secret|authorization|credential|api[_-]?key|private[_-]?key|cookie|session)/iu; + +function assertExactKeys( + value: FixtureRecord, + allowedKeys: readonly string[], + label: string +): void { + const allowed = new Set(allowedKeys); + const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`${label} contains unknown key ${unknownKeys[0]}.`); + } +} + +export function fixtureRecord(value: unknown, label: string): FixtureRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as FixtureRecord; +} + +export function fixtureNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + return value; +} + +export function fixtureUuid(value: unknown, label: string): string { + const result = fixtureNonEmptyString(value, label); + if (!UUID.test(result)) throw new Error(`${label} must be a UUID.`); + return result; +} + +function assertSecretFree(value: unknown, label = 'native fixture manifest'): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => assertSecretFree(entry, `${label}[${index}]`)); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + if (SECRET_KEY.test(key)) throw new Error(`${label} contains forbidden key ${key}.`); + assertSecretFree(child, `${label}.${key}`); + } +} + +export function endpointUrl( + graphqlOrigin: string, + route: Readonly<{ domain: string; subdomain: string }> +): string { + const base = new URL(graphqlOrigin); + if (base.protocol !== 'http:' && base.protocol !== 'https:') { + throw new Error('The GraphQL origin must use HTTP or HTTPS.'); + } + if (base.pathname !== '/' || base.search || base.hash || base.username || base.password) { + throw new Error('The GraphQL origin must contain only a scheme, host, and optional port.'); + } + if (!FIXTURE_HOST_PART.test(route.domain) || !FIXTURE_HOST_PART.test(route.subdomain)) { + throw new Error('Discovered endpoint metadata contains an invalid domain or subdomain.'); + } + const hostname = `${route.subdomain}.${route.domain}`; + return `${base.protocol}//${hostname}${base.port ? `:${base.port}` : ''}/graphql`; +} + +export function assertNativeFixtureManifest(value: unknown): asserts value is NativeFixtureManifest { + assertSecretFree(value); + const manifest = fixtureRecord(value, 'native fixture manifest'); + assertExactKeys(manifest, [ + 'version', + 'kind', + 'status', + 'runId', + 'createdAt', + 'constructiveDbPath', + 'presetModulePath', + 'platformDatabase', + 'graphqlOrigin', + 'membershipFixtureMode', + 'databaseIds', + 'cleanedDatabaseIds', + 'tenants' + ], 'native fixture manifest'); + if (manifest.version !== NATIVE_FIXTURE_VERSION || manifest.kind !== NATIVE_FIXTURE_KIND) { + throw new Error('The native fixture manifest version or kind is unsupported.'); + } + if (!['provisioning', 'ready', 'failed', 'cleaned'].includes(String(manifest.status))) { + throw new Error('The native fixture manifest has an invalid status.'); + } + fixtureNonEmptyString(manifest.runId, 'native fixture manifest.runId'); + fixtureNonEmptyString(manifest.createdAt, 'native fixture manifest.createdAt'); + if (!isAbsolute(fixtureNonEmptyString( + manifest.constructiveDbPath, + 'native fixture manifest.constructiveDbPath' + ))) { + throw new Error('native fixture manifest.constructiveDbPath must be absolute.'); + } + if (!isAbsolute(fixtureNonEmptyString( + manifest.presetModulePath, + 'native fixture manifest.presetModulePath' + ))) { + throw new Error('native fixture manifest.presetModulePath must be absolute.'); + } + fixtureNonEmptyString(manifest.platformDatabase, 'native fixture manifest.platformDatabase'); + if (manifest.membershipFixtureMode !== 'auto-approved-and-verified') { + throw new Error('native fixture manifest.membershipFixtureMode is unsupported.'); + } + endpointUrl(fixtureNonEmptyString( + manifest.graphqlOrigin, + 'native fixture manifest.graphqlOrigin' + ), { + domain: 'localhost', + subdomain: 'fixture-validation' + }); + + if (!Array.isArray(manifest.databaseIds) || !Array.isArray(manifest.cleanedDatabaseIds)) { + throw new Error('The native fixture database ID lists must be arrays.'); + } + const databaseIds = manifest.databaseIds.map((entry, index) => + fixtureUuid(entry, `databaseIds[${index}]`) + ); + const cleanedIds = manifest.cleanedDatabaseIds.map((entry, index) => + fixtureUuid(entry, `cleanedDatabaseIds[${index}]`) + ); + if (new Set(databaseIds).size !== databaseIds.length) { + throw new Error('The native fixture manifest contains duplicate database IDs.'); + } + if ( + new Set(cleanedIds).size !== cleanedIds.length || + cleanedIds.some((id) => !databaseIds.includes(id)) + ) { + throw new Error('The cleaned database IDs must be a unique subset of databaseIds.'); + } + + if (!Array.isArray(manifest.tenants)) throw new Error('The native fixture tenants must be an array.'); + const tenantIds: string[] = []; + const profiles: string[] = []; + for (const [index, rawTenant] of manifest.tenants.entries()) { + const tenant = fixtureRecord(rawTenant, `tenants[${index}]`); + assertExactKeys(tenant, [ + 'profile', + 'preset', + 'presetMode', + 'database', + 'projects', + 'endpoints', + 'capabilityBindings' + ], `tenants[${index}]`); + const profile = fixtureNonEmptyString(tenant.profile, `tenants[${index}].profile`); + if (!(FIXTURE_PROFILES as readonly string[]).includes(profile)) { + throw new Error(`tenants[${index}].profile is unsupported.`); + } + profiles.push(profile); + if (!(OFFICIAL_PRESETS as readonly string[]).includes(String(tenant.preset))) { + throw new Error(`tenants[${index}].preset is unsupported.`); + } + const expectedMode = profile === 'storage-routed' ? 'official-with-storage-route' : 'official'; + if (tenant.presetMode !== expectedMode) { + throw new Error(`tenants[${index}].presetMode does not match its profile.`); + } + const database = fixtureRecord(tenant.database, `tenants[${index}].database`); + assertExactKeys(database, ['id', 'name', 'domain', 'subdomain'], `tenants[${index}].database`); + tenantIds.push(fixtureUuid(database.id, `tenants[${index}].database.id`)); + fixtureNonEmptyString(database.name, `tenants[${index}].database.name`); + fixtureNonEmptyString(database.domain, `tenants[${index}].database.domain`); + fixtureNonEmptyString(database.subdomain, `tenants[${index}].database.subdomain`); + const projects = fixtureRecord(tenant.projects, `tenants[${index}].projects`); + assertExactKeys(projects, ['tableId', 'tableName'], `tenants[${index}].projects`); + fixtureUuid(projects.tableId, `tenants[${index}].projects.tableId`); + if (projects.tableName !== 'projects') throw new Error('The native RLS fixture table must be projects.'); + if (!Array.isArray(tenant.endpoints)) { + throw new Error(`tenants[${index}].endpoints must be an array.`); + } + const endpointApiNames: string[] = []; + for (const [endpointIndex, rawEndpoint] of tenant.endpoints.entries()) { + const endpoint = fixtureRecord(rawEndpoint, `tenants[${index}].endpoints[${endpointIndex}]`); + assertExactKeys(endpoint, [ + 'apiId', + 'apiName', + 'domainId', + 'domain', + 'subdomain', + 'url', + 'schemas' + ], `tenants[${index}].endpoints[${endpointIndex}]`); + fixtureUuid(endpoint.apiId, `tenants[${index}].endpoints[${endpointIndex}].apiId`); + fixtureUuid(endpoint.domainId, `tenants[${index}].endpoints[${endpointIndex}].domainId`); + endpointApiNames.push(fixtureNonEmptyString( + endpoint.apiName, + `tenants[${index}].endpoints[${endpointIndex}].apiName` + )); + const endpointDomain = fixtureNonEmptyString( + endpoint.domain, + `tenants[${index}].endpoints[${endpointIndex}].domain` + ); + const endpointSubdomain = fixtureNonEmptyString( + endpoint.subdomain, + `tenants[${index}].endpoints[${endpointIndex}].subdomain` + ); + const url = new URL(fixtureNonEmptyString( + endpoint.url, + `tenants[${index}].endpoints[${endpointIndex}].url` + )); + if (url.pathname !== '/graphql') throw new Error('Every discovered endpoint must use /graphql.'); + if (url.toString() !== endpointUrl(String(manifest.graphqlOrigin), { + domain: endpointDomain, + subdomain: endpointSubdomain + })) { + throw new Error('A discovered endpoint URL does not match its domain metadata.'); + } + if ( + !Array.isArray(endpoint.schemas) || + endpoint.schemas.some((schema) => typeof schema !== 'string') + ) { + throw new Error(`tenants[${index}].endpoints[${endpointIndex}].schemas must contain strings.`); + } + } + if (new Set(endpointApiNames).size !== endpointApiNames.length) { + throw new Error(`tenants[${index}] exposes an API through multiple public domains.`); + } + const bindings = fixtureRecord( + tenant.capabilityBindings, + `tenants[${index}].capabilityBindings` + ); + assertExactKeys(bindings, ['storageApiName'], `tenants[${index}].capabilityBindings`); + if (profile === 'storage-routed' && bindings.storageApiName !== 'admin') { + throw new Error('The routed Storage fixture must bind Storage to the admin API.'); + } + if (profile !== 'storage-routed' && bindings.storageApiName !== undefined) { + throw new Error('Official presets must not invent a Storage endpoint binding.'); + } + } + + if (new Set(profiles).size !== profiles.length) { + throw new Error('The native fixture manifest contains duplicate profiles.'); + } + if (new Set(tenantIds).size !== tenantIds.length) { + throw new Error('The native fixture manifest contains duplicate tenant database IDs.'); + } + if (tenantIds.some((id) => !databaseIds.includes(id))) { + throw new Error('Every tenant database ID must be recorded in databaseIds.'); + } + if (manifest.status === 'ready') { + if (cleanedIds.length > 0) { + throw new Error('A ready native fixture cannot contain cleaned database IDs.'); + } + for (const profile of FIXTURE_PROFILES) { + if (profiles.filter((candidate) => candidate === profile).length !== 1) { + throw new Error(`A ready native fixture requires exactly one ${profile} tenant.`); + } + } + if ( + tenantIds.length !== databaseIds.length || + databaseIds.some((id) => !tenantIds.includes(id)) + ) { + throw new Error('A ready native fixture must describe every provisioned database exactly once.'); + } + } + if (manifest.status === 'cleaned' && cleanedIds.length !== databaseIds.length) { + throw new Error('A cleaned native fixture must record every database ID as cleaned.'); + } +} + +export function tenantEndpoint( + tenant: NativeFixtureTenant, + apiName: string +): NativeEndpoint | undefined { + const matches = tenant.endpoints.filter((endpoint) => endpoint.apiName === apiName); + if (matches.length > 1) { + throw new Error(`${tenant.profile} exposes ${apiName} through multiple public domains.`); + } + return matches[0]; +} diff --git a/apps/blocks/e2e-live/native-fixture.test.ts b/apps/blocks/e2e-live/native-fixture.test.ts new file mode 100644 index 0000000..ecfc430 --- /dev/null +++ b/apps/blocks/e2e-live/native-fixture.test.ts @@ -0,0 +1,619 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + assertNativeFixtureManifest, + cleanupDatabasesRequest, + cleanupInventoryRequest, + cleanupNativeFixture, + cleanupVerificationRequest, + dropFixtureSchemaRequest, + endpointDiscoveryRequest, + endpointUrl, + loadOfficialPresetModules, + projectProvisionRequest, + provisionDatabaseRequest, + provisionNativeFixture, + routeStorageToAdmin, + type NativeFixtureDatabase, + type NativeFixtureManifest, + type SqlRequest +} from './native-fixture'; +import { + assertNativeFixtureManifestSlotAvailable, + parseNativeFixtureCliArgs, + writeNativeFixtureManifest +} from '../scripts/console-kit-native-fixture'; + +const PLATFORM_ID = '00000000-0000-4000-2000-000000000001'; +const OWNER_ID = '00000000-0000-4000-8000-000000000002'; + +async function fakeConstructiveDb(): Promise { + const root = await mkdtemp(join(tmpdir(), 'blocks-native-fixture-')); + const presetDirectory = join(root, 'packages/node-type-registry/src/module-presets'); + await mkdir(presetDirectory, { recursive: true }); + await writeFile( + join(presetDirectory, 'index.mjs'), + `export const allModulePresets = [ + { name: 'auth:hardened', modules: ['users_module', ['memberships_module', { scope: 'app' }]] }, + { name: 'b2b:storage', modules: ['users_module', ['memberships_module', { scope: 'app' }], 'storage_module'] }, + { name: 'full', modules: ['users_module', ['memberships_module', { scope: 'app' }], ['storage_module', { has_versioning: true }], 'billing_module'] } + ];\n` + ); + return root; +} + +function uuidAt(index: number): string { + return `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +} + +class FakeDatabase implements NativeFixtureDatabase { + readonly requests: SqlRequest[] = []; + readonly executed: SqlRequest[] = []; + provisionIndex = 0; + projectIndex = 0; + cleanupIds: string[] | null = null; + cleanupInventoryIds: string[] | null = null; + remainingDatabaseIds: string[] = []; + remainingSchemaNames: string[] = []; + failProvisionAt: number | null = null; + failProjectAt: number | null = null; + + async json(request: SqlRequest): Promise { + this.requests.push(request); + if (request.sql.includes("'platformDatabaseId'") && request.sql.includes("'ownerId'")) { + return { platformDatabaseId: PLATFORM_ID, ownerId: OWNER_ID } as T; + } + if (request.sql.includes('metaschema_generators.provision_database')) { + this.provisionIndex += 1; + if (this.failProvisionAt === this.provisionIndex) { + throw new Error('synthetic provision failure'); + } + return { databaseId: uuidAt(this.provisionIndex) } as T; + } + if (request.sql.includes('membership_defaults_table_id')) { + return { schemaName: 'fixture_memberships_public', tableName: 'app_membership_defaults' } as T; + } + if (request.sql.includes('secure_table_provision')) { + this.projectIndex += 1; + if (this.failProjectAt === this.projectIndex) throw new Error('synthetic project failure'); + return { tableId: uuidAt(100 + this.projectIndex), tableName: 'projects' } as T; + } + if (request.sql.includes('services_public.apis')) { + const databaseId = request.variables?.database_id; + const suffix = databaseId?.slice(-1) ?? '0'; + return [ + { + apiId: uuidAt(200 + Number(suffix)), + apiName: 'admin', + domainId: uuidAt(300 + Number(suffix)), + domain: 'localhost', + subdomain: `admin-console-kit-${suffix}`, + schemas: ['admin_public'] + }, + { + apiId: uuidAt(400 + Number(suffix)), + apiName: 'api', + domainId: uuidAt(500 + Number(suffix)), + domain: 'localhost', + subdomain: `api-console-kit-${suffix}`, + schemas: ['app_public'] + }, + { + apiId: uuidAt(600 + Number(suffix)), + apiName: 'auth', + domainId: uuidAt(700 + Number(suffix)), + domain: 'localhost', + subdomain: `auth-console-kit-${suffix}`, + schemas: ['auth_public'] + } + ] as T; + } + if (request.sql.includes('console-kit fixture cleanup inventory')) { + const requested = JSON.parse(request.variables?.database_ids ?? '[]') as string[]; + const matchedDatabaseIds = this.cleanupInventoryIds ?? requested; + return { + requested, + matchedDatabaseIds, + platformDatabaseId: PLATFORM_ID, + schemas: matchedDatabaseIds.map((databaseId, index) => ({ + databaseId, + schemaName: `console-kit-fixture-${index + 1}-public` + })) + } as T; + } + if (request.sql.includes('DELETE FROM metaschema_public.database')) { + const requested = JSON.parse(request.variables?.database_ids ?? '[]') as string[]; + const deleted = this.cleanupIds ?? requested; + return { requested, deleted } as T; + } + if (request.sql.includes('console-kit fixture cleanup verification')) { + return { + remainingDatabaseIds: this.remainingDatabaseIds, + remainingSchemaNames: this.remainingSchemaNames + } as T; + } + throw new Error(`Unhandled SQL request: ${request.sql}`); + } + + async execute(request: SqlRequest): Promise { + this.executed.push(request); + } +} + +test('loads the three official module arrays from the supplied Constructive DB checkout', async () => { + const root = await fakeConstructiveDb(); + const loaded = await loadOfficialPresetModules(root); + + assert.match(loaded.presetModulePath, /node-type-registry\/src\/module-presets\/index\.mjs$/u); + assert.deepEqual(loaded.presets['auth:hardened'], [ + 'users_module', + ['memberships_module', { scope: 'app' }] + ]); + assert.deepEqual(loaded.presets['b2b:storage'].at(-1), 'storage_module'); + assert.deepEqual(loaded.presets.full.at(-1), 'billing_module'); +}); + +test('derives routed Storage without mutating or flattening the official preset', () => { + const source = [ + 'users_module', + ['storage_module', { has_versioning: true }] as const + ]; + const routed = routeStorageToAdmin(source); + + assert.deepEqual(routed, [ + 'users_module', + ['storage_module', { has_versioning: true, api_name: 'admin' }] + ]); + assert.deepEqual(source, [ + 'users_module', + ['storage_module', { has_versioning: true }] + ]); + assert.throws(() => routeStorageToAdmin(['users_module']), /exactly one storage_module/u); +}); + +test('keeps values in psql variables for provisioning, RLS table creation, and cleanup', () => { + const provision = provisionDatabaseRequest({ + name: "fixture'; drop table users; --", + ownerId: OWNER_ID, + subdomain: 'fixture-safe', + domain: 'localhost', + modules: ['users_module'] + }); + assert.doesNotMatch(provision.sql, /drop table users/iu); + assert.equal(provision.variables?.database_name, "fixture'; drop table users; --"); + assert.match(provision.sql, /:'database_name'/u); + + const projects = projectProvisionRequest(uuidAt(1)); + assert.match(projects.sql, /metaschema_modules_public\.secure_table_provision/u); + assert.match( + projects.sql, + /metaschema_generators\.apply_scope_fields\([\s\S]*v_scope := 'app'/u + ); + assert.deepEqual(JSON.parse(projects.variables!.nodes), [ + { $type: 'DataId' }, + { $type: 'DataTimestamps' }, + { $type: 'DataDirectOwner' } + ]); + assert.deepEqual(JSON.parse(projects.variables!.policies)[0].privileges, [ + 'select', + 'insert', + 'update', + 'delete' + ]); + assert.deepEqual(JSON.parse(projects.variables!.fields)[2].default, { + value: false + }); + + const cleanup = cleanupDatabasesRequest([uuidAt(1), uuidAt(2)]); + assert.match(cleanup.sql, /DELETE FROM metaschema_public\.database/u); + assert.equal(cleanup.variables?.database_ids, JSON.stringify([uuidAt(1), uuidAt(2)])); + assert.doesNotMatch(cleanup.sql, new RegExp(uuidAt(1), 'u')); + + const inventory = cleanupInventoryRequest([uuidAt(1), uuidAt(2)]); + assert.match(inventory.sql, /metaschema_public\.schema/u); + assert.equal(inventory.variables?.database_ids, JSON.stringify([uuidAt(1), uuidAt(2)])); + + const hostileSchemaName = `fixture\"; DROP SCHEMA public; --`; + const drop = dropFixtureSchemaRequest(hostileSchemaName); + assert.equal( + drop.sql, + 'DROP SCHEMA IF EXISTS "fixture""; DROP SCHEMA public; --" CASCADE' + ); + + const verification = cleanupVerificationRequest([uuidAt(1)], [hostileSchemaName]); + assert.doesNotMatch(verification.sql, /DROP SCHEMA public/u); + assert.equal(verification.variables?.schema_names, JSON.stringify([hostileSchemaName])); +}); + +test('discovers endpoint routes from services metadata instead of constructing sibling API names', () => { + const request = endpointDiscoveryRequest(uuidAt(1)); + assert.match(request.sql, /services_public\.apis/u); + assert.match(request.sql, /services_public\.domains/u); + assert.match(request.sql, /services_public\.api_schemas/u); + assert.match(request.sql, /domain_record\.api_id = api\.id/u); + assert.equal( + endpointUrl('http://localhost:6464', { + domain: 'localhost', + subdomain: 'auth-tenant-from-metadata' + }), + 'http://auth-tenant-from-metadata.localhost:6464/graphql' + ); +}); + +test('provisions official profiles plus routed Storage and journals every exact database ID', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const journal: NativeFixtureManifest[] = []; + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'constructive-functions-console-kit-blocks', + runId: '11111111-1111-4111-8111-111111111111', + now: () => new Date('2026-07-23T00:00:00.000Z'), + writeManifest: async (value) => { + journal.push(structuredClone(value)); + } + }); + + assert.equal(manifest.status, 'ready'); + assert.deepEqual(manifest.databaseIds, [uuidAt(1), uuidAt(2), uuidAt(3), uuidAt(4)]); + assert.deepEqual(manifest.tenants.map((tenant) => tenant.profile), [ + 'auth-hardened', + 'b2b-storage', + 'full', + 'storage-routed' + ]); + assert.equal(manifest.tenants[3]!.preset, 'b2b:storage'); + assert.deepEqual(manifest.tenants[3]!.capabilityBindings, { storageApiName: 'admin' }); + assert.equal( + manifest.tenants[0]!.endpoints.find((endpoint) => endpoint.apiName === 'auth')?.url, + 'http://auth-console-kit-1.localhost:6464/graphql' + ); + assert.equal(database.executed.length, 4); + assert.ok(database.executed.every((request) => request.sql.includes('is_verified = TRUE'))); + + const provisionRequests = database.requests.filter((request) => + request.sql.includes('metaschema_generators.provision_database') + ); + const routedModules = JSON.parse(provisionRequests[3]!.variables!.modules); + assert.deepEqual(routedModules.at(-1), ['storage_module', { api_name: 'admin' }]); + assert.equal(journal.at(-1)?.status, 'ready'); + assert.ok(journal.some((entry) => entry.databaseIds.length === 1 && entry.status === 'provisioning')); + assertNativeFixtureManifest(manifest); +}); + +test('cleans only the UUIDs recorded by the manifest and records the result', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '22222222-2222-4222-8222-222222222222' + }); + const cleaned = await cleanupNativeFixture(database, manifest); + + assert.equal(cleaned.status, 'cleaned'); + assert.deepEqual(cleaned.cleanedDatabaseIds, manifest.databaseIds); + const cleanupRequest = database.requests.find((request) => + request.sql.includes('DELETE FROM metaschema_public.database') + )!; + assert.deepEqual(JSON.parse(cleanupRequest.variables!.database_ids), manifest.databaseIds); + const droppedSchemas = database.executed.filter((request) => request.sql.includes('DROP SCHEMA')); + assert.equal(droppedSchemas.length, manifest.databaseIds.length); + assert.ok(droppedSchemas.every((request) => /DROP SCHEMA IF EXISTS "console-kit-fixture-/u.test(request.sql))); + const verificationRequest = database.requests.find((request) => + request.sql.includes('console-kit fixture cleanup verification') + )!; + assert.deepEqual( + JSON.parse(verificationRequest.variables!.schema_names), + manifest.databaseIds.map((_, index) => `console-kit-fixture-${index + 1}-public`) + ); + assertNativeFixtureManifest(cleaned); +}); + +test('fails closed before dropping schemas when cleanup inventory is incomplete', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '23232323-2323-4323-8323-232323232323' + }); + database.cleanupInventoryIds = manifest.databaseIds.slice(1); + + await assert.rejects( + cleanupNativeFixture(database, manifest), + new RegExp(`cleanup inventory mismatch.*${manifest.databaseIds[0]}`, 'iu') + ); + assert.equal(database.executed.filter((request) => request.sql.includes('DROP SCHEMA')).length, 0); +}); + +test('fails closed when exact-ID cleanup does not delete the full journal', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '33333333-3333-4333-8333-333333333333' + }); + database.cleanupIds = manifest.databaseIds.slice(1); + + await assert.rejects( + cleanupNativeFixture(database, manifest), + new RegExp(`Exact-ID cleanup mismatch.*${manifest.databaseIds[0]}`, 'u') + ); +}); + +test('does not mark cleanup complete while a physical namespace remains', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '34343434-3434-4434-8434-343434343434' + }); + database.remainingSchemaNames = ['console-kit-fixture-1-public']; + + await assert.rejects( + cleanupNativeFixture(database, manifest), + /Cleanup verification failed.*console-kit-fixture-1-public/u + ); +}); + +test('rolls back provisioned IDs on failure unless --keep is selected', async () => { + const root = await fakeConstructiveDb(); + const rollbackDatabase = new FakeDatabase(); + rollbackDatabase.failProjectAt = 2; + const rollbackJournal: NativeFixtureManifest[] = []; + + await assert.rejects( + provisionNativeFixture(rollbackDatabase, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '44444444-4444-4444-8444-444444444444', + writeManifest: async (manifest) => { + rollbackJournal.push(structuredClone(manifest)); + } + }), + /synthetic project failure/u + ); + assert.equal(rollbackJournal.at(-1)?.status, 'cleaned'); + assert.deepEqual(rollbackJournal.at(-1)?.cleanedDatabaseIds, [uuidAt(1), uuidAt(2)]); + + const keptDatabase = new FakeDatabase(); + keptDatabase.failProjectAt = 1; + const keptJournal: NativeFixtureManifest[] = []; + await assert.rejects( + provisionNativeFixture(keptDatabase, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '55555555-5555-4555-8555-555555555555', + keepOnFailure: true, + writeManifest: async (manifest) => { + keptJournal.push(structuredClone(manifest)); + } + }), + /synthetic project failure/u + ); + assert.equal(keptJournal.at(-1)?.status, 'failed'); + assert.deepEqual(keptJournal.at(-1)?.databaseIds, [uuidAt(1)]); + assert.deepEqual(keptJournal.at(-1)?.cleanedDatabaseIds, []); +}); + +test('marks an empty journal cleaned when the first provision fails', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + database.failProvisionAt = 1; + const journal: NativeFixtureManifest[] = []; + + await assert.rejects( + provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '54545454-5454-4454-8454-545454545454', + writeManifest: async (manifest) => { + journal.push(structuredClone(manifest)); + } + }), + /synthetic provision failure/u + ); + + assert.deepEqual( + journal.map((manifest) => manifest.status), + ['provisioning', 'failed', 'cleaned'] + ); + assert.deepEqual(journal.at(-1)?.databaseIds, []); + assert.deepEqual(journal.at(-1)?.cleanedDatabaseIds, []); +}); + +test('cleans the in-memory exact ID when immediate post-provision manifest writes fail', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + const journal: NativeFixtureManifest[] = []; + const postProvisionError = new Error('synthetic post-provision journal failure'); + const failedStatusError = new Error('synthetic failed-status journal failure'); + + await assert.rejects( + provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '56565656-5656-4656-8656-565656565656', + writeManifest: async (manifest) => { + journal.push(structuredClone(manifest)); + if (manifest.status === 'provisioning' && manifest.databaseIds.length === 1) { + throw postProvisionError; + } + if (manifest.status === 'failed') throw failedStatusError; + } + }), + (error: unknown) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [postProvisionError, failedStatusError]); + return true; + } + ); + + const cleanupInventory = database.requests.find((request) => + request.sql.includes('console-kit fixture cleanup inventory') + ); + assert.deepEqual( + JSON.parse(cleanupInventory?.variables?.database_ids ?? '[]'), + [uuidAt(1)] + ); + const cleanupDelete = database.requests.find((request) => + request.sql.includes('DELETE FROM metaschema_public.database') + ); + assert.deepEqual( + JSON.parse(cleanupDelete?.variables?.database_ids ?? '[]'), + [uuidAt(1)] + ); + const droppedSchemas = database.executed.filter((request) => + request.sql.includes('DROP SCHEMA') + ); + assert.equal(droppedSchemas.length, 1); + assert.match(droppedSchemas[0]!.sql, /console-kit-fixture-1-public/u); + assert.equal(journal.at(-1)?.status, 'cleaned'); + assert.deepEqual(journal.at(-1)?.cleanedDatabaseIds, [uuidAt(1)]); +}); + +test('aggregates provisioning, failed-status persistence, and cleanup errors', async () => { + const root = await fakeConstructiveDb(); + const database = new FakeDatabase(); + database.failProjectAt = 1; + database.cleanupInventoryIds = []; + const failedStatusError = new Error('synthetic failed-status journal failure'); + + await assert.rejects( + provisionNativeFixture(database, { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '57575757-5757-4757-8757-575757575757', + writeManifest: async (manifest) => { + if (manifest.status === 'failed') throw failedStatusError; + } + }), + (error: unknown) => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors.length, 3); + assert.match(String(error.errors[0]), /synthetic project failure/u); + assert.equal(error.errors[1], failedStatusError); + assert.match(String(error.errors[2]), /cleanup inventory mismatch/u); + return true; + } + ); +}); + +test('rejects secret-bearing or structurally incomplete ready manifests', async () => { + const root = await fakeConstructiveDb(); + const manifest = await provisionNativeFixture(new FakeDatabase(), { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '66666666-6666-4666-8666-666666666666' + }); + assert.throws( + () => assertNativeFixtureManifest({ ...manifest, accessToken: 'must-not-be-written' }), + /forbidden key accessToken/u + ); + assert.throws( + () => assertNativeFixtureManifest({ ...manifest, verificationToken: 'must-not-be-written' }), + /forbidden key verificationToken/u + ); + assert.throws( + () => assertNativeFixtureManifest({ + ...manifest, + tenants: [{ ...manifest.tenants[0]!, unexpected: true }, ...manifest.tenants.slice(1)] + }), + /tenants\[0\] contains unknown key unexpected/u + ); + assert.throws( + () => assertNativeFixtureManifest({ + ...manifest, + tenants: [ + manifest.tenants[0]!, + { + ...manifest.tenants[1]!, + database: { + ...manifest.tenants[1]!.database, + id: manifest.tenants[0]!.database.id + } + }, + ...manifest.tenants.slice(2) + ] + }), + /duplicate tenant database IDs/u + ); + assert.throws( + () => assertNativeFixtureManifest({ ...manifest, tenants: manifest.tenants.slice(1) }), + /exactly one auth-hardened tenant/u + ); +}); + +test('parses explicit fun-up database and Constructive DB inputs for provision and run', () => { + const provision = parseNativeFixtureCliArgs( + [ + 'provision', + '--constructive-db', + '/workspace/constructive-db', + '--database', + 'constructive-functions-console-kit-blocks', + '--keep' + ], + '/workspace/blocks', + {} + ); + assert.equal(provision.platformDatabase, 'constructive-functions-console-kit-blocks'); + assert.equal(provision.manifestPath, '/workspace/blocks/.local/console-kit-native-fixture.json'); + assert.equal(provision.keep, true); + assert.equal(provision.graphqlOrigin, 'http://localhost:6464'); + + const run = parseNativeFixtureCliArgs( + [ + 'run', + '--constructive-db', + '/workspace/constructive-db', + '--db', + 'fixture-db', + '--', + 'pnpm', + 'test:e2e:live' + ], + '/workspace/blocks', + {} + ); + assert.deepEqual(run.childCommand, ['pnpm', 'test:e2e:live']); + assert.throws( + () => parseNativeFixtureCliArgs(['provision', '--database', 'fixture-db']), + /--constructive-db is required/u + ); + assert.throws( + () => parseNativeFixtureCliArgs(['cleanup', '--manifest', '/tmp/fixture.json']), + /--database is required/u + ); +}); + +test('refuses to overwrite an exact-ID journal until it is marked cleaned', async () => { + const root = await fakeConstructiveDb(); + const manifest = await provisionNativeFixture(new FakeDatabase(), { + constructiveDbPath: root, + platformDatabase: 'fixture-platform', + runId: '77777777-7777-4777-8777-777777777777' + }); + const directory = await mkdtemp(join(tmpdir(), 'blocks-native-journal-')); + const path = join(directory, 'fixture.json'); + await writeNativeFixtureManifest(path, manifest); + + await assert.rejects( + assertNativeFixtureManifestSlotAvailable(path), + /still owns 4 tenant database ID/u + ); + + await writeNativeFixtureManifest(path, { + ...manifest, + status: 'cleaned', + cleanedDatabaseIds: manifest.databaseIds + }); + await assert.doesNotReject(assertNativeFixtureManifestSlotAvailable(path)); +}); diff --git a/apps/blocks/e2e-live/native-fixture.ts b/apps/blocks/e2e-live/native-fixture.ts new file mode 100644 index 0000000..8276282 --- /dev/null +++ b/apps/blocks/e2e-live/native-fixture.ts @@ -0,0 +1,783 @@ +import { randomUUID } from 'node:crypto'; +import { access, realpath } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + FIXTURE_HOST_PART as HOST_PART, + FIXTURE_PROFILES, + NATIVE_FIXTURE_KIND, + NATIVE_FIXTURE_VERSION, + OFFICIAL_PRESETS, + assertNativeFixtureManifest, + endpointUrl, + fixtureNonEmptyString as nonEmptyString, + fixtureRecord as record, + fixtureUuid as uuid, + type FixtureProfile, + type FixtureRecord as UnknownRecord, + type NativeEndpoint, + type NativeFixtureManifest, + type NativeFixtureTenant, + type OfficialPreset +} from './native-fixture-contract'; + +export { + FIXTURE_PROFILES, + NATIVE_FIXTURE_KIND, + NATIVE_FIXTURE_VERSION, + OFFICIAL_PRESETS, + assertNativeFixtureManifest, + endpointUrl, + tenantEndpoint +} from './native-fixture-contract'; +export type { + FixtureProfile, + NativeEndpoint, + NativeFixtureManifest, + NativeFixtureTenant, + OfficialPreset +} from './native-fixture-contract'; + +export type ModuleEntry = string | readonly [string, Readonly>]; + +export type SqlRequest = Readonly<{ + sql: string; + variables?: Readonly>; +}>; + +export interface NativeFixtureDatabase { + json(request: SqlRequest): Promise; + execute(request: SqlRequest): Promise; +} + +export type NativeFixtureOptions = Readonly<{ + constructiveDbPath: string; + platformDatabase: string; + graphqlOrigin?: string; + domain?: string; + keepOnFailure?: boolean; + runId?: string; + now?: () => Date; + writeManifest?: (manifest: NativeFixtureManifest) => Promise; +}>; + +type LoadedPreset = Readonly<{ + name: string; + modules: readonly ModuleEntry[]; +}>; + +function moduleEntry(value: unknown, label: string): ModuleEntry { + if (typeof value === 'string' && value.length > 0) return value; + if ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + value[0].length > 0 && + value[1] && + typeof value[1] === 'object' && + !Array.isArray(value[1]) + ) { + return [value[0], { ...(value[1] as UnknownRecord) }]; + } + throw new Error(`${label} is not a Constructive module entry.`); +} + +function cloneModules(modules: readonly ModuleEntry[]): ModuleEntry[] { + return modules.map((entry) => + typeof entry === 'string' ? entry : [entry[0], { ...entry[1] }] + ); +} + +export function routeStorageToAdmin(modules: readonly ModuleEntry[]): ModuleEntry[] { + let storageEntries = 0; + const routed = modules.map((entry): ModuleEntry => { + const name = typeof entry === 'string' ? entry : entry[0]; + if (name !== 'storage_module') { + return typeof entry === 'string' ? entry : [entry[0], { ...entry[1] }]; + } + storageEntries += 1; + const options = typeof entry === 'string' ? {} : entry[1]; + return ['storage_module', { ...options, api_name: 'admin' }]; + }); + if (storageEntries !== 1) { + throw new Error(`The b2b:storage preset must contain exactly one storage_module; found ${storageEntries}.`); + } + return routed; +} + +const PRESET_MODULE_CANDIDATES = [ + 'packages/node-type-registry/src/module-presets/index.ts', + 'packages/node-type-registry/src/module-presets/index.js', + 'packages/node-type-registry/src/module-presets/index.mjs', + 'packages/node-type-registry/dist/module-presets/index.js', + 'packages/node-type-registry/lib/module-presets/index.js' +] as const; + +export async function resolvePresetModulePath(constructiveDbPath: string): Promise { + if (!isAbsolute(constructiveDbPath)) { + throw new Error('--constructive-db must be an absolute path.'); + } + const root = await realpath(constructiveDbPath); + for (const relativePath of PRESET_MODULE_CANDIDATES) { + const candidate = join(root, relativePath); + try { + await access(candidate); + return candidate; + } catch { + // Try the next source/runtime layout. + } + } + throw new Error( + `No node-type-registry preset module was found under ${root}. ` + + 'Build constructive-db or point --constructive-db at its repository root.' + ); +} + +export async function loadOfficialPresetModules( + constructiveDbPath: string +): Promise> }>> { + const presetModulePath = await resolvePresetModulePath(constructiveDbPath); + const imported = await import(`${pathToFileURL(presetModulePath).href}?native-fixture=${Date.now()}`); + const rawPresets = imported.allModulePresets; + if (!Array.isArray(rawPresets)) { + throw new Error(`${presetModulePath} does not export allModulePresets.`); + } + + const loaded = new Map(); + for (const [index, raw] of rawPresets.entries()) { + const preset = record(raw, `allModulePresets[${index}]`); + const name = nonEmptyString(preset.name, `allModulePresets[${index}].name`); + if (!Array.isArray(preset.modules)) { + throw new Error(`allModulePresets[${index}].modules must be an array.`); + } + loaded.set(name, { + name, + modules: preset.modules.map((entry, moduleIndex) => + moduleEntry(entry, `${name}.modules[${moduleIndex}]`) + ) + }); + } + + const presets = {} as Record; + for (const name of OFFICIAL_PRESETS) { + const preset = loaded.get(name); + if (!preset) throw new Error(`${presetModulePath} does not export the ${name} preset.`); + presets[name] = cloneModules(preset.modules); + } + return { presetModulePath, presets }; +} + +export function platformOwnerRequest(): SqlRequest { + return { + sql: ` + SELECT jsonb_build_object( + 'platformDatabaseId', database.id, + 'ownerId', database.owner_id + )::text + FROM metaschema_public.database AS database + WHERE database.id = metaschema_private.platform_database_id() + AND database.owner_id IS NOT NULL + ` + }; +} + +export function provisionDatabaseRequest(input: Readonly<{ + name: string; + ownerId: string; + subdomain: string; + domain: string; + modules: readonly ModuleEntry[]; +}>): SqlRequest { + return { + sql: ` + SELECT jsonb_build_object( + 'databaseId', + metaschema_generators.provision_database( + v_database_name := :'database_name', + v_owner_id := :'owner_id'::uuid, + v_subdomain := :'subdomain', + v_domain := :'domain', + v_modules := :'modules'::jsonb, + v_options := '{}'::jsonb + ) + )::text + `, + variables: { + database_name: input.name, + owner_id: input.ownerId, + subdomain: input.subdomain, + domain: input.domain, + modules: JSON.stringify(input.modules) + } + }; +} + +export function membershipDefaultsLookupRequest(databaseId: string): SqlRequest { + return { + sql: ` + SELECT jsonb_build_object( + 'schemaName', schema.schema_name, + 'tableName', table_record.name + )::text + FROM metaschema_modules_public.memberships_module AS module + JOIN metaschema_public.table AS table_record + ON table_record.id = module.membership_defaults_table_id + JOIN metaschema_public.schema AS schema + ON schema.id = table_record.schema_id + WHERE module.database_id = :'database_id'::uuid + AND module.scope = 'app' + LIMIT 1 + `, + variables: { database_id: databaseId } + }; +} + +function quoteIdentifier(identifier: string): string { + if (!identifier || identifier.includes('\u0000')) throw new Error('Invalid PostgreSQL identifier.'); + return `"${identifier.replaceAll('"', '""')}"`; +} + +export function enableMembershipDefaultsRequest(schemaName: string, tableName: string): SqlRequest { + return { + sql: ` + UPDATE ${quoteIdentifier(schemaName)}.${quoteIdentifier(tableName)} + SET is_verified = TRUE, is_approved = TRUE + ` + }; +} + +export function projectProvisionRequest(databaseId: string): SqlRequest { + return { + sql: ` + WITH provisioned AS ( + INSERT INTO metaschema_modules_public.secure_table_provision + (database_id, table_name, nodes, fields, grants, policies) + VALUES ( + :'database_id'::uuid, + 'projects', + :'nodes'::jsonb, + ARRAY(SELECT value FROM jsonb_array_elements(:'fields'::jsonb)), + :'grants'::jsonb, + :'policies'::jsonb + ) + RETURNING table_id + ), scoped AS ( + SELECT + table_id, + metaschema_generators.apply_scope_fields( + v_table_id := table_id, + v_scope := 'app' + ) AS scope_field_id + FROM provisioned + ) + SELECT jsonb_build_object( + 'tableId', table_id, + 'tableName', 'projects' + )::text + FROM scoped + `, + variables: { + database_id: databaseId, + nodes: JSON.stringify([ + { $type: 'DataId' }, + { $type: 'DataTimestamps' }, + { $type: 'DataDirectOwner' } + ]), + fields: JSON.stringify([ + { name: 'name', type: { name: 'text' }, is_required: true }, + { name: 'description', type: { name: 'text' } }, + { + name: 'completed', + type: { name: 'boolean' }, + default: { value: false }, + is_required: true + } + ]), + grants: JSON.stringify([ + { + roles: ['authenticated'], + privileges: [ + ['select', '*'], + ['insert', '*'], + ['update', '*'], + ['delete', '*'] + ] + } + ]), + policies: JSON.stringify([ + { + $type: 'AuthzDirectOwner', + data: { entity_field: 'owner_id' }, + privileges: ['select', 'insert', 'update', 'delete'], + policy_role: 'authenticated', + permissive: true, + policy_name: 'console_kit_projects_direct_owner' + } + ]) + } + }; +} + +export function endpointDiscoveryRequest(databaseId: string): SqlRequest { + return { + sql: ` + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'apiId', routes.api_id, + 'apiName', routes.api_name, + 'domainId', routes.domain_id, + 'domain', routes.domain, + 'subdomain', routes.subdomain, + 'schemas', routes.schemas + ) + ORDER BY routes.api_name, routes.subdomain, routes.domain + ), + '[]'::jsonb + )::text + FROM ( + SELECT + api.id AS api_id, + api.name AS api_name, + domain_record.id AS domain_id, + domain_record.domain, + domain_record.subdomain, + COALESCE( + jsonb_agg(DISTINCT schema.schema_name) + FILTER (WHERE schema.id IS NOT NULL), + '[]'::jsonb + ) AS schemas + FROM services_public.apis AS api + JOIN services_public.domains AS domain_record + ON domain_record.api_id = api.id + AND domain_record.database_id = api.database_id + LEFT JOIN services_public.api_schemas AS api_schema + ON api_schema.api_id = api.id + AND api_schema.database_id = api.database_id + LEFT JOIN metaschema_public.schema AS schema + ON schema.id = api_schema.schema_id + WHERE api.database_id = :'database_id'::uuid + AND api.is_public = TRUE + GROUP BY + api.id, + api.name, + domain_record.id, + domain_record.domain, + domain_record.subdomain + ) AS routes + `, + variables: { database_id: databaseId } + }; +} + +export function cleanupDatabasesRequest(databaseIds: readonly string[]): SqlRequest { + if (databaseIds.length === 0) throw new Error('Cleanup requires at least one database ID.'); + databaseIds.forEach((databaseId, index) => uuid(databaseId, `databaseIds[${index}]`)); + return { + sql: ` + WITH requested AS ( + SELECT value::uuid AS id + FROM jsonb_array_elements_text(:'database_ids'::jsonb) + ), deleted AS ( + DELETE FROM metaschema_public.database AS database + USING requested + WHERE database.id = requested.id + RETURNING database.id + ) + SELECT jsonb_build_object( + 'requested', (SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) FROM requested), + 'deleted', (SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) FROM deleted) + )::text + `, + variables: { database_ids: JSON.stringify(databaseIds) } + }; +} + +export function cleanupInventoryRequest(databaseIds: readonly string[]): SqlRequest { + if (databaseIds.length === 0) throw new Error('Cleanup inventory requires at least one database ID.'); + databaseIds.forEach((databaseId, index) => uuid(databaseId, `databaseIds[${index}]`)); + return { + sql: ` + /* console-kit fixture cleanup inventory */ + WITH requested AS ( + SELECT value::uuid AS id + FROM jsonb_array_elements_text(:'database_ids'::jsonb) + ), matched AS ( + SELECT database.id + FROM metaschema_public.database AS database + JOIN requested ON requested.id = database.id + ), fixture_schemas AS ( + SELECT schema.database_id, schema.schema_name + FROM metaschema_public.schema AS schema + JOIN requested ON requested.id = schema.database_id + ) + SELECT jsonb_build_object( + 'requested', (SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) FROM requested), + 'matchedDatabaseIds', (SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) FROM matched), + 'platformDatabaseId', metaschema_private.platform_database_id(), + 'schemas', ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object('databaseId', database_id, 'schemaName', schema_name) + ORDER BY database_id, schema_name + ), + '[]'::jsonb + ) + FROM fixture_schemas + ) + )::text + `, + variables: { database_ids: JSON.stringify(databaseIds) } + }; +} + +export function dropFixtureSchemaRequest(schemaName: string): SqlRequest { + return { + sql: `DROP SCHEMA IF EXISTS ${quoteIdentifier(nonEmptyString(schemaName, 'fixture schema name'))} CASCADE` + }; +} + +export function cleanupVerificationRequest( + databaseIds: readonly string[], + schemaNames: readonly string[] +): SqlRequest { + if (databaseIds.length === 0) throw new Error('Cleanup verification requires at least one database ID.'); + databaseIds.forEach((databaseId, index) => uuid(databaseId, `databaseIds[${index}]`)); + schemaNames.forEach((schemaName, index) => nonEmptyString(schemaName, `schemaNames[${index}]`)); + return { + sql: ` + /* console-kit fixture cleanup verification */ + WITH requested_databases AS ( + SELECT value::uuid AS id + FROM jsonb_array_elements_text(:'database_ids'::jsonb) + ), requested_schemas AS ( + SELECT value AS schema_name + FROM jsonb_array_elements_text(:'schema_names'::jsonb) + ) + SELECT jsonb_build_object( + 'remainingDatabaseIds', ( + SELECT COALESCE(jsonb_agg(database.id ORDER BY database.id), '[]'::jsonb) + FROM metaschema_public.database AS database + JOIN requested_databases ON requested_databases.id = database.id + ), + 'remainingSchemaNames', ( + SELECT COALESCE(jsonb_agg(namespace.nspname ORDER BY namespace.nspname), '[]'::jsonb) + FROM pg_catalog.pg_namespace AS namespace + JOIN requested_schemas ON requested_schemas.schema_name = namespace.nspname + ) + )::text + `, + variables: { + database_ids: JSON.stringify(databaseIds), + schema_names: JSON.stringify(schemaNames) + } + }; +} + +function parseDiscoveredEndpoints(value: unknown, graphqlOrigin: string): NativeEndpoint[] { + if (!Array.isArray(value)) throw new Error('Endpoint discovery did not return an array.'); + return value.map((raw, index): NativeEndpoint => { + const endpoint = record(raw, `endpoints[${index}]`); + const schemas = endpoint.schemas; + if (!Array.isArray(schemas) || schemas.some((schema) => typeof schema !== 'string')) { + throw new Error(`endpoints[${index}].schemas must be an array of strings.`); + } + const domain = nonEmptyString(endpoint.domain, `endpoints[${index}].domain`); + const subdomain = nonEmptyString(endpoint.subdomain, `endpoints[${index}].subdomain`); + return { + apiId: uuid(endpoint.apiId, `endpoints[${index}].apiId`), + apiName: nonEmptyString(endpoint.apiName, `endpoints[${index}].apiName`), + domainId: uuid(endpoint.domainId, `endpoints[${index}].domainId`), + domain, + subdomain, + url: endpointUrl(graphqlOrigin, { domain, subdomain }), + schemas: [...new Set(schemas)].sort() + }; + }); +} + +export async function cleanupNativeFixture( + database: NativeFixtureDatabase, + manifest: NativeFixtureManifest +): Promise { + assertNativeFixtureManifest(manifest); + if (manifest.status === 'cleaned') return manifest; + if (manifest.databaseIds.length === 0) { + const cleaned: NativeFixtureManifest = { + ...manifest, + status: 'cleaned', + cleanedDatabaseIds: [] + }; + assertNativeFixtureManifest(cleaned); + return cleaned; + } + const inventory = await database.json<{ + requested?: unknown; + matchedDatabaseIds?: unknown; + platformDatabaseId?: unknown; + schemas?: unknown; + }>(cleanupInventoryRequest(manifest.databaseIds)); + const matchedDatabaseIds = Array.isArray(inventory.matchedDatabaseIds) + ? inventory.matchedDatabaseIds.map((entry, index) => + uuid(entry, `cleanup.matchedDatabaseIds[${index}]`) + ) + : []; + const platformDatabaseId = uuid(inventory.platformDatabaseId, 'cleanup.platformDatabaseId'); + if (manifest.databaseIds.includes(platformDatabaseId)) { + throw new Error('Cleanup refused because the fixture journal contains the platform database ID.'); + } + const missingInventory = manifest.databaseIds.filter((id) => !matchedDatabaseIds.includes(id)); + const unexpectedInventory = matchedDatabaseIds.filter((id) => !manifest.databaseIds.includes(id)); + if (missingInventory.length > 0 || unexpectedInventory.length > 0) { + throw new Error( + `Exact-ID cleanup inventory mismatch (missing: ${missingInventory.join(', ') || 'none'}; ` + + `unexpected: ${unexpectedInventory.join(', ') || 'none'}).` + ); + } + if (!Array.isArray(inventory.schemas)) { + throw new Error('Cleanup inventory did not return a schema list.'); + } + const schemaNames = inventory.schemas.map((entry, index) => { + const schema = record(entry, `cleanup.schemas[${index}]`); + const databaseId = uuid(schema.databaseId, `cleanup.schemas[${index}].databaseId`); + if (!manifest.databaseIds.includes(databaseId)) { + throw new Error(`Cleanup inventory returned a schema for unexpected database ${databaseId}.`); + } + return nonEmptyString(schema.schemaName, `cleanup.schemas[${index}].schemaName`); + }); + if (new Set(schemaNames).size !== schemaNames.length) { + throw new Error('Cleanup inventory returned duplicate physical schema names.'); + } + for (const schemaName of schemaNames) { + await database.execute(dropFixtureSchemaRequest(schemaName)); + } + + const result = await database.json<{ requested?: unknown; deleted?: unknown }>( + cleanupDatabasesRequest(manifest.databaseIds) + ); + const deleted = Array.isArray(result.deleted) + ? result.deleted.map((entry, index) => uuid(entry, `cleanup.deleted[${index}]`)) + : []; + const missing = manifest.databaseIds.filter((id) => !deleted.includes(id)); + const unexpected = deleted.filter((id) => !manifest.databaseIds.includes(id)); + if (missing.length > 0 || unexpected.length > 0) { + throw new Error( + `Exact-ID cleanup mismatch (missing: ${missing.join(', ') || 'none'}; ` + + `unexpected: ${unexpected.join(', ') || 'none'}).` + ); + } + const verification = await database.json<{ + remainingDatabaseIds?: unknown; + remainingSchemaNames?: unknown; + }>(cleanupVerificationRequest(manifest.databaseIds, schemaNames)); + const remainingDatabaseIds = Array.isArray(verification.remainingDatabaseIds) + ? verification.remainingDatabaseIds.map((entry, index) => + uuid(entry, `cleanup.remainingDatabaseIds[${index}]`) + ) + : []; + const remainingSchemaNames = Array.isArray(verification.remainingSchemaNames) + ? verification.remainingSchemaNames.map((entry, index) => + nonEmptyString(entry, `cleanup.remainingSchemaNames[${index}]`) + ) + : []; + if (remainingDatabaseIds.length > 0 || remainingSchemaNames.length > 0) { + throw new Error( + `Cleanup verification failed (database IDs: ${remainingDatabaseIds.join(', ') || 'none'}; ` + + `schemas: ${remainingSchemaNames.join(', ') || 'none'}).` + ); + } + const cleaned: NativeFixtureManifest = { + ...manifest, + status: 'cleaned', + cleanedDatabaseIds: [...manifest.databaseIds] + }; + assertNativeFixtureManifest(cleaned); + return cleaned; +} + +function fixtureDefinitions( + presets: Readonly> +): ReadonlyArray> { + return [ + { + profile: 'auth-hardened', + preset: 'auth:hardened', + presetMode: 'official', + modules: cloneModules(presets['auth:hardened']) + }, + { + profile: 'b2b-storage', + preset: 'b2b:storage', + presetMode: 'official', + modules: cloneModules(presets['b2b:storage']) + }, + { + profile: 'full', + preset: 'full', + presetMode: 'official', + modules: cloneModules(presets.full) + }, + { + profile: 'storage-routed', + preset: 'b2b:storage', + presetMode: 'official-with-storage-route', + modules: routeStorageToAdmin(presets['b2b:storage']), + storageApiName: 'admin' + } + ]; +} + +export async function provisionNativeFixture( + database: NativeFixtureDatabase, + options: NativeFixtureOptions +): Promise { + const now = options.now ?? (() => new Date()); + const constructiveDbPath = await realpath(options.constructiveDbPath); + const loaded = await loadOfficialPresetModules(constructiveDbPath); + const graphqlOrigin = new URL(options.graphqlOrigin ?? 'http://localhost:6464').origin; + const domain = options.domain ?? 'localhost'; + if (!HOST_PART.test(domain)) throw new Error('--domain must be a valid domain name.'); + const runId = options.runId ?? randomUUID(); + const runSuffix = runId.replaceAll('-', '').slice(0, 12).toLowerCase(); + const platform = await database.json<{ platformDatabaseId?: unknown; ownerId?: unknown }>( + platformOwnerRequest() + ); + uuid(platform.platformDatabaseId, 'platform database ID'); + const ownerId = uuid(platform.ownerId, 'platform owner ID'); + + let manifest: NativeFixtureManifest = { + version: NATIVE_FIXTURE_VERSION, + kind: NATIVE_FIXTURE_KIND, + status: 'provisioning', + runId, + createdAt: now().toISOString(), + constructiveDbPath, + presetModulePath: loaded.presetModulePath, + platformDatabase: options.platformDatabase, + graphqlOrigin, + membershipFixtureMode: 'auto-approved-and-verified', + databaseIds: [], + cleanedDatabaseIds: [], + tenants: [] + }; + const persist = async (): Promise => { + assertNativeFixtureManifest(manifest); + await options.writeManifest?.(manifest); + }; + await persist(); + + try { + for (const definition of fixtureDefinitions(loaded.presets)) { + const slug = definition.profile.replaceAll('-', '_'); + const databaseName = `console_kit_${slug}_${runSuffix}`; + const subdomain = `console-kit-${definition.profile}-${runSuffix}`; + const provisioned = await database.json<{ databaseId?: unknown }>( + provisionDatabaseRequest({ + name: databaseName, + ownerId, + subdomain, + domain, + modules: definition.modules + }) + ); + const databaseId = uuid(provisioned.databaseId, `${definition.profile} database ID`); + manifest = { + ...manifest, + databaseIds: [...manifest.databaseIds, databaseId] + }; + await persist(); + + const membershipTable = await database.json<{ schemaName?: unknown; tableName?: unknown }>( + membershipDefaultsLookupRequest(databaseId) + ); + await database.execute( + enableMembershipDefaultsRequest( + nonEmptyString(membershipTable.schemaName, `${definition.profile} membership defaults schema`), + nonEmptyString(membershipTable.tableName, `${definition.profile} membership defaults table`) + ) + ); + + const projects = await database.json<{ tableId?: unknown; tableName?: unknown }>( + projectProvisionRequest(databaseId) + ); + const tableId = uuid(projects.tableId, `${definition.profile} projects table ID`); + if (projects.tableName !== 'projects') { + throw new Error(`${definition.profile} provisioned an unexpected fixture table.`); + } + const rawEndpoints = await database.json(endpointDiscoveryRequest(databaseId)); + const endpoints = parseDiscoveredEndpoints(rawEndpoints, graphqlOrigin); + for (const requiredApi of ['api', 'auth', 'admin']) { + if (endpoints.filter((endpoint) => endpoint.apiName === requiredApi).length !== 1) { + throw new Error(`${definition.profile} must expose exactly one public ${requiredApi} endpoint.`); + } + } + if ( + definition.storageApiName && + endpoints.filter((endpoint) => endpoint.apiName === definition.storageApiName).length !== 1 + ) { + throw new Error('The routed Storage fixture has no public admin endpoint.'); + } + + const tenant: NativeFixtureTenant = { + profile: definition.profile, + preset: definition.preset, + presetMode: definition.presetMode, + database: { + id: databaseId, + name: databaseName, + domain, + subdomain + }, + projects: { tableId, tableName: 'projects' }, + endpoints, + capabilityBindings: definition.storageApiName + ? { storageApiName: definition.storageApiName } + : {} + }; + manifest = { + ...manifest, + tenants: [...manifest.tenants, tenant] + }; + await persist(); + } + manifest = { ...manifest, status: 'ready' }; + await persist(); + return manifest; + } catch (error) { + const failures: unknown[] = [error]; + manifest = { ...manifest, status: 'failed' }; + try { + await persist(); + } catch (persistenceError) { + failures.push(persistenceError); + } + if (!options.keepOnFailure) { + try { + manifest = await cleanupNativeFixture(database, manifest); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (manifest.status === 'cleaned') { + try { + await persist(); + } catch (persistenceError) { + failures.push(persistenceError); + } + } + } + if (failures.length > 1) { + throw new AggregateError( + failures, + 'Fixture provisioning failed and one or more manifest persistence or exact-ID cleanup operations also failed.' + ); + } + throw error; + } +} diff --git a/apps/blocks/e2e-live/proof-context.ts b/apps/blocks/e2e-live/proof-context.ts new file mode 100644 index 0000000..e40cc4d --- /dev/null +++ b/apps/blocks/e2e-live/proof-context.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; + +import { + FIXTURE_PROFILES, + assertNativeFixtureManifest, + tenantEndpoint, + type FixtureProfile, + type NativeFixtureManifest, + type NativeFixtureTenant +} from './native-fixture'; + +export const PROOF_PROFILES = FIXTURE_PROFILES; +export type ProofProfile = FixtureProfile; +export type ProofTenant = NativeFixtureTenant; + +export const ENDPOINT_KINDS = [ + 'data', + 'auth', + 'admin', + 'billing', + 'storage', + 'notifications' +] as const; +export type EndpointKind = (typeof ENDPOINT_KINDS)[number]; + +export type ProofCredentials = Readonly<{ + email: string; + password: string; +}>; + +export type ProofContext = Readonly<{ + manifestPath: string; + routeUrl: string; + runId: string; + status: NativeFixtureManifest['status']; + manifest: NativeFixtureManifest; + tenants: readonly ProofTenant[]; + tenant(profile: ProofProfile): ProofTenant; + credentials(tenant: ProofTenant, actor?: 'owner' | 'peer'): ProofCredentials; +}>; + +const API_BY_KIND: Readonly, string>> = { + data: 'api', + auth: 'auth', + admin: 'admin', + billing: 'usage', + notifications: 'notifications' +}; + +function requiredAbsolutePath(name: string): string { + const value = process.env[name]; + if (!value || !isAbsolute(value)) throw new Error(`${name} must be an absolute path.`); + return value; +} + +export function endpointUrl(tenant: ProofTenant, kind: EndpointKind): string { + const apiName = kind === 'storage' + ? tenant.capabilityBindings.storageApiName + : API_BY_KIND[kind]; + if (!apiName) { + throw new Error(`${tenant.profile} does not bind ${kind} to a public API.`); + } + const endpoint = tenantEndpoint(tenant, apiName); + if (!endpoint) { + throw new Error(`${tenant.profile} has no discovered public ${apiName} endpoint for ${kind}.`); + } + return endpoint.url; +} + +function credentialSlug(tenant: ProofTenant, actor: 'owner' | 'peer', runId: string): string { + return `${runId.slice(0, 8)}-${tenant.profile}-${actor}`.replace(/[^a-z0-9-]/giu, '-').toLowerCase(); +} + +export function loadProofContext(): ProofContext { + const manifestPath = requiredAbsolutePath('CONSOLE_KIT_TENANT_MANIFEST'); + const routeUrl = process.env.CONSOLE_KIT_BASE_URL; + if (!routeUrl) throw new Error('CONSOLE_KIT_BASE_URL is required.'); + const parsedRoute = new URL(routeUrl); + if (parsedRoute.protocol !== 'http:' && parsedRoute.protocol !== 'https:') { + throw new Error('CONSOLE_KIT_BASE_URL must use HTTP or HTTPS.'); + } + + const raw: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + assertNativeFixtureManifest(raw); + if (raw.status !== 'ready') { + throw new Error(`The native tenant fixture is ${raw.status}, not ready.`); + } + const tenants = raw.tenants; + return { + manifestPath, + routeUrl: parsedRoute.toString(), + runId: raw.runId, + status: raw.status, + manifest: raw, + tenants, + tenant(profile) { + const matches = tenants.filter((tenant) => tenant.profile === profile); + if (matches.length !== 1) throw new Error(`The native fixture requires one ${profile} tenant.`); + return matches[0]!; + }, + credentials(tenant, actor = 'owner') { + const slug = credentialSlug(tenant, actor, raw.runId); + return { + email: `${slug}@console-kit.constructive.test`, + password: `ConsoleKit-${raw.runId.slice(0, 12)}-${actor === 'owner' ? 'Aa1' : 'Bb2'}!` + }; + } + }; +} diff --git a/apps/blocks/e2e/docs.interaction.spec.ts b/apps/blocks/e2e/docs.interaction.spec.ts index e14ba14..f28bc6d 100644 --- a/apps/blocks/e2e/docs.interaction.spec.ts +++ b/apps/blocks/e2e/docs.interaction.spec.ts @@ -160,14 +160,14 @@ test('controlled overlay examples reflect one open and one close request', async test('supported pointer dismissal closes floating and modal surfaces', async ({ page }) => { const cases = [ - ['dropdown-menu', 'Actions', 'dropdown-menu-content', '[role="presentation"][data-base-ui-inert]'], - ['popover', 'Connection limits', 'popover-content', 'main h1'], - ['dialog', 'Rename database', 'dialog-popup', '[data-slot="dialog-viewport"]'], - ['drawer', 'Open quick actions', 'drawer-content', '[data-slot="drawer-overlay"]'], - ['sheet', 'Edit organization', 'sheet-content', '[data-slot="sheet-overlay"]'], + ['dropdown-menu', 'Actions', 'dropdown-menu-content', '[role="presentation"][data-base-ui-inert]', 'trigger'], + ['popover', 'Connection limits', 'popover-content', 'main h1', 'main'], + ['dialog', 'Rename database', 'dialog-popup', '[data-slot="dialog-viewport"]', 'trigger'], + ['drawer', 'Open quick actions', 'drawer-content', '[data-slot="drawer-overlay"]', 'trigger'], + ['sheet', 'Edit organization', 'sheet-content', '[data-slot="sheet-overlay"]', 'trigger'], ] as const; - for (const [slug, triggerName, contentSlot, outsideSelector] of cases) { + for (const [slug, triggerName, contentSlot, outsideSelector, focusTarget] of cases) { await test.step(slug, async () => { await visitPrimitive(page, slug); const trigger = page.getByRole('button', { name: triggerName }); @@ -176,7 +176,12 @@ test('supported pointer dismissal closes floating and modal surfaces', async ({ await expect(content).toBeVisible(); await page.locator(outsideSelector).click({ position: { x: 2, y: 2 } }); await expect(content).toBeHidden(); - await expect(trigger).toBeFocused(); + if (focusTarget === 'trigger') { + await expect(trigger).toBeFocused(); + } else { + // Non-modal popovers preserve intentional pointer focus outside the popup. + await expect(page.locator(focusTarget)).toBeFocused(); + } }); } }); @@ -427,7 +432,9 @@ test('billing settings keeps narrow leaf layout inside its desktop rail', async .toBe(1280); await expect .poll(() => - inlineFrame.evaluate((frame) => frame.getBoundingClientRect().height), + inlineFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerHeight, + ), ) .toBe(960); diff --git a/apps/blocks/e2e/docs.smoke.spec.ts b/apps/blocks/e2e/docs.smoke.spec.ts index de96fb3..a020a81 100644 --- a/apps/blocks/e2e/docs.smoke.spec.ts +++ b/apps/blocks/e2e/docs.smoke.spec.ts @@ -18,7 +18,33 @@ const billingBlocks = [ 'billing-settings-page', ] as const; +const featurePacks = [ + { id: 'data', previewHeading: 'Data', title: 'Data', variant: 'tables' }, + { id: 'auth', previewHeading: 'Account', title: 'Authentication', variant: 'account' }, + { + id: 'users', + previewHeading: 'App access', + title: 'App access', + variant: 'directory', + }, + { + id: 'organizations', + previewHeading: 'Organizations', + title: 'Organizations', + variant: 'memberships', + }, + { id: 'storage', previewHeading: 'Storage', title: 'Storage', variant: 'browser' }, + { id: 'billing', previewHeading: 'Billing', title: 'Billing', variant: 'organization' }, + { + id: 'notifications', + previewHeading: 'Notifications', + title: 'Notifications', + variant: 'inbox', + }, +] as const; + const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; +const featurePackRoute = (id: string) => `/blocks/blocks/features/${id}/`; for (const [route, heading] of routes) { test(`${route} renders its documentation surface`, async ({ page }) => { @@ -29,18 +55,12 @@ for (const [route, heading] of routes) { }); } -test('the primitive catalogs remain complete and legacy block routes stay removed', async ({ - page, -}) => { +test('the primitive catalogs remain complete and legacy block routes stay removed', async ({ page }) => { await page.goto('/blocks/', { waitUntil: 'networkidle' }); - await expect( - page.locator('section[aria-labelledby="component-showcase"] a[href*="/ui/"]'), - ).toHaveCount(29); + await expect(page.locator('section[aria-labelledby="component-showcase"] a[href*="/ui/"]')).toHaveCount(29); await page.goto('/blocks/blocks/', { waitUntil: 'networkidle' }); - await expect( - page.locator('section[aria-labelledby="primitive-catalog"] a[href*="/ui/"]'), - ).toHaveCount(29); + await expect(page.locator('section[aria-labelledby="primitive-catalog"] a[href*="/ui/"]')).toHaveCount(29); const legacyResponse = await page.goto('/blocks/blocks/auth/sign-in-card/', { waitUntil: 'networkidle', @@ -55,17 +75,11 @@ test('the billing catalog discovers all eight live pages', async ({ page }) => { expect(catalogResponse?.status()).toBe(200); await expect(page.getByRole('heading', { level: 1, name: 'Billing' })).toBeVisible(); - const previewLinks = page - .locator('section[aria-labelledby="billing-catalog-heading"]') - .getByRole('link'); + const previewLinks = page.locator('section[aria-labelledby="billing-catalog-heading"]').getByRole('link'); await expect(previewLinks).toHaveCount(8); const discoveredPaths = await previewLinks.evaluateAll((links) => - links - .map((link) => - new URL((link as HTMLAnchorElement).href).pathname.replace(/\/?$/, '/'), - ) - .sort(), + links.map((link) => new URL((link as HTMLAnchorElement).href).pathname.replace(/\/?$/, '/')).sort(), ); expect(discoveredPaths).toEqual(billingBlocks.map(billingRoute).sort()); @@ -79,3 +93,64 @@ test('the billing catalog discovers all eight live pages', async ({ page }) => { }); } }); + +test('the feature-pack catalog discovers all seven live pages', async ({ page }) => { + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + + const catalogResponse = await page.goto('/blocks/blocks/features/', { + waitUntil: 'networkidle', + }); + expect(catalogResponse?.status()).toBe(200); + await expect(page.getByRole('heading', { level: 1, name: 'Feature packs' })).toBeVisible(); + + const previewLinks = page.locator('section[aria-labelledby="feature-pack-catalog-heading"]').getByRole('link'); + await expect(previewLinks).toHaveCount(7); + + const discoveredPaths = await previewLinks.evaluateAll((links) => + links.map((link) => new URL((link as HTMLAnchorElement).href).pathname.replace(/\/?$/, '/')).sort(), + ); + expect(discoveredPaths).toEqual(featurePacks.map(({ id }) => featurePackRoute(id)).sort()); + + for (const { id, previewHeading, title, variant } of featurePacks) { + await test.step(id, async () => { + const response = await page.goto(featurePackRoute(id), { + waitUntil: 'networkidle', + }); + expect(response?.status()).toBe(200); + const preview = page.locator('[data-slot="feature-pack-showcase-preview"]'); + await expect(preview).toBeVisible(); + + const frameTitle = `${title} feature pack inline live preview`; + const iframe = preview.getByTitle(frameTitle); + await expect(iframe).toHaveAttribute( + 'src', + `/blocks/blocks/features/${id}/preview/?variant=${variant}&state=ready`, + ); + await expect( + page.frameLocator(`iframe[title="${frameTitle}"]`).getByRole('heading', { + exact: true, + level: 1, + name: previewHeading, + }), + ).toBeVisible(); + }); + } + + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); + +test('unknown feature-pack detail and preview routes return 404', async ({ page }) => { + for (const route of [ + '/blocks/blocks/features/not-a-pack/', + '/blocks/blocks/features/not-a-pack/preview/', + ]) { + const response = await page.goto(route, { waitUntil: 'domcontentloaded' }); + expect(response?.status()).toBe(404); + } +}); diff --git a/apps/blocks/e2e/mobile.interaction.spec.ts b/apps/blocks/e2e/mobile.interaction.spec.ts index 0ef7b1c..32e7e23 100644 --- a/apps/blocks/e2e/mobile.interaction.spec.ts +++ b/apps/blocks/e2e/mobile.interaction.spec.ts @@ -54,16 +54,22 @@ async function chooseShowcaseOption( test('navigation changes at the shared 860px breakpoint', async ({ page }) => { await page.setViewportSize({ width: 861, height: 900 }); await page.goto('/blocks/blocks/ui/button/', { waitUntil: 'networkidle' }); - await expect(page.getByRole('button', { name: 'Open navigation' })).toBeHidden(); + const navigationMenu = page.getByRole('button', { name: 'Navigation menu', exact: true }); + await expect(navigationMenu).toBeHidden(); await page.setViewportSize({ width: 860, height: 900 }); - const openNavigation = page.getByRole('button', { name: 'Open navigation' }); - await expect(openNavigation).toBeVisible(); - await openNavigation.click(); - await expect(page.getByRole('button', { name: 'Close navigation' })).toBeVisible(); + await expect(navigationMenu).toBeVisible(); + await expect(navigationMenu).toHaveAttribute('aria-expanded', 'false'); + await navigationMenu.click(); + await expect(navigationMenu).toHaveAttribute('aria-expanded', 'true'); + const closeNavigation = page.getByRole('button', { name: 'Close navigation', exact: true }); + await expect(closeNavigation).toBeVisible(); + await expect(closeNavigation).toHaveCount(1); + await expect(page.getByRole('button', { name: 'Dismiss navigation', exact: true })).toBeVisible(); await page.getByRole('link', { name: 'Tooltip' }).click(); await expect(page).toHaveURL(/\/blocks\/blocks\/ui\/tooltip\/$/); - await expect(page.getByRole('button', { name: 'Close navigation' })).toHaveCount(0); + await expect(closeNavigation).toHaveCount(0); + await expect(navigationMenu).toHaveAttribute('aria-expanded', 'false'); }); test('mobile modal examples open, dismiss, and restore focus without hydration errors', async ({ page }) => { diff --git a/apps/blocks/next.config.ts b/apps/blocks/next.config.ts index 082171b..c516483 100644 --- a/apps/blocks/next.config.ts +++ b/apps/blocks/next.config.ts @@ -4,6 +4,9 @@ import type { NextConfig } from 'next'; const isPagesBuild = process.env.BLOCKS_PAGES === '1'; const nextConfig: NextConfig = { + ...(process.env.CONSOLE_KIT_INTEGRATION === '1' + ? { allowedDevOrigins: ['127.0.0.1'] } + : {}), ...(isPagesBuild ? { output: 'export', diff --git a/apps/blocks/package.json b/apps/blocks/package.json index c9878b7..607657b 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -5,10 +5,8 @@ "scripts": { "gen": "tsx scripts/generate-ui-demo-source.ts && tsx scripts/check-docs.ts", "gen:check": "tsx scripts/generate-ui-demo-source.ts --check && tsx scripts/check-docs.ts", - "fixtures:refresh": "tsx scripts/refresh-sdk-fixtures.ts", - "fixtures:check": "tsx scripts/check-sdk-fixtures.ts", "build:registry": "pnpm --filter @constructive-io/registry build", - "check:flows": "pnpm gen:check && pnpm fixtures:check", + "check:flows": "pnpm gen:check", "check:install-namespace": "pnpm gen:check", "check:selections": "tsx scripts/check-mutation-selections.ts", "predev": "pnpm gen:check", @@ -19,9 +17,12 @@ "start": "next start --port 3005", "lint:types": "tsc --noEmit && pnpm scripts:typecheck", "scripts:typecheck": "tsc --noEmit -p tsconfig.scripts.json", - "test": "vitest run", + "test": "vitest run && pnpm test:native-fixture", + "test:native-fixture": "tsx --test e2e-live/native-fixture.test.ts", "test:watch": "vitest --watch", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:live": "playwright test --config playwright.live.config.ts", + "fixture:console-kit": "tsx scripts/console-kit-native-fixture.ts" }, "postcss": { "plugins": { @@ -34,7 +35,9 @@ "@ai-sdk/openai-compatible": "^3.0.9", "@ai-sdk/react": "^4.0.27", "@base-ui/react": "^1.0.0", + "@constructive-io/data": "workspace:*", "@constructive-io/graphql-types": "^3.4.3", + "@constructive-io/sheets": "workspace:*", "@constructive-io/ui": "workspace:*", "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.90.16", @@ -52,7 +55,8 @@ "react-dom": "^19.2.0", "sugar-high": "1.2.0", "tailwind-merge": "^3.3.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { "@playwright/test": "1.61.1", diff --git a/apps/blocks/playwright.live.config.ts b/apps/blocks/playwright.live.config.ts new file mode 100644 index 0000000..bbec5df --- /dev/null +++ b/apps/blocks/playwright.live.config.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; + +function requiredRouteUrl(): string { + const value = process.env.CONSOLE_KIT_BASE_URL; + if (!value) throw new Error('CONSOLE_KIT_BASE_URL is required for the live Console Kit suite.'); + + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('CONSOLE_KIT_BASE_URL must use HTTP or HTTPS.'); + } + return url.toString(); +} + +const proofUrl = requiredRouteUrl(); + +export default defineConfig({ + testDir: './e2e-live', + testMatch: /console-kit\.live\.spec\.ts/, + outputDir: '/tmp/constructive-blocks-playwright-live', + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: 'list', + preserveOutput: 'failures-only', + timeout: 120_000, + expect: { timeout: 20_000 }, + use: { + baseURL: new URL(proofUrl).origin, + colorScheme: 'dark', + serviceWorkers: 'block', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'off' + }, + projects: [ + { + name: 'console-kit-live-chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1000 } + } + } + ], + webServer: { + command: 'pnpm dev', + url: proofUrl, + reuseExistingServer: true, + timeout: 180_000 + } +}); diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index 459aabc..dc63f35 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -4,2745 +4,991 @@ "homepage": "https://constructive-io.github.io/blocks", "items": [ { - "name": "auth-errors", + "name": "billing-contracts", "type": "registry:lib", - "title": "Auth Errors", - "description": "Error-code mapping and GraphQL/validation error parsing for auth blocks. Zero imports; foundation lib shared by every auth block.", + "title": "Billing Contracts", + "description": "Provider-neutral billing DTOs, arbitrary-precision formatters, open-status presentation maps, and explicit resource states shared by every customer billing block.", "categories": [ "blocks", - "foundation" + "billing" ], + "docs": "## Usage\n\nInstall this item when building a custom billing composition. It defines the shared resource states, account references, money values, allowances, plans, subscriptions, usage, credits, activity, messages, and formatting options used by the billing blocks.\n\n```ts\nimport type { BillingPlan, BillingResource } from '@/blocks/billing/billing-contracts/billing-contracts';\n\nconst plans: BillingResource = {\n status: 'ready',\n quality: 'authoritative',\n asOf: '2026-07-20T12:00:00.000Z',\n data: [{\n id: 'growth',\n name: 'Growth',\n prices: [{\n kind: 'fixed',\n id: 'growth-monthly',\n interval: 'month',\n money: { amountMinor: '4900', currency: 'USD' }\n }]\n }]\n};\n```\n\nKeep quantities, limits, deltas, and minor-unit money as decimal strings so large values retain their precision. Supply an explicit locale and time zone to the formatters, keep each record's currency, and use the explicit contact-sales, unlimited, uninitialized, and unknown variants when they apply.", "dependencies": [], "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/lib/auth-errors.ts", - "target": "src/blocks/lib/auth-errors.ts", + "path": "registry/constructive/blocks/billing/billing-contracts/billing-contracts.ts", + "target": "src/blocks/billing/billing-contracts/billing-contracts.ts", "type": "registry:lib" } ] }, { - "name": "auth-schemas", + "name": "billing-ui", "type": "registry:lib", - "title": "Auth Schemas", - "description": "Zod validation schemas for auth forms (Wave 0 ships the login subset). Foundation lib shared by auth blocks.", + "title": "Billing UI Helpers", + "description": "Shared formatting and presentation helpers used by the customer billing blocks.", "categories": [ "blocks", - "foundation" - ], - "dependencies": [ - "zod" + "billing" ], + "dependencies": [], "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/lib/schemas.ts", - "target": "src/blocks/lib/schemas.ts", + "path": "registry/constructive/blocks/billing/billing-ui/billing-ui.tsx", + "target": "src/blocks/billing/billing-ui/billing-ui.tsx", "type": "registry:lib" } ] }, { - "name": "auth-error-alert", - "type": "registry:component", - "title": "Auth Error Alert", - "description": "Inline animated error banner for auth forms. Collapses to zero height when there is no error so layout never jumps.", + "name": "billing-pricing-table", + "type": "registry:block", + "title": "Billing Pricing Table", + "description": "Responsive plan comparison with controlled interval selection, fixed and contact-sales pricing, current and featured states, and optional actions.", "categories": [ "blocks", - "foundation" + "billing" ], + "docs": "## Usage\n\nPass a plan resource, account context, formatting options, a controlled billing interval, and the actions you want to show.\n\n```tsx\nimport { BillingPricingTable } from '@/blocks/billing/billing-pricing-table/billing-pricing-table';\n\n openPlanSelection({ planId, priceId, account })}\n onContactSales={({ planId, account }) => openSalesRequest({ planId, account })}\n/>\n```\n\nFixed prices use minor-unit decimal strings and the currency on each record. Use `BillingPrice.kind = 'contact_sales'` for custom pricing. Missing callbacks remove their actions, rejected promises appear next to the action, and resolved promises leave the selected plan state under your control.", "dependencies": [ "lucide-react" ], - "registryDependencies": [ - "cn" - ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/primitives/auth-error-alert.tsx", - "target": "src/blocks/primitives/auth-error-alert.tsx", + "path": "registry/constructive/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", + "target": "src/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", "type": "registry:component" - } - ] - }, - { - "name": "form-field", - "type": "registry:component", - "title": "Form Field", - "description": "Label + input + inline error wrapper bound to a TanStack Form field. Composes source-installed FormControl and Input primitives.", - "categories": [ - "blocks", - "foundation" - ], - "dependencies": [], - "registryDependencies": [], - "files": [ + }, { - "path": "registry/constructive/blocks/primitives/form-field.tsx", - "target": "src/blocks/primitives/form-field.tsx", + "path": "registry/constructive/blocks/billing/billing-pricing-table/messages.ts", + "target": "src/blocks/billing/billing-pricing-table/messages.ts", "type": "registry:component" } ] }, { - "name": "auth-loading-button", - "type": "registry:component", - "title": "Auth Loading Button", - "description": "Submit button that shows a spinner + loading label and self-disables while a request is in flight. Composes the source-installed Button primitive.", + "name": "billing-subscription-card", + "type": "registry:block", + "title": "Billing Subscription Card", + "description": "Current-plan summary with independent subscription, provider, and payment status plus exact lifecycle dates and optional actions.", "categories": [ "blocks", - "foundation" + "billing" ], + "docs": "## Usage\n\nPass the current plan together with any payment and provider states you want to show. Add only the actions that make sense in your billing flow.\n\n```tsx\nimport { BillingSubscriptionCard } from '@/blocks/billing/billing-subscription-card/billing-subscription-card';\n\n openSubscriptionSettings(subscriptionId, account)}\n/>\n```\n\nOnly `renewsAt` is labelled “Renews”; `endsAt` is always labelled “Ends.” Status strings are open, unknown values render neutrally, and missing callbacks hide their controls.", "dependencies": [ "lucide-react" ], - "registryDependencies": [ - "cn" - ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/primitives/auth-loading-button.tsx", - "target": "src/blocks/primitives/auth-loading-button.tsx", + "path": "registry/constructive/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", + "target": "src/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-subscription-card/messages.ts", + "target": "src/blocks/billing/billing-subscription-card/messages.ts", "type": "registry:component" } ] }, { - "name": "blocks-runtime", - "type": "registry:component", - "title": "Blocks Runtime", - "description": "The single wiring point for Constructive data blocks. Reuse-or-mount a shared QueryClient, configure each generated SDK namespace, and attach a per-request Bearer token. Mount once at the app root.", + "name": "billing-entitlements-list", + "type": "registry:block", + "title": "Billing Entitlements List", + "description": "Structured display of plan feature flags, caps, discrete quotas, meter allowances, and neutral unknown values.", "categories": [ "blocks", - "runtime" + "billing" ], - "docs": "## Setup\n\n`blocks-runtime` is the ONE wiring point for Constructive data blocks. Mount `` once at your app root; every data block declares it as a dependency.\n\n```tsx\nimport { BlocksRuntime } from '@/blocks/runtime/blocks-runtime';\nimport { tokenManager } from '@/lib/auth/token-manager';\n\n tokenManager.getAccessToken()}>\n {children}\n\n```\n\n### Required env\n\nEach namespace reads `NEXT_PUBLIC__GRAPHQL_ENDPOINT` (e.g. `NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT`).\n\n### Generated SDKs\n\nEach namespace must have a generated SDK at `@/generated/` exporting `configure()`. Blocks import their hooks from there — the runtime never ships those types.\n\n### Adding a namespace\n\nExtend the `BlocksNamespace` union plus the `CONFIGURERS` and `ENDPOINTS` maps in this file — the single expected edit, since it is your app's wiring point, not a leaf block.", + "docs": "## Usage\n\nPass each entitlement with a semantic kind so features, caps, quotas, and metered allowances keep their distinct meaning.\n\n```tsx\nimport { BillingEntitlementsList } from '@/blocks/billing/billing-entitlements-list/billing-entitlements-list';\n\n\n```\n\nUse explicit `unlimited`, `uninitialized`, and `unknown` variants. Unknown values stay visible with a neutral presentation, and message overrides let you adapt the labels without changing the data model.", "dependencies": [ - "@tanstack/react-query" + "lucide-react" ], "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/runtime/blocks-runtime.tsx", - "target": "src/blocks/runtime/blocks-runtime.tsx", + "path": "registry/constructive/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", + "target": "src/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-entitlements-list/messages.ts", + "target": "src/blocks/billing/billing-entitlements-list/messages.ts", "type": "registry:component" } ] }, { - "name": "auth-sign-in-card", + "name": "billing-usage-overview", "type": "registry:block", - "title": "Sign-in Card", - "description": "Email + password sign-in card bound to the host's generated useSignInMutation. Inline error mapping, MFA/unverified branches via onMessage, and an onSubmit override seam for non-Constructive backends.", + "title": "Billing Usage Overview", + "description": "Current-period usage with quota, boolean, unlimited, uninitialized, exhausted, overage, category-pool, and universal-pool states.", "categories": [ "blocks", - "authentication" + "billing" ], - "docs": "## Setup\n\n1. Mount `blocks-runtime` once at your app root (installed automatically as a dependency) and set `NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT`.\n2. This block imports `useSignInMutation` from your generated `auth` SDK at `@/generated/auth`. Ensure that SDK is generated.\n\n```tsx\nimport { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card';\n\n router.push('/')} forgotPasswordHref=\"/forgot\" signUpHref=\"/register\" />\n```\n\nThe declared requirements ship to `.constructive/blocks/auth-sign-in-card.requires.json` for the SDK check tool.", + "docs": "## Usage\n\nPass a current-period usage snapshot and nest child meters beneath category or universal pools when credits are shared.\n\n```tsx\nimport { BillingUsageOverview } from '@/blocks/billing/billing-usage-overview/billing-usage-overview';\n\n openUsageHistory(meterSlug)}\n onBuyCredits={(meterSlug) => openCreditPurchase(meterSlug)}\n/>\n```\n\nCategory and universal pools stay nested so their application order remains clear. Progress caps visually at 100 percent while text and accessible values preserve the actual used, allowance, and overage values. Actions disappear when their callbacks are absent.", "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-schemas", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" + "lucide-react", + "motion" ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/sign-in-card/sign-in-card.tsx", - "target": "src/blocks/auth/sign-in-card/sign-in-card.tsx", + "path": "registry/constructive/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", + "target": "src/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/sign-in-card/messages.ts", - "target": "src/blocks/auth/sign-in-card/messages.ts", + "path": "registry/constructive/blocks/billing/billing-usage-overview/messages.ts", + "target": "src/blocks/billing/billing-usage-overview/messages.ts", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/sign-in-card/auth-sign-in-card.requires.json", - "target": "~/.constructive/blocks/auth-sign-in-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "password-strength", - "type": "registry:lib", - "title": "Password Strength", - "description": "Lightweight, dependency-free password-strength estimator (0–4 score + label) for inline sign-up/reset UX. Zero imports; foundation lib shared by auth blocks.", - "categories": [ - "blocks", - "foundation" - ], - "dependencies": [], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/lib/password-strength.ts", - "target": "src/blocks/lib/password-strength.ts", - "type": "registry:lib" } ] }, { - "name": "user-avatar", + "name": "billing-credits-card", "type": "registry:block", - "title": "User Avatar", - "description": "Avatar with image and generated-initials fallback for a user. Presentational only — takes user fields as props; no data binding.", + "title": "Billing Credits Card", + "description": "Available credits grouped by compatible meter and unit with permanent, period, rollover, expiring, and unknown lot semantics.", "categories": [ "blocks", - "user" + "billing" ], - "dependencies": [], - "registryDependencies": [ - "cn" + "docs": "## Usage\n\nPass available balances and their credit lots while keeping each meter and unit independent.\n\n```tsx\nimport { BillingCreditsCard } from '@/blocks/billing/billing-credits-card/billing-credits-card';\n\n\n```\n\nKeep incompatible meters and units in separate balance groups. Credit lots show the current grant makeup, while `billing-activity-table` provides a chronological view of grants, usage, adjustments, rollover, and expiration.", + "dependencies": [ + "lucide-react", + "motion" ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/user/user-avatar/user-avatar.tsx", - "target": "src/blocks/user/user-avatar/user-avatar.tsx", + "path": "registry/constructive/blocks/billing/billing-credits-card/billing-credits-card.tsx", + "target": "src/blocks/billing/billing-credits-card/billing-credits-card.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/user/user-avatar/user-initials.ts", - "target": "src/blocks/user/user-avatar/user-initials.ts", + "path": "registry/constructive/blocks/billing/billing-credits-card/messages.ts", + "target": "src/blocks/billing/billing-credits-card/messages.ts", "type": "registry:component" } ] }, { - "name": "auth-sign-out-button", + "name": "billing-usage-history", "type": "registry:block", - "title": "Sign-out Button", - "description": "Button bound to the host's generated useSignOutMutation. Clears cached queries on success; feedback via onSuccess/onError; onSubmit override seam for non-Constructive backends.", + "title": "Billing Usage History", + "description": "Accessible, table-first period history with controlled meter, period, and pagination filters plus explicit quality labels.", "categories": [ "blocks", - "authentication" + "billing" ], + "docs": "## Usage\n\nPass a page of usage periods and control the meter, period, and pagination values when you provide their callbacks.\n\n```tsx\nimport { BillingUsageHistory } from '@/blocks/billing/billing-usage-history/billing-usage-history';\n\n\n```\n\nCredits and overage columns appear only when their values are marked as confirmed. Quality labels remain visible on each period, and the table keeps pagination and filter changes under your control.", "dependencies": [ - "@tanstack/react-query" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "cn" + "lucide-react" ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/sign-out-button/sign-out-button.tsx", - "target": "src/blocks/auth/sign-out-button/sign-out-button.tsx", + "path": "registry/constructive/blocks/billing/billing-usage-history/billing-usage-history.tsx", + "target": "src/blocks/billing/billing-usage-history/billing-usage-history.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/sign-out-button/messages.ts", - "target": "src/blocks/auth/sign-out-button/messages.ts", + "path": "registry/constructive/blocks/billing/billing-usage-history/messages.ts", + "target": "src/blocks/billing/billing-usage-history/messages.ts", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/sign-out-button/auth-sign-out-button.requires.json", - "target": "~/.constructive/blocks/auth-sign-out-button.requires.json", - "type": "registry:file" } ] }, { - "name": "auth-forgot-password-card", + "name": "billing-activity-table", "type": "registry:block", - "title": "Forgot-password Card", - "description": "Email-entry card bound to the host's generated useForgotPasswordMutation. Always shows a neutral confirmation (no account enumeration); inline error mapping; onSubmit override seam.", + "title": "Billing Activity Table", + "description": "Paginated ledger activity with controlled filters, semantic class/type presentation, and a titled metadata detail sheet.", "categories": [ "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-schemas", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" + "billing" ], + "docs": "## Usage\n\nPass ledger class and entry type with each activity entry so labels and badge tones describe the event itself.\n\n```tsx\nimport { BillingActivityTable } from '@/blocks/billing/billing-activity-table/billing-activity-table';\n\n\n```\n\nA positive delta can represent consumption and a negative delta can represent release or reset, so presentation derives from `ledgerClass` and `entryType` rather than the sign. Unknown values remain neutral, and JSON-safe metadata opens in a titled detail sheet.", + "dependencies": [], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/forgot-password-card/forgot-password-card.tsx", - "target": "src/blocks/auth/forgot-password-card/forgot-password-card.tsx", + "path": "registry/constructive/blocks/billing/billing-activity-table/billing-activity-table.tsx", + "target": "src/blocks/billing/billing-activity-table/billing-activity-table.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/forgot-password-card/messages.ts", - "target": "src/blocks/auth/forgot-password-card/messages.ts", + "path": "registry/constructive/blocks/billing/billing-activity-table/messages.ts", + "target": "src/blocks/billing/billing-activity-table/messages.ts", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/forgot-password-card/auth-forgot-password-card.requires.json", - "target": "~/.constructive/blocks/auth-forgot-password-card.requires.json", - "type": "registry:file" } ] }, { - "name": "auth-reset-password-card", + "name": "billing-settings-page", "type": "registry:block", - "title": "Reset-password Card", - "description": "New-password card bound to the host's generated useResetPasswordMutation. Reads token/roleId from props or URL, inline strength meter, expired/invalid/missing-token states; onSubmit override seam.", + "title": "Billing Settings Page", + "description": "Router-neutral Overview, Usage, and Plans composition that keeps subscription, usage, credits, entitlements, history, activity, and pricing failures isolated.", "categories": [ "blocks", - "authentication" + "billing" ], + "docs": "## Usage\n\nPass one account together with independent resources and actions. Use `section` and `onSectionChange` to synchronize navigation, or `defaultSection` for local state. The page renders its own heading by default; set `showHeader={false}` when the surrounding document already provides it.\n\n```tsx\nimport { BillingSettingsPage } from '@/blocks/billing/billing-settings-page/billing-settings-page';\n\n\n```\n\nEach resource renders its own loading, empty, error, ready, and quality state, so one unavailable section never replaces the whole page. Controlled filters and actions are forwarded to their matching leaf blocks.", "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-schemas", - "password-strength", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" + "lucide-react" ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/reset-password-card/reset-password-card.tsx", - "target": "src/blocks/auth/reset-password-card/reset-password-card.tsx", + "path": "registry/constructive/blocks/billing/billing-settings-page/billing-settings-page.tsx", + "target": "src/blocks/billing/billing-settings-page/billing-settings-page.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/reset-password-card/messages.ts", - "target": "src/blocks/auth/reset-password-card/messages.ts", + "path": "registry/constructive/blocks/billing/billing-settings-page/messages.ts", + "target": "src/blocks/billing/billing-settings-page/messages.ts", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/reset-password-card/auth-reset-password-card.requires.json", - "target": "~/.constructive/blocks/auth-reset-password-card.requires.json", - "type": "registry:file" } ] }, { - "name": "auth-sign-up-card", - "type": "registry:block", - "title": "Sign-up Card", - "description": "Email + password registration card bound to the host's generated useSignUpMutation. Inline password-strength meter, confirm-match validation, inline error mapping; onSubmit override seam.", - "categories": [ - "blocks", - "authentication" - ], + "name": "pack-foundation", + "type": "registry:lib", + "title": "Feature Pack Foundation", + "description": "Provider-neutral resource, action-policy, error, loading, empty, and status contracts shared by Constructive feature packs.", "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-schemas", - "password-strength", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" + "lucide-react" ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/sign-up-card/sign-up-card.tsx", - "target": "src/blocks/auth/sign-up-card/sign-up-card.tsx", - "type": "registry:component" + "path": "registry/constructive/blocks/feature-packs/shared/feature-pack-contracts.ts", + "target": "src/blocks/feature-packs/shared/feature-pack-contracts.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/sign-up-card/messages.ts", - "target": "src/blocks/auth/sign-up-card/messages.ts", + "path": "registry/constructive/blocks/feature-packs/shared/feature-pack-ui.tsx", + "target": "src/blocks/feature-packs/shared/feature-pack-ui.tsx", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/sign-up-card/auth-sign-up-card.requires.json", - "target": "~/.constructive/blocks/auth-sign-up-card.requires.json", - "type": "registry:file" } ] }, { - "name": "auth-step-up-dialog", - "type": "registry:block", - "title": "Step-up Dialog", - "description": "Re-authentication dialog bound to the host's generated useRequireStepUpQuery + useVerifyPasswordMutation/useVerifyTotpMutation. Short-circuits when step-up is already satisfied; onSubmit override seam.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], + "name": "console-runtime", + "type": "registry:lib", + "title": "Console Runtime", + "description": "Endpoint, session, transport, cache partitioning, and adapter contracts for identity-scoped Constructive consoles.", + "dependencies": [], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/auth/step-up-dialog/step-up-dialog.tsx", - "target": "src/blocks/auth/step-up-dialog/step-up-dialog.tsx", - "type": "registry:component" + "path": "registry/constructive/blocks/console-runtime/capabilities.ts", + "target": "src/blocks/console-runtime/capabilities.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/step-up-dialog/messages.ts", - "target": "src/blocks/auth/step-up-dialog/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/console-runtime/endpoints.ts", + "target": "src/blocks/console-runtime/endpoints.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/step-up-dialog/auth-step-up-dialog.requires.json", - "target": "~/.constructive/blocks/auth-step-up-dialog.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/console-runtime/feature-adapter.ts", + "target": "src/blocks/console-runtime/feature-adapter.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-runtime/index.ts", + "target": "src/blocks/console-runtime/index.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-runtime/session.ts", + "target": "src/blocks/console-runtime/session.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-runtime/standalone-session.ts", + "target": "src/blocks/console-runtime/standalone-session.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-runtime/transport.ts", + "target": "src/blocks/console-runtime/transport.ts", + "type": "registry:lib" } ] }, { - "name": "use-step-up", - "type": "registry:hook", - "title": "useStepUp", - "description": "Imperative step-up trigger: a provider + hook that mounts the step-up dialog on demand and resolves a Promise when the user verifies. Wraps auth-step-up-dialog.", - "categories": [ - "blocks", - "authentication" + "name": "console-feature-catalog", + "type": "registry:lib", + "title": "Console Feature Catalog", + "description": "Validated feature-pack capability manifests and stable backend preset profiles.", + "dependencies": [ + "zod" ], - "dependencies": [], "registryDependencies": [ - "auth-step-up-dialog" + "console-runtime" ], "files": [ { - "path": "registry/constructive/blocks/auth/use-step-up/use-step-up.ts", - "target": "src/blocks/auth/use-step-up/use-step-up.ts", - "type": "registry:hook" + "path": "registry/constructive/feature-packs/capabilities.ts", + "target": "src/feature-packs/capabilities.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/feature-packs/catalog.ts", + "target": "src/feature-packs/catalog.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/use-step-up/step-up-provider.tsx", - "target": "src/blocks/auth/use-step-up/step-up-provider.tsx", - "type": "registry:component" + "path": "registry/constructive/feature-packs/catalog-validation.ts", + "target": "src/feature-packs/catalog-validation.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/feature-packs/index.ts", + "target": "src/feature-packs/index.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/feature-packs/manifest.ts", + "target": "src/feature-packs/manifest.ts", + "type": "registry:lib" } ] }, { - "name": "auth-account-emails-list", + "name": "console-kit-core", "type": "registry:block", - "title": "Account Emails List", - "description": "Manage a user's email addresses bound to the host's generated useEmailsQuery + create/update/delete/send-verification mutations. Add, verify, set-primary, and remove emails; onSubmit override seam.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Constructive Console Kit Core", + "description": "The leaf-independent Console Kit shell, runtime, discovery, and single modular Zustand store.", + "docs": "`console-kit-core` installed the shell, runtime, semantic routing, callback boundary, and one per-instance modular Zustand store. Core intentionally includes no feature view, so add selected `console-module-*` items.\n\nDegraded states: explicit endpoint, current `_meta`, introspection, capability, and adapter evidence fail closed independently; installation never grants authority.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [ - "@tanstack/react-form" + "@constructive-io/data", + "@tanstack/react-query", + "graphql", + "lucide-react", + "zustand" ], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-schemas", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" + "console-runtime", + "console-feature-catalog" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-emails-list/account-emails-list.tsx", - "target": "src/blocks/auth/account-emails-list/account-emails-list.tsx", + "path": "registry/constructive/blocks/console-kit/feature-module.ts", + "target": "src/blocks/console-kit/feature-module.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/console-kit-routes.ts", + "target": "src/blocks/console-kit/console-kit-routes.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/console-kit-contracts.ts", + "target": "src/blocks/console-kit/console-kit-contracts.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/console-connection-menu.tsx", + "target": "src/blocks/console-kit/console-connection-menu.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-emails-list/messages.ts", - "target": "src/blocks/auth/account-emails-list/messages.ts", + "path": "registry/constructive/blocks/console-kit/console-kit-runtime.tsx", + "target": "src/blocks/console-kit/console-kit-runtime.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-emails-list/auth-account-emails-list.requires.json", - "target": "~/.constructive/blocks/auth-account-emails-list.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-sign-in-page", - "type": "registry:block", - "title": "auth-sign-in-page", - "description": "Thin Next.js page wrapper that composes auth-sign-in-card into a centered full-viewport layout with router glue: reads ?redirect= from useSearchParams, validates same-origin with URL-parse guard, routes to MFA_PATH with challenge token on mfaRequired, delegates all data/form logic to SignInCard.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-sign-in-card", - "cn" - ], - "files": [ + "path": "registry/constructive/blocks/console-kit/use-latest-callback.ts", + "target": "src/blocks/console-kit/use-latest-callback.ts", + "type": "registry:lib" + }, { - "path": "registry/constructive/blocks/auth/sign-in-page/sign-in-page.tsx", - "target": "src/blocks/auth/sign-in-page/sign-in-page.tsx", + "path": "registry/constructive/blocks/console-kit/console-kit-core.tsx", + "target": "src/blocks/console-kit/console-kit-core.tsx", "type": "registry:component" - } - ] - }, - { - "name": "auth-sign-up-page", - "type": "registry:block", - "title": "auth-sign-up-page", - "description": "Thin Next.js 15 page composing auth-sign-up-card with centered layout and post-registration redirect logic.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-sign-up-card", - "cn" - ], - "files": [ + }, { - "path": "registry/constructive/blocks/auth/sign-up-page/sign-up-page.tsx", - "target": "src/blocks/auth/sign-up-page/sign-up-page.tsx", + "path": "registry/constructive/blocks/console-kit/console-kit.tsx", + "target": "src/blocks/console-kit/console-kit.tsx", "type": "registry:component" - } - ] - }, - { - "name": "auth-forgot-password-page", - "type": "registry:block", - "title": "Forgot Password Page", - "description": "Next.js 15 page composing auth-forgot-password-card inside a centered layout, pre-filling email from ?email= searchParam.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-forgot-password-card" - ], - "files": [ + }, { - "path": "registry/constructive/blocks/auth/forgot-password-page/forgot-password-page.tsx", - "target": "src/blocks/auth/forgot-password-page/forgot-password-page.tsx", - "type": "registry:component" - } - ] - }, - { - "name": "auth-reset-password-page", - "type": "registry:block", - "title": "Reset Password Page", - "description": "Thin Next.js 15 page wrapper for auth-reset-password-card: reads ?token= and ?role_id= from URL searchParams and passes them as props to the card, routes to sign-in on success.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-reset-password-card", - "cn" - ], - "files": [ + "path": "registry/constructive/blocks/console-kit/constructive/constructive-adapter-utils.ts", + "target": "src/blocks/console-kit/constructive/constructive-adapter-utils.ts", + "type": "registry:lib" + }, { - "path": "registry/constructive/blocks/auth/reset-password-page/reset-password-page.tsx", - "target": "src/blocks/auth/reset-password-page/reset-password-page.tsx", - "type": "registry:component" - } - ] - }, - { - "name": "auth-verify-email-page", - "type": "registry:block", - "title": "auth-verify-email-page (blocker fixes)", - "description": "Fixed two blockers and two minors in the auth-verify-email-page block. All 11 tests pass, zero TSC errors in the block's own files.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "cn" - ], - "files": [ + "path": "registry/constructive/blocks/console-kit/constructive/constructive-capabilities.ts", + "target": "src/blocks/console-kit/constructive/constructive-capabilities.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/constructive/constructive-callback.ts", + "target": "src/blocks/console-kit/constructive/constructive-callback.ts", + "type": "registry:lib" + }, { - "path": "registry/constructive/blocks/auth/verify-email-page/verify-email-page.tsx", - "target": "src/blocks/auth/verify-email-page/verify-email-page.tsx", + "path": "registry/constructive/blocks/console-kit/constructive/constructive-console-kit.tsx", + "target": "src/blocks/console-kit/constructive/constructive-console-kit.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/verify-email-page/messages.ts", - "target": "src/blocks/auth/verify-email-page/messages.ts", + "path": "registry/constructive/blocks/console-kit/constructive/constructive-graphql.ts", + "target": "src/blocks/console-kit/constructive/constructive-graphql.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/constructive/constructive-meta-utils.ts", + "target": "src/blocks/console-kit/constructive/constructive-meta-utils.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/adapter-slice.ts", + "target": "src/blocks/console-kit/store/adapter-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/context-slice.ts", + "target": "src/blocks/console-kit/store/context-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/endpoint-capability-slice.ts", + "target": "src/blocks/console-kit/store/endpoint-capability-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/navigation-slice.ts", + "target": "src/blocks/console-kit/store/navigation-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/runtime-slice.ts", + "target": "src/blocks/console-kit/store/runtime-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/session-slice.ts", + "target": "src/blocks/console-kit/store/session-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/console-kit/store/console-kit-store.tsx", + "target": "src/blocks/console-kit/store/console-kit-store.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/verify-email-page/auth-verify-email-page.requires.json", - "target": "~/.constructive/blocks/auth-verify-email-page.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/console-kit/store/index.ts", + "target": "src/blocks/console-kit/store/index.ts", + "type": "registry:lib" } ] }, { - "name": "auth-verify-email-banner", + "name": "feature-pack-data", "type": "registry:block", - "title": "auth-verify-email-banner", - "description": "Dismissible top-of-page banner for authenticated users whose primary email is not yet verified. Includes a resend verification email CTA backed by the host's generated useSendVerificationEmailMutation hook.", - "categories": [ - "blocks", - "authentication" + "title": "Data Feature Pack", + "description": "A current-_meta-only application data explorer with application-table filtering and spreadsheet CRUD.", + "docs": "`feature-pack-data` installed a provider-neutral view and `.constructive/feature-packs/data.json`. Import from `@/blocks/feature-packs/data/data-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-data` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: The host owns Sheets state and endpoint binding; incompatible metadata stays explicit, and PostgreSQL privileges and RLS decide every query and mutation.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/data/", + "dependencies": [ + "@constructive-io/data", + "@constructive-io/sheets", + "lucide-react" ], - "dependencies": [], + "css": { + "@import '@constructive-io/sheets/styles.css'": {} + }, "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/auth/verify-email-banner/verify-email-banner.tsx", - "target": "src/blocks/auth/verify-email-banner/verify-email-banner.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/verify-email-banner/messages.ts", - "target": "src/blocks/auth/verify-email-banner/messages.ts", + "path": "registry/constructive/blocks/feature-packs/data/data-feature-pack.tsx", + "target": "src/blocks/feature-packs/data/data-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/verify-email-banner/auth-verify-email-banner.requires.json", - "target": "~/.constructive/blocks/auth-verify-email-banner.requires.json", + "path": "registry/constructive/blocks/feature-packs/data/feature-pack.json", + "target": "~/.constructive/feature-packs/data.json", "type": "registry:file" } ] }, { - "name": "auth-change-password-form", + "name": "console-module-data", "type": "registry:block", - "title": "Change Password Form", - "description": "Authenticated inline form to update current password, with step-up (tier: medium) gate, password-strength meter, confirm mismatch guard, and full error mapping.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], + "title": "Data Console Module", + "description": "Console Kit navigation, capability discovery, and tenant runtime binding for the Data feature pack.", + "docs": "`console-module-data` installed Console Kit core, the provider-neutral data view, and its Constructive integration. Import from `@/blocks/feature-packs/data/data-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: A missing or incompatible data endpoint, current _meta contract, introspection result, or application table scope keeps Data unavailable; RLS remains authoritative.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/data/", + "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "password-strength", - "auth-error-alert", - "form-field", - "auth-loading-button", - "use-step-up", - "cn" + "console-kit-core", + "feature-pack-data" ], "files": [ { - "path": "registry/constructive/blocks/auth/change-password-form/change-password-form.tsx", - "target": "src/blocks/auth/change-password-form/change-password-form.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/change-password-form/messages.ts", - "target": "src/blocks/auth/change-password-form/messages.ts", + "path": "registry/constructive/blocks/feature-packs/data/data-console-module.tsx", + "target": "src/blocks/feature-packs/data/data-console-module.tsx", "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/change-password-form/auth-change-password-form.requires.json", - "target": "~/.constructive/blocks/auth-change-password-form.requires.json", - "type": "registry:file" } ] }, { - "name": "auth-account-profile-card", + "name": "feature-pack-auth", "type": "registry:block", - "title": "auth-account-profile-card", - "description": "Allows the signed-in user to update their display_name and profile_picture. Renders person or organization name fields based on user.type. Profile picture changes use an optimistic preview; the raw File is passed via userPatch.profilePictureUpload (the ORM-native upload field) rather than being embedded in an ImageJsonb.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Authentication Feature Pack", + "description": "Provider-neutral public authentication flows and personal account/session management.", + "docs": "`feature-pack-auth` installed a provider-neutral view and `.constructive/feature-packs/auth.json`. Import from `@/blocks/feature-packs/auth/auth-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-auth` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: Unsupported policy grants or callbacks stay absent, provider failures remain explicit, and callback credentials never enter the view.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/auth/", "dependencies": [ - "@tanstack/react-form" + "lucide-react" ], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "user-avatar", - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-profile-card/account-profile-card.tsx", - "target": "src/blocks/auth/account-profile-card/account-profile-card.tsx", - "type": "registry:component" + "path": "registry/constructive/blocks/feature-packs/auth/auth-contracts.ts", + "target": "src/blocks/feature-packs/auth/auth-contracts.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/account-profile-card/messages.ts", - "target": "src/blocks/auth/account-profile-card/messages.ts", + "path": "registry/constructive/blocks/feature-packs/auth/auth-entry-panel.tsx", + "target": "src/blocks/feature-packs/auth/auth-entry-panel.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-profile-card/auth-account-profile-card.requires.json", - "target": "~/.constructive/blocks/auth-account-profile-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-account-sessions-list", - "type": "registry:block", - "title": "Account Sessions List", - "description": "Lists the signed-in user's active sessions (host-supplied via adapter seam) and provides per-session and bulk revoke actions, both gated behind step-up verification.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "use-step-up", - "cn" - ], - "files": [ + "path": "registry/constructive/blocks/feature-packs/auth/auth-challenge-panel.tsx", + "target": "src/blocks/feature-packs/auth/auth-challenge-panel.tsx", + "type": "registry:component" + }, { - "path": "registry/constructive/blocks/auth/account-sessions-list/account-sessions-list.tsx", - "target": "src/blocks/auth/account-sessions-list/account-sessions-list.tsx", + "path": "registry/constructive/blocks/feature-packs/auth/auth-account-view.tsx", + "target": "src/blocks/feature-packs/auth/auth-account-view.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-sessions-list/messages.ts", - "target": "src/blocks/auth/account-sessions-list/messages.ts", + "path": "registry/constructive/blocks/feature-packs/auth/auth-password-policy.ts", + "target": "src/blocks/feature-packs/auth/auth-password-policy.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/feature-packs/auth/auth-feature-pack.tsx", + "target": "src/blocks/feature-packs/auth/auth-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-sessions-list/auth-account-sessions-list.requires.json", - "target": "~/.constructive/blocks/auth-account-sessions-list.requires.json", + "path": "registry/constructive/blocks/feature-packs/auth/feature-pack.json", + "target": "~/.constructive/feature-packs/auth.json", "type": "registry:file" } ] }, { - "name": "auth-account-connected-accounts", + "name": "console-module-auth", "type": "registry:block", - "title": "auth-account-connected-accounts", - "description": "Settings card listing linked OAuth providers with disconnect (step-up gated) and connect-via-OAuth links.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Authentication Console Module", + "description": "Console Kit session flow, capability discovery, and Constructive adapter for the Authentication feature pack.", + "docs": "`console-module-auth` installed Console Kit core, the provider-neutral auth view, and its Constructive integration. Import from `@/blocks/feature-packs/auth/auth-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: Auth requires complete public session, credential, and password flows; pass csrfTokenProvider when required, and keep callback credentials outside Zustand.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/auth/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "use-step-up", - "cn" + "console-kit-core", + "feature-pack-auth" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-connected-accounts/account-connected-accounts.tsx", - "target": "src/blocks/auth/account-connected-accounts/account-connected-accounts.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-connected-accounts/messages.ts", - "target": "src/blocks/auth/account-connected-accounts/messages.ts", + "path": "registry/constructive/blocks/feature-packs/auth/auth-console-module.tsx", + "target": "src/blocks/feature-packs/auth/auth-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-connected-accounts/auth-account-connected-accounts.requires.json", - "target": "~/.constructive/blocks/auth-account-connected-accounts.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/console-kit/constructive/auth-adapter.ts", + "target": "src/blocks/console-kit/constructive/auth-adapter.ts", + "type": "registry:lib" } ] }, { - "name": "auth-passkey-management-list", + "name": "feature-pack-users", "type": "registry:block", - "title": "auth-passkey-management-list (blocker fixes)", - "description": "Fixed all 4 blockers and 3 worthwhile minors in the auth-passkey-management-list block. Step-up now fires unconditionally before both default and adapter delete paths; queryCredentials override is actually called; message keys renamed to spec; test asserts step-up in adapter path.", - "categories": [ - "blocks", - "authentication" + "title": "App access Feature Pack", + "description": "App access governance with member lifecycle controls, invitations, access profiles, direct and effective permissions, and new-member defaults.", + "docs": "`feature-pack-users` installed a provider-neutral view and `.constructive/feature-packs/users.json`. Import from `@/blocks/feature-packs/users/users-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-users` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: Missing resources or callbacks stay unavailable, while row policy, PostgreSQL privileges, RLS, and final-owner constraints decide mutations.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/users/", + "dependencies": [ + "lucide-react" ], - "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "use-step-up", - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/auth/passkey-management-list/passkey-management-list.tsx", - "target": "src/blocks/auth/passkey-management-list/passkey-management-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/passkey-management-list/messages.ts", - "target": "src/blocks/auth/passkey-management-list/messages.ts", + "path": "registry/constructive/blocks/feature-packs/users/users-feature-pack.tsx", + "target": "src/blocks/feature-packs/users/users-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/passkey-management-list/hooks/use-passkey-management.ts", - "target": "src/blocks/auth/passkey-management-list/hooks/use-passkey-management.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/auth/passkey-management-list/auth-passkey-management-list.requires.json", - "target": "~/.constructive/blocks/auth-passkey-management-list.requires.json", + "path": "registry/constructive/blocks/feature-packs/users/feature-pack.json", + "target": "~/.constructive/feature-packs/users.json", "type": "registry:file" } ] }, { - "name": "auth-api-key-created-modal", + "name": "console-module-users", "type": "registry:block", - "title": "auth-api-key-created-modal", - "description": "One-time display modal for a freshly-created raw API key. Enforces explicit acknowledgement before allowing dismiss. No data operations — purely presentational.", - "categories": [ - "blocks", - "authentication" - ], + "title": "App access Console Module", + "description": "Console Kit navigation, capability discovery, and Constructive adapter for the App access feature pack.", + "docs": "`console-module-users` installed Console Kit core, the provider-neutral users view, and its Constructive integration. Import from `@/blocks/feature-packs/users/users-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: App access requires a compatible admin contract, and every control still depends on pack policy, adapter support, row policy, PostgreSQL privileges, and RLS.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/users/", "dependencies": [], "registryDependencies": [ - "cn" + "console-kit-core", + "feature-pack-users" ], "files": [ { - "path": "registry/constructive/blocks/auth/api-key-created-modal/api-key-created-modal.tsx", - "target": "src/blocks/auth/api-key-created-modal/api-key-created-modal.tsx", + "path": "registry/constructive/blocks/feature-packs/users/users-console-module.tsx", + "target": "src/blocks/feature-packs/users/users-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/api-key-created-modal/messages.ts", - "target": "src/blocks/auth/api-key-created-modal/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/console-kit/constructive/users-adapter.ts", + "target": "src/blocks/console-kit/constructive/users-adapter.ts", + "type": "registry:lib" } ] }, { - "name": "auth-account-danger-card", + "name": "feature-pack-organizations", "type": "registry:block", - "title": "auth-account-danger-card", - "description": "Danger zone card that initiates the account-deletion flow with step-up auth gate and inline success/error states.", - "categories": [ - "blocks", - "authentication" + "title": "Organizations Feature Pack", + "description": "Complete tenant administration for selection, member governance, invitations, profiles, permissions, defaults, hierarchy, settings, principals, and API keys.", + "docs": "`feature-pack-organizations` installed a provider-neutral view and `.constructive/feature-packs/organizations.json`. Import from `@/blocks/feature-packs/organizations/organizations-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-organizations` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: When the active organization is missing or does not match the host-provided list, the view selects the first available organization. It never infers a principal, policy grant, or mutation authority; missing resources and callbacks stay unavailable, while PostgreSQL privileges and RLS remain authoritative.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/organizations/", + "dependencies": [ + "lucide-react" ], - "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "use-step-up", - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-danger-card/account-danger-card.tsx", - "target": "src/blocks/auth/account-danger-card/account-danger-card.tsx", + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-contracts.ts", + "target": "src/blocks/feature-packs/organizations/organizations-contracts.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-access-panels.tsx", + "target": "src/blocks/feature-packs/organizations/organizations-access-panels.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-operation-panels.tsx", + "target": "src/blocks/feature-packs/organizations/organizations-operation-panels.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-danger-card/messages.ts", - "target": "src/blocks/auth/account-danger-card/messages.ts", + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-feature-pack.tsx", + "target": "src/blocks/feature-packs/organizations/organizations-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-danger-card/auth-account-danger-card.requires.json", - "target": "~/.constructive/blocks/auth-account-danger-card.requires.json", + "path": "registry/constructive/blocks/feature-packs/organizations/feature-pack.json", + "target": "~/.constructive/feature-packs/organizations.json", "type": "registry:file" } ] }, { - "name": "auth-account-deletion-confirm-page", + "name": "console-module-organizations", "type": "registry:block", - "title": "auth-account-deletion-confirm-page", - "description": "Next.js page that handles /auth/delete-account?token=...&user_id=... email links. Calls confirmDeleteAccount on mount and renders three outcome states: processing (spinner), success (redirect after 2s), expired/invalid/error (inline CTA). Data path: useConfirmDeleteAccountMutation from @/generated/auth.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Organizations Console Module", + "description": "Console Kit discovery, metadata resolution, and Constructive adapter for the Organizations feature pack.", + "docs": "`console-module-organizations` installed Console Kit core, the provider-neutral organizations view, and its Constructive integration. Import from `@/blocks/feature-packs/organizations/organizations-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: Incomplete metadata or ambiguous organization and principal scope fails closed; every mutation remains policy and RLS constrained.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/organizations/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "cn" + "console-kit-core", + "feature-pack-organizations" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page.tsx", - "target": "src/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page.tsx", + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-console-module.tsx", + "target": "src/blocks/feature-packs/organizations/organizations-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-deletion-confirm-page/messages.ts", - "target": "src/blocks/auth/account-deletion-confirm-page/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/feature-packs/organizations/organizations-meta-contract.ts", + "target": "src/blocks/feature-packs/organizations/organizations-meta-contract.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/account-deletion-confirm-page/auth-account-deletion-confirm-page.requires.json", - "target": "~/.constructive/blocks/auth-account-deletion-confirm-page.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/console-kit/constructive/organizations-adapter.ts", + "target": "src/blocks/console-kit/constructive/organizations-adapter.ts", + "type": "registry:lib" } ] }, { - "name": "auth-api-key-create-dialog", + "name": "feature-pack-storage", "type": "registry:block", - "title": "API Key Create Dialog", - "description": "Modal dialog form for creating a user-scoped API key, with high-severity step-up gating and one-time key handoff to auth-api-key-created-modal on success.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Storage Feature Pack", + "description": "Bucket, folder, object, upload, download, and deletion surfaces backed by injected storage actions.", + "docs": "`feature-pack-storage` installed a provider-neutral view and `.constructive/feature-packs/storage.json`. Import from `@/blocks/feature-packs/storage/storage-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-storage` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: An object endpoint alone does not prove bucket or file operations; incomplete and read-only contracts keep write controls hidden.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/storage/", "dependencies": [ - "@tanstack/react-form" + "lucide-react" ], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "use-step-up", - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/auth/api-key-create-dialog/api-key-create-dialog.tsx", - "target": "src/blocks/auth/api-key-create-dialog/api-key-create-dialog.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/api-key-create-dialog/messages.ts", - "target": "src/blocks/auth/api-key-create-dialog/messages.ts", + "path": "registry/constructive/blocks/feature-packs/storage/storage-feature-pack.tsx", + "target": "src/blocks/feature-packs/storage/storage-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/api-key-create-dialog/auth-api-key-create-dialog.requires.json", - "target": "~/.constructive/blocks/auth-api-key-create-dialog.requires.json", + "path": "registry/constructive/blocks/feature-packs/storage/feature-pack.json", + "target": "~/.constructive/feature-packs/storage.json", "type": "registry:file" } ] }, { - "name": "auth-account-security-card", + "name": "console-module-storage", "type": "registry:block", - "title": "auth-account-security-card", - "description": "At-a-glance security posture summary card: password status, TOTP MFA status, and passkey count. Display-only with action callbacks. Supports query adapter for non-Constructive backends.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Storage Console Module", + "description": "Console Kit discovery, metadata resolution, adapter, and modular state slice for the Storage feature pack.", + "docs": "`console-module-storage` installed Console Kit core, the provider-neutral storage view, and its Constructive integration. Import from `@/blocks/feature-packs/storage/storage-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: The objects endpoint alone is insufficient; incomplete contracts stay unavailable and read-only contracts hide write controls.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/storage/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "cn" + "console-kit-core", + "feature-pack-storage" ], "files": [ { - "path": "registry/constructive/blocks/auth/account-security-card/account-security-card.tsx", - "target": "src/blocks/auth/account-security-card/account-security-card.tsx", + "path": "registry/constructive/blocks/feature-packs/storage/storage-console-module.tsx", + "target": "src/blocks/feature-packs/storage/storage-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/account-security-card/messages.ts", - "target": "src/blocks/auth/account-security-card/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/console-kit/constructive/storage-adapter.ts", + "target": "src/blocks/console-kit/constructive/storage-adapter.ts", + "type": "registry:lib" }, { - "path": "registry/constructive/blocks/auth/account-security-card/auth-account-security-card.requires.json", - "target": "~/.constructive/blocks/auth-account-security-card.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/feature-packs/storage/storage-console-slice.ts", + "target": "src/blocks/feature-packs/storage/storage-console-slice.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/feature-packs/storage/storage-meta-contract.ts", + "target": "src/blocks/feature-packs/storage/storage-meta-contract.ts", + "type": "registry:lib" } ] }, { - "name": "shell-sidebar", + "name": "feature-pack-billing", "type": "registry:block", - "title": "ShellSidebar", - "description": "Collapsible application sidebar — primary nav rail with icon-only collapsed mode, localStorage persistence, keyboard shortcut toggle (Mod+B), permission-gated nav items, sub-item expansion, and render-prop slots for context-switcher and account-menu.", - "categories": [ - "blocks", - "shell" - ], + "title": "Billing Feature Pack", + "description": "The feature-pack root for Constructive billing plans, subscriptions, usage, entitlements, credits, and activity.", + "docs": "`feature-pack-billing` installed a provider-neutral view and `.constructive/feature-packs/billing.json`. Import from `@/blocks/feature-packs/billing/billing-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-billing` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: Missing resources remain independent degraded states, and unavailable provider callbacks stay absent.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/billing/", "dependencies": [], - "registryDependencies": [ - "cn" - ], + "registryDependencies": [], "files": [ { - "path": "registry/constructive/blocks/shell/sidebar/sidebar.tsx", - "target": "src/blocks/shell/sidebar/sidebar.tsx", + "path": "registry/constructive/blocks/feature-packs/billing/billing-feature-pack.tsx", + "target": "src/blocks/feature-packs/billing/billing-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/sidebar/messages.ts", - "target": "src/blocks/shell/sidebar/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/feature-packs/billing/feature-pack.json", + "target": "~/.constructive/feature-packs/billing.json", + "type": "registry:file" } ] }, { - "name": "shell-header", + "name": "console-module-billing", "type": "registry:block", - "title": "shell-header", - "description": "Top application bar providing logo slot, breadcrumbs slot, optional search input, command palette trigger (Cmd+K), account menu slot, and sidebar hamburger toggle (mobile only). Pure layout block — no data fetching.", - "categories": [ - "blocks", - "shell" - ], + "title": "Billing Console Module", + "description": "Console Kit navigation, capability discovery, and Constructive adapter for the Billing feature pack.", + "docs": "`console-module-billing` installed Console Kit core, the provider-neutral billing view, and its Constructive integration. Import from `@/blocks/feature-packs/billing/billing-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: Missing plans or subscriptions keeps Billing unavailable, missing optional meters produces a partial view, and provider writes require an explicit adapter action.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/billing/", "dependencies": [], "registryDependencies": [ - "cn" + "console-kit-core", + "feature-pack-billing" ], "files": [ { - "path": "registry/constructive/blocks/shell/header/header.tsx", - "target": "src/blocks/shell/header/header.tsx", + "path": "registry/constructive/blocks/feature-packs/billing/billing-console-module.tsx", + "target": "src/blocks/feature-packs/billing/billing-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/header/messages.ts", - "target": "src/blocks/shell/header/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/console-kit/constructive/billing-adapter.ts", + "target": "src/blocks/console-kit/constructive/billing-adapter.ts", + "type": "registry:lib" } ] }, { - "name": "shell-breadcrumbs", + "name": "feature-pack-notifications", "type": "registry:block", - "title": "Shell Breadcrumbs", - "description": "Route-based breadcrumb trail for the app shell. Parses usePathname() and maps path segments to human-readable labels via a consumer-provided resolveLabel function, with optional explicit segments override and ellipsis collapsing.", - "categories": [ - "blocks", - "shell" + "title": "Notifications Feature Pack", + "description": "An application notification inbox with read, open, and deletion actions.", + "docs": "`feature-pack-notifications` installed a provider-neutral view and `.constructive/feature-packs/notifications.json`. Import from `@/blocks/feature-packs/notifications/notifications-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-notifications` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: The host must provide an authoritative inbox, policy, and callbacks; absent operations stay unavailable and mutations require a refreshed resource.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/notifications/", + "dependencies": [ + "lucide-react" ], - "dependencies": [], "registryDependencies": [ - "cn" + "pack-foundation" ], "files": [ { - "path": "registry/constructive/blocks/shell/breadcrumbs/breadcrumbs.tsx", - "target": "src/blocks/shell/breadcrumbs/breadcrumbs.tsx", + "path": "registry/constructive/blocks/feature-packs/notifications/notifications-feature-pack.tsx", + "target": "src/blocks/feature-packs/notifications/notifications-feature-pack.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/breadcrumbs/messages.ts", - "target": "src/blocks/shell/breadcrumbs/messages.ts", - "type": "registry:component" + "path": "registry/constructive/blocks/feature-packs/notifications/feature-pack.json", + "target": "~/.constructive/feature-packs/notifications.json", + "type": "registry:file" } ] }, { - "name": "shell-command-palette", + "name": "console-module-notifications", "type": "registry:block", - "title": "Shell Command Palette", - "description": "Auth-aware Cmd+K command palette with provider-based command registration, built-in commands from useCurrentUserQuery, and async-safe onError routing.", - "categories": [ - "blocks", - "shell" - ], + "title": "Notifications Console Module", + "description": "Console Kit navigation, capability discovery, and Constructive adapter for the Notifications feature pack.", + "docs": "`console-module-notifications` installed Console Kit core, the provider-neutral notifications view, and its Constructive integration. Import from `@/blocks/feature-packs/notifications/notifications-console-module` and compose the exported module with `ConstructiveConsoleKitCore`; the install inspector reports the exact import targets and transitive closure.\n\nDegraded states: A backend module record without a reachable public inbox is not capability evidence, and realtime remains unavailable until the complete flow is proven.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/notifications/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "cn" + "console-kit-core", + "feature-pack-notifications" ], "files": [ { - "path": "registry/constructive/blocks/shell/command-palette/command-palette.tsx", - "target": "src/blocks/shell/command-palette/command-palette.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/shell/command-palette/shell-command-palette-provider.tsx", - "target": "src/blocks/shell/command-palette/shell-command-palette-provider.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/shell/command-palette/use-command-palette.ts", - "target": "src/blocks/shell/command-palette/use-command-palette.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/shell/command-palette/messages.ts", - "target": "src/blocks/shell/command-palette/messages.ts", + "path": "registry/constructive/blocks/feature-packs/notifications/notifications-console-module.tsx", + "target": "src/blocks/feature-packs/notifications/notifications-console-module.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/command-palette/shell-command-palette.requires.json", - "target": "~/.constructive/blocks/shell-command-palette.requires.json", - "type": "registry:file" + "path": "registry/constructive/blocks/console-kit/constructive/notifications-adapter.ts", + "target": "src/blocks/console-kit/constructive/notifications-adapter.ts", + "type": "registry:lib" } ] }, { - "name": "shell-notifications", + "name": "preset-auth-hardened", "type": "registry:block", - "title": "shell-notifications", - "description": "Notification center popover block (bell icon + panel) for shell-header. Backend-pending data block with graceful degradation. Ships requires.json, use-notifications utility hook, and pure-layout component.", - "categories": [ - "blocks", - "shell" - ], + "title": "Hardened Authentication Preset", + "description": "Stable Constructive auth-hardened backend preset mapped to its supported Console Kit modules.", + "docs": "`preset-auth-hardened` installed the stable `auth:hardened` composition: Data, Authentication, and App access. Render `AuthHardenedConsoleKit` with a secret-free tenant descriptor.\n\nDegraded states: each public endpoint and module fails closed independently, and PostgreSQL privileges and RLS remain authoritative.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [], "registryDependencies": [ - "cn" + "console-kit-core", + "console-module-data", + "console-module-auth", + "console-module-users" ], "files": [ { - "path": "registry/constructive/blocks/shell/notifications/notifications.tsx", - "target": "src/blocks/shell/notifications/notifications.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/shell/notifications/messages.ts", - "target": "src/blocks/shell/notifications/messages.ts", + "path": "registry/constructive/blocks/presets/auth-hardened-console-kit.tsx", + "target": "src/blocks/presets/auth-hardened-console-kit.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/notifications/hooks/use-notifications.ts", - "target": "src/blocks/shell/notifications/hooks/use-notifications.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/shell/notifications/shell-notifications.requires.json", - "target": "~/.constructive/blocks/shell-notifications.requires.json", + "path": "registry/constructive/blocks/presets/auth-hardened.json", + "target": "~/.constructive/feature-packs/auth-hardened.json", "type": "registry:file" } ] }, { - "name": "shell-account-menu", + "name": "preset-b2b-storage", "type": "registry:block", - "title": "Shell Account Menu", - "description": "Dropdown showing current user avatar + display name as trigger, with Account settings link and Sign out action (delegated to auth-sign-out-button). Shell-block exception: navigates to signOutRedirectHref after sign-out.", - "categories": [ - "blocks", - "shell" - ], + "title": "B2B with Storage Preset", + "description": "Stable Constructive b2b-storage backend preset mapped to its supported Console Kit modules.", + "docs": "`preset-b2b-storage` installed the stable `b2b:storage` composition: Data, Authentication, App access, Organizations, and Storage. Render `B2BStorageConsoleKit` with a secret-free tenant descriptor.\n\nDegraded states: stock Storage can remain unavailable when no supported public bucket and file contract is routed; other proven modules continue independently.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-sign-out-button", - "user-avatar", - "cn" + "console-kit-core", + "console-module-data", + "console-module-auth", + "console-module-users", + "console-module-organizations", + "console-module-storage" ], "files": [ { - "path": "registry/constructive/blocks/shell/account-menu/account-menu.tsx", - "target": "src/blocks/shell/account-menu/account-menu.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/shell/account-menu/messages.ts", - "target": "src/blocks/shell/account-menu/messages.ts", + "path": "registry/constructive/blocks/presets/b2b-storage-console-kit.tsx", + "target": "src/blocks/presets/b2b-storage-console-kit.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/shell/account-menu/shell-account-menu.requires.json", - "target": "~/.constructive/blocks/shell-account-menu.requires.json", + "path": "registry/constructive/blocks/presets/b2b-storage.json", + "target": "~/.constructive/feature-packs/b2b-storage.json", "type": "registry:file" } ] }, { - "name": "auth-social-buttons", + "name": "preset-full", "type": "registry:block", - "title": "auth-social-buttons", - "description": "Row of OAuth provider sign-in / sign-up buttons with DB query or static provider list, loading skeletons, error handling, and host callback seams.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Full Preset", + "description": "Stable Constructive full backend preset mapped to its supported Console Kit modules.", + "docs": "`preset-full` installed all seven Console modules and the stable Full preset component. Render `FullConsoleKit` with a secret-free tenant descriptor.\n\nDegraded states: installed backend modules do not imply public routes or user authority; Storage, Billing, or Notifications can remain partial or unavailable while other modules continue.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "cn" + "console-kit-core", + "console-module-data", + "console-module-auth", + "console-module-users", + "console-module-organizations", + "console-module-storage", + "console-module-billing", + "console-module-notifications" ], "files": [ { - "path": "registry/constructive/blocks/auth/social-buttons/social-buttons.tsx", - "target": "src/blocks/auth/social-buttons/social-buttons.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/social-buttons/messages.ts", - "target": "src/blocks/auth/social-buttons/messages.ts", + "path": "registry/constructive/blocks/presets/full-console-kit.tsx", + "target": "src/blocks/presets/full-console-kit.tsx", "type": "registry:component" }, { - "path": "registry/constructive/blocks/auth/social-buttons/auth-social-buttons.requires.json", - "target": "~/.constructive/blocks/auth-social-buttons.requires.json", + "path": "registry/constructive/blocks/presets/full.json", + "target": "~/.constructive/feature-packs/full.json", "type": "registry:file" } ] }, { - "name": "auth-cross-origin-link", + "name": "console-kit-nextjs", "type": "registry:block", - "title": "Cross-Origin Link", - "description": "Generates a one-time cross-origin auth token via `requestCrossOriginToken` and navigates to the destination origin with the token appended as `?token=`.", - "categories": [ - "blocks", - "authentication" - ], + "title": "Constructive Console Kit for Next.js", + "description": "A full-page Next.js tenant console with semantic routing, auth, dynamic endpoint discovery, first-party adapters, and every feature pack.", + "docs": "`console-kit-nextjs` installed the full Next.js tenant console. Render `ConstructiveConsoleKit` with a secret-free database ID and explicit semantic endpoint map; use presets or selected Console modules for smaller compositions.\n\nDegraded states: every endpoint and module fails closed independently, diagnostics are development-only, credentials never enter Zustand, and PostgreSQL privileges and RLS remain authoritative.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [], "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "cn" + "preset-full" ], "files": [ { - "path": "registry/constructive/blocks/auth/cross-origin-link/cross-origin-link.tsx", - "target": "src/blocks/auth/cross-origin-link/cross-origin-link.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/cross-origin-link/messages.ts", - "target": "src/blocks/auth/cross-origin-link/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/cross-origin-link/auth-cross-origin-link.requires.json", - "target": "~/.constructive/blocks/auth-cross-origin-link.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-mfa-totp-challenge", - "type": "registry:block", - "title": "MFA TOTP Challenge", - "description": "6-digit TOTP code input card for MFA challenge step, with device trust checkbox, spec-compliant accessibility attributes, and normalized paste/input handling.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-totp-challenge/mfa-totp-challenge.tsx", - "target": "src/blocks/auth/mfa-totp-challenge/mfa-totp-challenge.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-challenge/messages.ts", - "target": "src/blocks/auth/mfa-totp-challenge/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-challenge/auth-mfa-totp-challenge.requires.json", - "target": "~/.constructive/blocks/auth-mfa-totp-challenge.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-mfa-totp-enroll", - "type": "registry:block", - "title": "auth-mfa-totp-enroll", - "description": "Three-step TOTP enrollment block: QR setup → code verify → backup codes (delegated to [[auth-mfa-backup-codes-display]]). Backend-pending CASE (b): all three procedures undeployed; onSubmit/onConfirm/onGenerateCodes override seams are primary path.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "auth-mfa-backup-codes-display", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-totp-enroll/mfa-totp-enroll.tsx", - "target": "src/blocks/auth/mfa-totp-enroll/mfa-totp-enroll.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-enroll/messages.ts", - "target": "src/blocks/auth/mfa-totp-enroll/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-enroll/auth-mfa-totp-enroll.requires.json", - "target": "~/.constructive/blocks/auth-mfa-totp-enroll.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-mfa-totp-disable-confirm", - "type": "registry:block", - "title": "MFA TOTP Disable Confirm", - "description": "Confirmation dialog for disabling TOTP; requires high-severity step-up before calling disable_totp.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "use-step-up", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-totp-disable-confirm/mfa-totp-disable-confirm.tsx", - "target": "src/blocks/auth/mfa-totp-disable-confirm/mfa-totp-disable-confirm.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-disable-confirm/messages.ts", - "target": "src/blocks/auth/mfa-totp-disable-confirm/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-disable-confirm/auth-mfa-totp-disable-confirm.requires.json", - "target": "~/.constructive/blocks/auth-mfa-totp-disable-confirm.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-mfa-backup-codes-display", - "type": "registry:block", - "title": "MFA Backup Codes Display", - "description": "One-time display card for MFA backup codes — 2-column code grid, copy-all, download-as-txt, and an optional confirmation checkbox gate before the caller can proceed.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display.tsx", - "target": "src/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-backup-codes-display/messages.ts", - "target": "src/blocks/auth/mfa-backup-codes-display/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "auth-account-api-keys-list", - "type": "registry:block", - "title": "Account API Keys List", - "description": "Displays user API keys with revoke action and create-key composition; list rows supplied by the host adapter (no generated list hook — user_api_keys is private schema, no public Connection type).", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "auth-api-key-create-dialog", - "auth-api-key-created-modal", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/account-api-keys-list/account-api-keys-list.tsx", - "target": "src/blocks/auth/account-api-keys-list/account-api-keys-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-api-keys-list/messages.ts", - "target": "src/blocks/auth/account-api-keys-list/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-api-keys-list/auth-account-api-keys-list.requires.json", - "target": "~/.constructive/blocks/auth-account-api-keys-list.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-social-providers-grid", - "type": "registry:block", - "title": "auth-social-providers-grid", - "description": "Grid layout for OAuth providers with Last used badge, mode-aware labels, and localStorage persistence.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-social-buttons", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/social-providers-grid/social-providers-grid.tsx", - "target": "src/blocks/auth/social-providers-grid/social-providers-grid.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/social-providers-grid/messages.ts", - "target": "src/blocks/auth/social-providers-grid/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "auth-mfa-totp-challenge-page", - "type": "registry:block", - "title": "auth-mfa-totp-challenge-page", - "description": "Next.js page mounted at /auth/mfa/totp. Reads ?token= and ?redirect= from useSearchParams, mounts MfaTotpChallenge, and routes on success. Shows error cards for missing-token and expired states.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-mfa-totp-challenge", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-totp-challenge-page/mfa-totp-challenge-page.tsx", - "target": "src/blocks/auth/mfa-totp-challenge-page/mfa-totp-challenge-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-totp-challenge-page/messages.ts", - "target": "src/blocks/auth/mfa-totp-challenge-page/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "auth-mfa-backup-codes-regenerate", - "type": "registry:block", - "title": "MFA Backup Codes Regenerate", - "description": "Confirmation dialog that gates behind high-tier step-up, calls generate_backup_codes(), then hands off to auth-mfa-backup-codes-display for code presentation and confirmation.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "use-step-up", - "auth-mfa-backup-codes-display", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/mfa-backup-codes-regenerate/mfa-backup-codes-regenerate.tsx", - "target": "src/blocks/auth/mfa-backup-codes-regenerate/mfa-backup-codes-regenerate.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-backup-codes-regenerate/messages.ts", - "target": "src/blocks/auth/mfa-backup-codes-regenerate/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/mfa-backup-codes-regenerate/auth-mfa-backup-codes-regenerate.requires.json", - "target": "~/.constructive/blocks/auth-mfa-backup-codes-regenerate.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-anonymous-sign-in-button", - "type": "registry:block", - "title": "Anonymous Sign-In Button", - "description": "Single-click guest session button that creates an anonymous session without credentials — for try-before-you-register flows.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/anonymous-sign-in-button/anonymous-sign-in-button.tsx", - "target": "src/blocks/auth/anonymous-sign-in-button/anonymous-sign-in-button.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/anonymous-sign-in-button/messages.ts", - "target": "src/blocks/auth/anonymous-sign-in-button/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/anonymous-sign-in-button/auth-anonymous-sign-in-button.requires.json", - "target": "~/.constructive/blocks/auth-anonymous-sign-in-button.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-magic-link-request-card", - "type": "registry:block", - "title": "Magic Link Request Card", - "description": "Email-only form that initiates the magic-link sign-in flow; transitions to a neutral confirmation state within the card on success (no navigation, no account enumeration).", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-errors", - "auth-schemas", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/magic-link-request-card/magic-link-request-card.tsx", - "target": "src/blocks/auth/magic-link-request-card/magic-link-request-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-request-card/messages.ts", - "target": "src/blocks/auth/magic-link-request-card/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-request-card/auth-magic-link-request-card.requires.json", - "target": "~/.constructive/blocks/auth-magic-link-request-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-magic-link-sent-page", - "type": "registry:block", - "title": "Magic Link Sent Page", - "description": "Post-submission confirmation page for the magic-link sign-in flow. Displays the target email, a resend CTA with 60-second cooldown, and navigation links back to the request form and sign-in page.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/magic-link-sent-page/magic-link-sent-page.tsx", - "target": "src/blocks/auth/magic-link-sent-page/magic-link-sent-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-sent-page/messages.ts", - "target": "src/blocks/auth/magic-link-sent-page/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-sent-page/auth-magic-link-sent-page.requires.json", - "target": "~/.constructive/blocks/auth-magic-link-sent-page.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-magic-link-callback-page", - "type": "registry:block", - "title": "auth-magic-link-callback-page (blockers fixed)", - "description": "Magic-link callback page block with all reviewer blockers and worthwhile minors resolved.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/magic-link-callback-page/magic-link-callback-page.tsx", - "target": "src/blocks/auth/magic-link-callback-page/magic-link-callback-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-callback-page/messages.ts", - "target": "src/blocks/auth/magic-link-callback-page/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/magic-link-callback-page/auth-magic-link-callback-page.requires.json", - "target": "~/.constructive/blocks/auth-magic-link-callback-page.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-email-otp-request-card", - "type": "registry:block", - "title": "auth-email-otp-request-card", - "description": "Email-only form that sends a one-time passcode to the user's email. On success, transitions to a code-sent state — by default renders [[auth-email-otp-input]] inline (showOtpInputInline=true), or shows confirmation + resend with onSuccess for navigation (showOtpInputInline=false). BACKEND-PENDING (CASE b): send_email_otp not yet deployed; onSubmit override is the primary network path.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-errors", - "auth-schemas", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/email-otp-request-card/email-otp-request-card.tsx", - "target": "src/blocks/auth/email-otp-request-card/email-otp-request-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/email-otp-request-card/messages.ts", - "target": "src/blocks/auth/email-otp-request-card/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/email-otp-request-card/auth-email-otp-request-card.requires.json", - "target": "~/.constructive/blocks/auth-email-otp-request-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-email-otp-input", - "type": "registry:block", - "title": "Email OTP Input", - "description": "6-segment OTP code input with countdown timer, resend CTA, and attempt feedback for email OTP sign-in flows.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/email-otp-input/email-otp-input.tsx", - "target": "src/blocks/auth/email-otp-input/email-otp-input.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/email-otp-input/messages.ts", - "target": "src/blocks/auth/email-otp-input/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/email-otp-input/auth-email-otp-input.requires.json", - "target": "~/.constructive/blocks/auth-email-otp-input.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-passkey-enroll", - "type": "registry:block", - "title": "auth-passkey-enroll", - "description": "WebAuthn passkey enrollment block — two-step begin/finish ceremony with injectable override seam.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/passkey-enroll/passkey-enroll.tsx", - "target": "src/blocks/auth/passkey-enroll/passkey-enroll.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/passkey-enroll/messages.ts", - "target": "src/blocks/auth/passkey-enroll/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/passkey-enroll/hooks/use-passkey-enroll.ts", - "target": "src/blocks/auth/passkey-enroll/hooks/use-passkey-enroll.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/auth/passkey-enroll/auth-passkey-enroll.requires.json", - "target": "~/.constructive/blocks/auth-passkey-enroll.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-passkey-sign-in", - "type": "registry:block", - "title": "auth-passkey-sign-in", - "description": "WebAuthn assertion (sign-in) block with button and conditional UI (autofill picker) support. Orchestrates the begin/browser/finish ceremony via the shipped utility hook or a consumer-supplied onSubmit override.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "auth-errors", - "auth-error-alert", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/passkey-sign-in/passkey-sign-in.tsx", - "target": "src/blocks/auth/passkey-sign-in/passkey-sign-in.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/passkey-sign-in/messages.ts", - "target": "src/blocks/auth/passkey-sign-in/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/passkey-sign-in/hooks/use-passkey-sign-in.ts", - "target": "src/blocks/auth/passkey-sign-in/hooks/use-passkey-sign-in.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/auth/passkey-sign-in/auth-passkey-sign-in.requires.json", - "target": "~/.constructive/blocks/auth-passkey-sign-in.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-account-phones-list", - "type": "registry:block", - "title": "auth-account-phones-list", - "description": "Phone number list block for authenticated accounts — add, verify (inline OTP), set-primary, delete.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/account-phones-list/account-phones-list.tsx", - "target": "src/blocks/auth/account-phones-list/account-phones-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-phones-list/messages.ts", - "target": "src/blocks/auth/account-phones-list/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-phones-list/auth-account-phones-list.requires.json", - "target": "~/.constructive/blocks/auth-account-phones-list.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-invitation-acceptance-card", - "type": "registry:block", - "title": "auth-invitation-acceptance-card (blocker fixes)", - "description": "Fixed all three reviewer blockers: B2 (InviteAcceptResult type), B1 (conditional setAccepted guard + pending state), B3 (new tests for result:false / null paths).", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "auth-loading-button", - "user-avatar", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/invitation-acceptance-card/invitation-acceptance-card.tsx", - "target": "src/blocks/auth/invitation-acceptance-card/invitation-acceptance-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/invitation-acceptance-card/messages.ts", - "target": "src/blocks/auth/invitation-acceptance-card/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/invitation-acceptance-card/auth-invitation-acceptance-card.requires.json", - "target": "~/.constructive/blocks/auth-invitation-acceptance-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "user-context-switcher", - "type": "registry:block", - "title": "User Context Switcher", - "description": "Dropdown allowing the signed-in user to switch the active 'acting as' context between their personal account and any orgs they are a member of. Demonstrates Constructive's unified User model.", - "categories": [ - "blocks", - "user" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "user-avatar", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/user/context-switcher/context-switcher.tsx", - "target": "src/blocks/user/context-switcher/context-switcher.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/user/context-switcher/messages.ts", - "target": "src/blocks/user/context-switcher/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/user/context-switcher/hooks/use-switch-context.ts", - "target": "src/blocks/user/context-switcher/hooks/use-switch-context.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/user/context-switcher/hooks/use-user-contexts.ts", - "target": "src/blocks/user/context-switcher/hooks/use-user-contexts.ts", - "type": "registry:hook" - }, - { - "path": "registry/constructive/blocks/user/context-switcher/user-context-switcher.requires.json", - "target": "~/.constructive/blocks/user-context-switcher.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-create-card", - "type": "registry:block", - "title": "Org Create Card", - "description": "Multi-step wizard to create a new organization (users row with type=2). Three steps: display name + slug with debounced availability check, optional logo upload, confirm summary + submit. Binds to useCreateUserMutation and useUsersQuery from @/generated/auth.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/create-card/create-card.tsx", - "target": "src/blocks/org/create-card/create-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/create-card/messages.ts", - "target": "src/blocks/org/create-card/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/create-card/org-create-card.requires.json", - "target": "~/.constructive/blocks/org-create-card.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-members-list", - "type": "registry:block", - "title": "Org Members List", - "description": "Paginated org member list with role chips, inline role-change selector, remove-member (gated by step-up medium/high based on admin status), and transfer-ownership (gated by step-up high). All sensitive actions require step-up before mutation fires.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "use-step-up", - "user-avatar", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/members-list/members-list.tsx", - "target": "src/blocks/org/members-list/members-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/members-list/messages.ts", - "target": "src/blocks/org/members-list/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/members-list/org-members-list.requires.json", - "target": "~/.constructive/blocks/org-members-list.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-invite-dialog", - "type": "registry:block", - "title": "OrgInviteDialog", - "description": "Dialog for inviting members to an org by email with optional role assignment. Shows pending invites list with resend and cancel actions. Resend = cancel + re-create (resendOrgInvite backend procedure pending).", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/invite-dialog/invite-dialog.tsx", - "target": "src/blocks/org/invite-dialog/invite-dialog.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/invite-dialog/messages.ts", - "target": "src/blocks/org/invite-dialog/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/invite-dialog/org-invite-dialog.requires.json", - "target": "~/.constructive/blocks/org-invite-dialog.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-roles-editor", - "type": "registry:block", - "title": "Org Roles Editor", - "description": "Admin card for creating, editing, and deleting named org role profiles backed by the host's generated admin SDK hooks.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/roles-editor/roles-editor.tsx", - "target": "src/blocks/org/roles-editor/roles-editor.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/roles-editor/messages.ts", - "target": "src/blocks/org/roles-editor/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/roles-editor/org-roles-editor.requires.json", - "target": "~/.constructive/blocks/org-roles-editor.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-settings-form", - "type": "registry:block", - "title": "org-settings-form", - "description": "Org settings form with display name, URL slug, and danger-zone deletion (step-up gated).", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "form-field", - "auth-loading-button", - "use-step-up", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/settings-form/settings-form.tsx", - "target": "src/blocks/org/settings-form/settings-form.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/settings-form/messages.ts", - "target": "src/blocks/org/settings-form/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/settings-form/org-settings-form.requires.json", - "target": "~/.constructive/blocks/org-settings-form.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "org-app-memberships", - "type": "registry:block", - "title": "OrgAppMemberships", - "description": "Admin block for managing an org's app-level memberships: approve, revoke (with step-up), and profile-update via a Select dropdown. Uses three generated admin SDK hooks (query + two mutations). Revoke is gated behind a confirmation dialog and step-up at tier 'medium'.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-errors", - "auth-error-alert", - "use-step-up", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/app-memberships/app-memberships.tsx", - "target": "src/blocks/org/app-memberships/app-memberships.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/app-memberships/messages.ts", - "target": "src/blocks/org/app-memberships/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/app-memberships/org-app-memberships.requires.json", - "target": "~/.constructive/blocks/org-app-memberships.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-invitation-acceptance-page", - "type": "registry:block", - "title": "auth-invitation-acceptance-page", - "description": "Next.js page wrapper for /invite route. Enforces auth gate via useCurrentUserQuery, redirects unauthenticated users to SIGN_IN_PATH with encoded return URL, shows loading skeleton during auth resolution, renders InvitationAcceptanceCard for signed-in users, handles accept/decline routing.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-invitation-acceptance-card" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/invitation-acceptance-page/invitation-acceptance-page.tsx", - "target": "src/blocks/auth/invitation-acceptance-page/invitation-acceptance-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/invitation-acceptance-page/auth-invitation-acceptance-page.requires.json", - "target": "~/.constructive/blocks/auth-invitation-acceptance-page.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-account-settings-page", - "type": "registry:block", - "title": "auth-account-settings-page", - "description": "Tabbed account settings page composing all account-settings section cards. Calls useCurrentUserQuery at mount, wraps inner content in Suspense per Next.js 15 requirements, and gates the API keys tab behind an allowApiKeys prop.", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "blocks-runtime", - "auth-account-api-keys-list", - "auth-account-connected-accounts", - "auth-account-danger-card", - "auth-account-emails-list", - "auth-account-phones-list", - "auth-account-profile-card", - "auth-account-security-card", - "auth-account-sessions-list", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/account-settings-page/account-settings-page.tsx", - "target": "src/blocks/auth/account-settings-page/account-settings-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-settings-page/messages.ts", - "target": "src/blocks/auth/account-settings-page/messages.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/account-settings-page/auth-account-settings-page.requires.json", - "target": "~/.constructive/blocks/auth-account-settings-page.requires.json", - "type": "registry:file" - } - ] - }, - { - "name": "auth-sso-setup-card", - "type": "registry:block", - "title": "auth-sso-setup-card", - "description": "SSO setup placeholder card (v2 stub) — presentational block with full i18n message catalog. No data binding.", - "categories": [ - "blocks", - "authentication" - ], - "docs": "Renders a card describing OIDC/SAML SSO capability with a coming-soon notice. All user-visible labels are in the SsoSetupCardMessages catalog and overridable via the messages prop.", - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/sso-setup-card/sso-setup-card.tsx", - "target": "src/blocks/auth/sso-setup-card/sso-setup-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/sso-setup-card/messages.ts", - "target": "src/blocks/auth/sso-setup-card/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "auth-sso-sign-in-card", - "type": "registry:block", - "title": "auth-sso-sign-in-card", - "description": "v2 stub: SSO-initiated sign-in card with email-domain lookup surface; backend procedures deferred (no @/generated import, no data binding).", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [ - "@tanstack/react-form" - ], - "registryDependencies": [ - "auth-error-alert", - "form-field", - "auth-loading-button", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/sso-sign-in-card/sso-sign-in-card.tsx", - "target": "src/blocks/auth/sso-sign-in-card/sso-sign-in-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/sso-sign-in-card/messages.ts", - "target": "src/blocks/auth/sso-sign-in-card/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "auth-domain-verification-step", - "type": "registry:block", - "title": "Auth Domain Verification Step", - "description": "DNS TXT-record verification UI stub for SSO domain ownership. Displays the TXT record name and value derived from props, with copy-to-clipboard per field, a manual \"Check now\" trigger, and a status badge. Backend polling is deferred (v2 SSO procedures not yet deployed).", - "categories": [ - "blocks", - "authentication" - ], - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/auth/domain-verification-step/domain-verification-step.tsx", - "target": "src/blocks/auth/domain-verification-step/domain-verification-step.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/auth/domain-verification-step/messages.ts", - "target": "src/blocks/auth/domain-verification-step/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "org-scim-token-generation-card", - "type": "registry:block", - "title": "Org SCIM Token Generation Card", - "description": "Presentational stub for SCIM bearer token generation — deferred until the SCIM backend (generate_scim_token / revoke_scim_token procedures + scim_providers table) ships in constructive-db.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/scim-token-generation-card/scim-token-generation-card.tsx", - "target": "src/blocks/org/scim-token-generation-card/scim-token-generation-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/scim-token-generation-card/messages.ts", - "target": "src/blocks/org/scim-token-generation-card/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "org-scim-connections-list", - "type": "registry:block", - "title": "Org SCIM Connections List", - "description": "Backend-pending presentational stub that renders an informational empty state for SCIM provider connections. Full data block deferred until the scim_providers schema and revoke_scim_token procedure ship.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/scim-connections-list/scim-connections-list.tsx", - "target": "src/blocks/org/scim-connections-list/scim-connections-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/scim-connections-list/messages.ts", - "target": "src/blocks/org/scim-connections-list/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "org-scim-setup-guide", - "type": "registry:block", - "title": "Org SCIM Setup Guide", - "description": "v2 stub: static SCIM 2.0 configuration guide card with provider selector (Okta, Azure AD, JumpCloud, Google Workspace, Generic), copyable endpoint URL, bearer token instructions, and attribute mapping table — no backend binding until scim_providers table ships.", - "categories": [ - "blocks", - "organization" - ], - "dependencies": [], - "registryDependencies": [ - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/org/scim-setup-guide/scim-setup-guide.tsx", - "target": "src/blocks/org/scim-setup-guide/scim-setup-guide.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/org/scim-setup-guide/messages.ts", - "target": "src/blocks/org/scim-setup-guide/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "chat", - "type": "registry:block", - "title": "Chat Widget", - "description": "AI chat widget with page context awareness, settings panel, and floating UI. Built on Vercel AI SDK.", - "categories": [ - "block" - ], - "docs": "## Setup\n\n1. Add the typography plugin to your `globals.css` (the Constructive theme is installed automatically):\n```css\n@plugin \"@tailwindcss/typography\";\n```\n\n2. Add to your root `layout.tsx`:\n```tsx\nimport { ChatProvider, ChatWidget } from '@/components/chat';\nimport { PortalRoot } from '@/components/ui/portal';\n\n\n {children}\n \n\n\n```\n\nIncludes Next.js API routes at `src/app/api/chat/route.ts` and `src/app/api/chat/test/route.ts`. OpenAI-compatible egress defaults to `https://api.openai.com/v1`. To enable a custom or local provider, set an explicit server-side allowlist before startup, for example `CHAT_ALLOWED_PROVIDER_BASE_URLS=http://127.0.0.1:11434/v1,https://llm.example.com/openai`. Entries must be absolute HTTP(S) URLs without credentials, queries, or fragments; localhost resolves from the route server. Add authentication, authorization, and rate limiting around both POST routes before exposing them publicly, and do not log or persist provider keys in plaintext.", - "dependencies": [ - "ai", - "@ai-sdk/react", - "@ai-sdk/anthropic", - "@ai-sdk/openai-compatible", - "marked", - "dompurify", - "motion", - "lucide-react", - "zod", - "@tailwindcss/typography" - ], - "registryDependencies": [ - "button", - "input", - "label", - "select", - "cn" - ], - "files": [ - { - "path": "registry/constructive/blocks/chat/index.ts", - "target": "src/components/chat/index.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat.types.ts", - "target": "src/components/chat/chat.types.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/dom-scraper.ts", - "target": "src/components/chat/dom-scraper.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-context.tsx", - "target": "src/components/chat/chat-context.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-widget.tsx", - "target": "src/components/chat/chat-widget.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-panel.tsx", - "target": "src/components/chat/chat-panel.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-messages.tsx", - "target": "src/components/chat/chat-messages.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-message-content.tsx", - "target": "src/components/chat/chat-message-content.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-input.tsx", - "target": "src/components/chat/chat-input.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-fab.tsx", - "target": "src/components/chat/chat-fab.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/chat-settings.tsx", - "target": "src/components/chat/chat-settings.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/tool-registry.ts", - "target": "src/components/chat/tool-registry.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/tool-message.tsx", - "target": "src/components/chat/tool-message.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/tool-status.tsx", - "target": "src/components/chat/tool-status.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/tool-ui-config.ts", - "target": "src/components/chat/tool-ui-config.ts", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/shimmer.tsx", - "target": "src/components/chat/shimmer.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/chat/api-route.ts", - "target": "src/app/api/chat/route.ts", - "type": "registry:lib" - }, - { - "path": "registry/constructive/blocks/chat/api-test-route.ts", - "target": "src/app/api/chat/test/route.ts", - "type": "registry:lib" - } - ] - }, - { - "name": "billing-contracts", - "type": "registry:lib", - "title": "Billing Contracts", - "description": "Provider-neutral billing DTOs, arbitrary-precision formatters, open-status presentation maps, and explicit resource states shared by every customer billing block.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nInstall this item when building a custom billing composition. It defines the shared resource states, account references, money values, allowances, plans, subscriptions, usage, credits, activity, messages, and formatting options used by the billing blocks.\n\n```ts\nimport type { BillingPlan, BillingResource } from '@/blocks/billing/billing-contracts/billing-contracts';\n\nconst plans: BillingResource = {\n status: 'ready',\n quality: 'authoritative',\n asOf: '2026-07-20T12:00:00.000Z',\n data: [{\n id: 'growth',\n name: 'Growth',\n prices: [{\n kind: 'fixed',\n id: 'growth-monthly',\n interval: 'month',\n money: { amountMinor: '4900', currency: 'USD' }\n }]\n }]\n};\n```\n\nKeep quantities, limits, deltas, and minor-unit money as decimal strings so large values retain their precision. Supply an explicit locale and time zone to the formatters, keep each record's currency, and use the explicit contact-sales, unlimited, uninitialized, and unknown variants when they apply.", - "dependencies": [], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-contracts/billing-contracts.ts", - "target": "src/blocks/billing/billing-contracts/billing-contracts.ts", - "type": "registry:lib" - } - ] - }, - { - "name": "billing-ui", - "type": "registry:lib", - "title": "Billing UI Helpers", - "description": "Shared formatting and presentation helpers used by the customer billing blocks.", - "categories": [ - "blocks", - "billing" - ], - "dependencies": [], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-ui/billing-ui.tsx", - "target": "src/blocks/billing/billing-ui/billing-ui.tsx", + "path": "registry/constructive/blocks/console-kit/constructive/index.ts", + "target": "src/blocks/console-kit/constructive/index.ts", "type": "registry:lib" } ] - }, - { - "name": "billing-pricing-table", - "type": "registry:block", - "title": "Billing Pricing Table", - "description": "Responsive plan comparison with controlled interval selection, fixed and contact-sales pricing, current and featured states, and optional actions.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass a plan resource, account context, formatting options, a controlled billing interval, and the actions you want to show.\n\n```tsx\nimport { BillingPricingTable } from '@/blocks/billing/billing-pricing-table/billing-pricing-table';\n\n openPlanSelection({ planId, priceId, account })}\n onContactSales={({ planId, account }) => openSalesRequest({ planId, account })}\n/>\n```\n\nFixed prices use minor-unit decimal strings and the currency on each record. Use `BillingPrice.kind = 'contact_sales'` for custom pricing. Missing callbacks remove their actions, rejected promises appear next to the action, and resolved promises leave the selected plan state under your control.", - "dependencies": [ - "lucide-react" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", - "target": "src/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-pricing-table/messages.ts", - "target": "src/blocks/billing/billing-pricing-table/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-subscription-card", - "type": "registry:block", - "title": "Billing Subscription Card", - "description": "Current-plan summary with independent subscription, provider, and payment status plus exact lifecycle dates and optional actions.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass the current plan together with any payment and provider states you want to show. Add only the actions that make sense in your billing flow.\n\n```tsx\nimport { BillingSubscriptionCard } from '@/blocks/billing/billing-subscription-card/billing-subscription-card';\n\n openSubscriptionSettings(subscriptionId, account)}\n/>\n```\n\nOnly `renewsAt` is labelled “Renews”; `endsAt` is always labelled “Ends.” Status strings are open, unknown values render neutrally, and missing callbacks hide their controls.", - "dependencies": [ - "lucide-react" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", - "target": "src/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-subscription-card/messages.ts", - "target": "src/blocks/billing/billing-subscription-card/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-entitlements-list", - "type": "registry:block", - "title": "Billing Entitlements List", - "description": "Structured display of plan feature flags, caps, discrete quotas, meter allowances, and neutral unknown values.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass each entitlement with a semantic kind so features, caps, quotas, and metered allowances keep their distinct meaning.\n\n```tsx\nimport { BillingEntitlementsList } from '@/blocks/billing/billing-entitlements-list/billing-entitlements-list';\n\n\n```\n\nUse explicit `unlimited`, `uninitialized`, and `unknown` variants. Unknown values stay visible with a neutral presentation, and message overrides let you adapt the labels without changing the data model.", - "dependencies": [ - "lucide-react" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", - "target": "src/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-entitlements-list/messages.ts", - "target": "src/blocks/billing/billing-entitlements-list/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-usage-overview", - "type": "registry:block", - "title": "Billing Usage Overview", - "description": "Current-period usage with quota, boolean, unlimited, uninitialized, exhausted, overage, category-pool, and universal-pool states.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass a current-period usage snapshot and nest child meters beneath category or universal pools when credits are shared.\n\n```tsx\nimport { BillingUsageOverview } from '@/blocks/billing/billing-usage-overview/billing-usage-overview';\n\n openUsageHistory(meterSlug)}\n onBuyCredits={(meterSlug) => openCreditPurchase(meterSlug)}\n/>\n```\n\nCategory and universal pools stay nested so their application order remains clear. Progress caps visually at 100 percent while text and accessible values preserve the actual used, allowance, and overage values. Actions disappear when their callbacks are absent.", - "dependencies": [ - "lucide-react", - "motion" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", - "target": "src/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-usage-overview/messages.ts", - "target": "src/blocks/billing/billing-usage-overview/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-credits-card", - "type": "registry:block", - "title": "Billing Credits Card", - "description": "Available credits grouped by compatible meter and unit with permanent, period, rollover, expiring, and unknown lot semantics.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass available balances and their credit lots while keeping each meter and unit independent.\n\n```tsx\nimport { BillingCreditsCard } from '@/blocks/billing/billing-credits-card/billing-credits-card';\n\n\n```\n\nKeep incompatible meters and units in separate balance groups. Credit lots show the current grant makeup, while `billing-activity-table` provides a chronological view of grants, usage, adjustments, rollover, and expiration.", - "dependencies": [ - "lucide-react", - "motion" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-credits-card/billing-credits-card.tsx", - "target": "src/blocks/billing/billing-credits-card/billing-credits-card.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-credits-card/messages.ts", - "target": "src/blocks/billing/billing-credits-card/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-usage-history", - "type": "registry:block", - "title": "Billing Usage History", - "description": "Accessible, table-first period history with controlled meter, period, and pagination filters plus explicit quality labels.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass a page of usage periods and control the meter, period, and pagination values when you provide their callbacks.\n\n```tsx\nimport { BillingUsageHistory } from '@/blocks/billing/billing-usage-history/billing-usage-history';\n\n\n```\n\nCredits and overage columns appear only when their values are marked as confirmed. Quality labels remain visible on each period, and the table keeps pagination and filter changes under your control.", - "dependencies": [ - "lucide-react" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-usage-history/billing-usage-history.tsx", - "target": "src/blocks/billing/billing-usage-history/billing-usage-history.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-usage-history/messages.ts", - "target": "src/blocks/billing/billing-usage-history/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-activity-table", - "type": "registry:block", - "title": "Billing Activity Table", - "description": "Paginated ledger activity with controlled filters, semantic class/type presentation, and a titled metadata detail sheet.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass ledger class and entry type with each activity entry so labels and badge tones describe the event itself.\n\n```tsx\nimport { BillingActivityTable } from '@/blocks/billing/billing-activity-table/billing-activity-table';\n\n\n```\n\nA positive delta can represent consumption and a negative delta can represent release or reset, so presentation derives from `ledgerClass` and `entryType` rather than the sign. Unknown values remain neutral, and JSON-safe metadata opens in a titled detail sheet.", - "dependencies": [], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-activity-table/billing-activity-table.tsx", - "target": "src/blocks/billing/billing-activity-table/billing-activity-table.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-activity-table/messages.ts", - "target": "src/blocks/billing/billing-activity-table/messages.ts", - "type": "registry:component" - } - ] - }, - { - "name": "billing-settings-page", - "type": "registry:block", - "title": "Billing Settings Page", - "description": "Router-neutral Overview, Usage, and Plans composition that keeps subscription, usage, credits, entitlements, history, activity, and pricing failures isolated.", - "categories": [ - "blocks", - "billing" - ], - "docs": "## Usage\n\nPass one account together with independent resources and actions. Use `section` and `onSectionChange` to synchronize navigation, or `defaultSection` for local state. The page renders its own heading by default; set `showHeader={false}` when the surrounding document already provides it.\n\n```tsx\nimport { BillingSettingsPage } from '@/blocks/billing/billing-settings-page/billing-settings-page';\n\n\n```\n\nEach resource renders its own loading, empty, error, ready, and quality state, so one unavailable section never replaces the whole page. Controlled filters and actions are forwarded to their matching leaf blocks.", - "dependencies": [ - "lucide-react" - ], - "registryDependencies": [], - "files": [ - { - "path": "registry/constructive/blocks/billing/billing-settings-page/billing-settings-page.tsx", - "target": "src/blocks/billing/billing-settings-page/billing-settings-page.tsx", - "type": "registry:component" - }, - { - "path": "registry/constructive/blocks/billing/billing-settings-page/messages.ts", - "target": "src/blocks/billing/billing-settings-page/messages.ts", - "type": "registry:component" - } - ] } ] } diff --git a/apps/blocks/scripts/check-docs.ts b/apps/blocks/scripts/check-docs.ts index 348fb63..d685465 100644 --- a/apps/blocks/scripts/check-docs.ts +++ b/apps/blocks/scripts/check-docs.ts @@ -5,13 +5,11 @@ import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { PRIMITIVE_DOCS } from '../src/content/ui'; +import { FEATURE_PACK_IDS, FEATURE_PACK_MANIFESTS } from '../src/feature-packs'; import { UI_DEMO_SOURCE } from '../src/generated/ui-demo-source'; -import { - BASE_PRIMITIVES, - packageImport, - type BasePrimitiveName, -} from '../src/lib/base-primitives'; +import { BASE_PRIMITIVES, packageImport, type BasePrimitiveName } from '../src/lib/base-primitives'; import { packageCommands, registryCommands } from '../src/lib/install-mode'; +import { FEATURE_PACK_DOCS } from '../src/lib/feature-packs'; import { PRIMITIVE_DOC_SECTION_ORDER, type PrimitiveApiPart } from '../src/lib/primitive-docs'; type PackageManifest = { @@ -20,7 +18,7 @@ type PackageManifest = { }; type RegistryManifest = { - items?: Array<{ name?: string }>; + items?: Array<{ name?: string; docs?: string }>; }; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); @@ -33,6 +31,10 @@ function readJson(file: string): T { return JSON.parse(fs.readFileSync(file, 'utf8')) as T; } +function isEmpty(values: readonly unknown[]): boolean { + return values.length === 0; +} + function collectFiles(directory: string): string[] { if (!fs.existsSync(directory)) return []; @@ -52,9 +54,7 @@ function hasExportModifier(node: ts.Node): boolean { function bindingNames(name: ts.BindingName): string[] { if (ts.isIdentifier(name)) return [name.text]; - return name.elements.flatMap((element) => - ts.isOmittedExpression(element) ? [] : bindingNames(element.name), - ); + return name.elements.flatMap((element) => (ts.isOmittedExpression(element) ? [] : bindingNames(element.name))); } function runtimeExports(sourceFile: ts.SourceFile): string[] { @@ -75,9 +75,7 @@ function runtimeExports(sourceFile: ts.SourceFile): string[] { if (!hasExportModifier(statement)) continue; if ( - (ts.isFunctionDeclaration(statement) || - ts.isClassDeclaration(statement) || - ts.isEnumDeclaration(statement)) && + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name ) { exports.add(statement.name.text); @@ -211,13 +209,12 @@ for (const primitive of BASE_PRIMITIVES) { errors.push(`${primitive.name}: expected one to six focused component-specific examples`); } if (docs.accessibility.length === 0) errors.push(`${primitive.name}: missing accessibility guidance`); - if (docs.stateModel === 'stateless' && docs.state) errors.push(`${primitive.name}: stateless docs cannot render state guidance`); - if (docs.stateModel !== 'stateless' && !docs.state) errors.push(`${primitive.name}: stateful docs require state guidance`); + if (docs.stateModel === 'stateless' && docs.state) + errors.push(`${primitive.name}: stateless docs cannot render state guidance`); + if (docs.stateModel !== 'stateless' && !docs.state) + errors.push(`${primitive.name}: stateful docs require state guidance`); - const generated = UI_DEMO_SOURCE[primitive.name] as Record< - string, - { npm: string; registry: string } - >; + const generated = UI_DEMO_SOURCE[primitive.name] as Record; const demoReferences = [ 'BlockDemo', docs.usage.demo, @@ -233,7 +230,10 @@ for (const primitive of BASE_PRIMITIVES) { errors.push(`${primitive.name}: missing generated source for ${demo}`); continue; } - if (source.npm.includes('@/components/docs/showcase-kit') || source.registry.includes('@/components/docs/showcase-kit')) { + if ( + source.npm.includes('@/components/docs/showcase-kit') || + source.registry.includes('@/components/docs/showcase-kit') + ) { errors.push(`${primitive.name}:${demo}: consumer source must omit the docs-only Demo wrapper`); } if (!source.npm.includes(`@constructive-io/ui/${primitive.name}`)) { @@ -279,6 +279,76 @@ for (const primitive of BASE_PRIMITIVES) { } } +const documentedFeaturePackIds = FEATURE_PACK_DOCS.map(({ id }) => id); +if (JSON.stringify(documentedFeaturePackIds) !== JSON.stringify(FEATURE_PACK_IDS)) { + errors.push('Feature-pack docs must match the seven canonical ids in dependency order'); +} + +const featurePackRegistry = readJson(path.join(appDirectory, 'registry.json')); +const featurePackRegistryItems = new Map( + (featurePackRegistry.items ?? []).flatMap((item) => (item.name ? [[item.name, item] as const] : [])), +); + +for (const relativePath of [ + path.join('src', 'app', 'blocks', 'features', '[pack]', 'page.tsx'), + path.join('src', 'app', 'blocks', 'features', '[pack]', 'preview', 'page.tsx'), +]) { + if (!fs.existsSync(path.join(appDirectory, relativePath))) { + errors.push(`Missing feature-pack route ${relativePath}`); + } +} + +for (const block of FEATURE_PACK_DOCS) { + const manifest = FEATURE_PACK_MANIFESTS.find(({ id }) => id === block.id); + if (!manifest) { + errors.push(`${block.id}: missing canonical feature-pack manifest`); + continue; + } + + const expectedEndpoints = [ + ...manifest.endpoints.required, + ...manifest.endpoints.optional.map((endpoint) => `optional ${endpoint}`), + ].join(', '); + if (block.registryName !== `feature-pack-${block.id}`) { + errors.push(`${block.id}: registry name must be feature-pack-${block.id}`); + } + if (JSON.stringify(block.dependencies) !== JSON.stringify(manifest.dependencies)) { + errors.push(`${block.id}: documented dependencies drifted from its manifest`); + } + if (block.endpoints !== expectedEndpoints) { + errors.push(`${block.id}: documented endpoints drifted from its manifest`); + } + if ( + block.whenToUse.length < 2 || + isEmpty(block.surfaces) || + isEmpty(block.accessibility) || + isEmpty(block.api) || + !block.state.description || + !block.state.actionGuidance || + !block.usage.description + ) { + errors.push(`${block.id}: feature-pack docs are missing required editorial coverage`); + } + + const apiLabels = block.api.flatMap(({ name }) => name.split(' / ').map((prop) => prop.trim())); + const documentedApiProps = [...block.apiProps]; + if ( + new Set(apiLabels).size !== apiLabels.length || + JSON.stringify([...apiLabels].sort()) !== JSON.stringify([...documentedApiProps].sort()) + ) { + errors.push(`${block.id}: API labels must exactly match its typed public property catalog`); + } + + const expectedImport = `@/blocks/feature-packs/${block.id}/${block.id}-feature-pack`; + if (!block.usage.example.includes(block.exportName) || !block.usage.example.includes(expectedImport)) { + errors.push(`${block.id}: basic usage must import and render ${block.exportName}`); + } + const registryItem = featurePackRegistryItems.get(block.registryName); + if (!registryItem?.docs?.includes(expectedImport)) { + errors.push(`${block.id}: registry item must document its feature-pack root import`); + } +} + const appPackage = readJson(path.join(appDirectory, 'package.json')); if (appPackage.devDependencies?.shadcn !== '4.13.1') { errors.push('apps/blocks must pin shadcn to 4.13.1'); @@ -296,4 +366,7 @@ if (errors.length > 0) { } console.log('Blocks docs expose exactly 29 source-checked primitive references.'); -console.log('Every page has complete examples, dual install paths, state guidance, accessibility, and API-last coverage.'); +console.log('Feature-pack docs expose seven manifest-aligned live references.'); +console.log( + 'Every page has complete examples, dual install paths, state guidance, accessibility, and API-last coverage.', +); diff --git a/apps/blocks/scripts/check-sdk-fixtures.ts b/apps/blocks/scripts/check-sdk-fixtures.ts deleted file mode 100644 index 07ff67b..0000000 --- a/apps/blocks/scripts/check-sdk-fixtures.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-nocheck -- The committed fixture manifest intentionally has a generated, namespace-shaped schema. -import fs from 'node:fs'; -import path from 'node:path'; - -import { - collectRequirements, - collectGeneratedImports, - fixtureManifestPath, - fixtureRoot, - namespaces, - operationExport, - readKnownMissing, - serialiseImports, - serialiseRequirements, -} from './sdk-fixture-utils'; - -if (!fs.existsSync(fixtureManifestPath)) throw new Error(`Missing ${fixtureManifestPath}; run pnpm fixtures:refresh.`); - -const manifest = JSON.parse(fs.readFileSync(fixtureManifestPath, 'utf8')); -const expectedImports = serialiseImports(collectGeneratedImports()); -if (JSON.stringify(manifest.imports) !== JSON.stringify(expectedImports)) { - throw new Error('Generated SDK imports changed; refresh the pruned fixtures and review the newly reachable API surface.'); -} - -const expectedRequirements = serialiseRequirements(collectRequirements()); -if (JSON.stringify(manifest.requirements) !== JSON.stringify(expectedRequirements)) { - throw new Error('Registry requirements changed; refresh fixtures so every operation and model is audited.'); -} - -const knownMissing = readKnownMissing(); -if (JSON.stringify(manifest.knownMissing) !== JSON.stringify([...knownMissing.keys()].sort())) { - throw new Error('The known-missing SDK ledger changed; refresh fixtures and verify every exception against generated output.'); -} - -const coverageErrors = []; -const currentRequirementKeys = new Set(); -for (const [namespace, entry] of Object.entries(expectedRequirements)) { - const hooksIndex = path.join(fixtureRoot, namespace, 'hooks', 'index.ts'); - const hooksSource = fs.existsSync(hooksIndex) ? fs.readFileSync(hooksIndex, 'utf8') : ''; - const rootIndex = path.join(fixtureRoot, namespace, 'index.ts'); - const rootSource = fs.existsSync(rootIndex) ? fs.readFileSync(rootIndex, 'utf8') : ''; - - for (const kind of ['queries', 'mutations']) { - for (const name of entry[kind]) { - const key = `${namespace}:${kind}:${name}`; - currentRequirementKeys.add(key); - const symbol = operationExport(kind, name); - const exported = hooksSource.includes(`/${symbol}'`) || hooksSource.includes(`/${symbol}"`); - if (knownMissing.has(key)) { - if (exported) coverageErrors.push(`${key}: ledger entry is stale because ${symbol} is now exported`); - } else if (!exported) { - coverageErrors.push(`${key}: missing generated export ${symbol}`); - } - } - } - - for (const model of entry.models) { - const key = `${namespace}:models:${model}`; - currentRequirementKeys.add(key); - const exported = rootSource.includes(`{ ${model} }`); - if (knownMissing.has(key)) { - if (exported) coverageErrors.push(`${key}: ledger entry is stale because the model is now exported`); - } else if (!exported) { - coverageErrors.push(`${key}: missing normalized model export ${model}`); - } - } -} - -for (const key of knownMissing.keys()) { - if (!currentRequirementKeys.has(key)) coverageErrors.push(`${key}: known-missing entry no longer has a sidecar declaration`); -} -if (coverageErrors.length) { - throw new Error(`SDK requirement coverage failed:\n- ${coverageErrors.join('\n- ')}`); -} - -for (const namespace of namespaces) { - const expected = new Set(manifest.namespaces?.[namespace]?.files ?? []); - for (const relative of expected) { - if (!fs.existsSync(path.join(fixtureRoot, namespace, relative))) { - throw new Error(`Fixture manifest references missing ${namespace}/${relative}.`); - } - } - if (!expected.has('index.ts') || !expected.has('hooks/index.ts')) { - throw new Error(`${namespace} fixture is missing its pruned public barrels.`); - } -} - -console.log('Pruned generated SDK fixtures match every Blocks import.'); -console.log('Every sidecar operation/model is exported or documented in the known-missing ledger.'); diff --git a/apps/blocks/scripts/console-kit-native-fixture.ts b/apps/blocks/scripts/console-kit-native-fixture.ts new file mode 100644 index 0000000..6c2596c --- /dev/null +++ b/apps/blocks/scripts/console-kit-native-fixture.ts @@ -0,0 +1,380 @@ +import { spawn } from 'node:child_process'; +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { isAbsolute, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + assertNativeFixtureManifest, + cleanupNativeFixture, + provisionNativeFixture, + type NativeFixtureDatabase, + type NativeFixtureManifest, + type SqlRequest +} from '../e2e-live/native-fixture'; + +type Command = 'provision' | 'cleanup' | 'run'; + +export type NativeFixtureCliOptions = Readonly<{ + command: Command; + constructiveDbPath?: string; + platformDatabase: string; + manifestPath: string; + graphqlOrigin: string; + domain: string; + host: string; + port: number; + user: string; + keep: boolean; + childCommand: readonly string[]; +}>; + +type FixtureEnvironment = Readonly>; + +const HELP = ` +Constructive Console Kit native tenant fixture + +Usage: + pnpm fixture:console-kit provision --constructive-db /abs/path/constructive-db --database NAME [options] + pnpm fixture:console-kit cleanup --database NAME --manifest /abs/path/manifest.json [options] + pnpm fixture:console-kit run --constructive-db /abs/path/constructive-db --database NAME [options] -- COMMAND + +Required: + --constructive-db PATH Constructive DB repository root (provision/run) + --database, --db NAME Platform database passed to fun up --local --db NAME + +Options: + --manifest PATH Exact-ID journal (default: .local/console-kit-native-fixture.json) + --graphql-origin URL Public GraphQL origin (default: http://localhost:6464) + --domain DOMAIN Tenant root domain (default: localhost) + --host HOST PostgreSQL host (default: PGHOST or localhost) + --port PORT PostgreSQL port (default: PGPORT or 5432) + --user USER PostgreSQL user (default: PGUSER or postgres) + --keep Keep tenants after run, and keep partial tenants after provision failure + --help Show this help + +The password is read from PGPASSWORD, with fun up's local default of "password". +`; + +function optionValue(args: readonly string[], index: number, name: string): string { + const value = args[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`); + return value; +} + +function positivePort(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error('--port must be an integer from 1 through 65535.'); + } + return parsed; +} + +export function parseNativeFixtureCliArgs( + argv: readonly string[], + cwd = process.cwd(), + env: FixtureEnvironment = process.env +): NativeFixtureCliOptions { + const command = argv[0]; + if (command === '--help' || command === '-h') { + throw Object.assign(new Error(HELP.trim()), { help: true }); + } + if (command !== 'provision' && command !== 'cleanup' && command !== 'run') { + throw new Error('Expected a fixture command: provision, cleanup, or run.'); + } + + let constructiveDbPath: string | undefined; + let platformDatabase = ''; + let manifestPath = resolve(cwd, '.local/console-kit-native-fixture.json'); + let graphqlOrigin = `http://localhost:${env.GRAPHQL_PORT || '6464'}`; + let domain = 'localhost'; + let host = env.PGHOST || 'localhost'; + let port = positivePort(env.PGPORT || '5432'); + let user = env.PGUSER || 'postgres'; + let keep = false; + let childCommand: readonly string[] = []; + + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === '--') { + childCommand = argv.slice(index + 1); + break; + } + if (argument === '--help' || argument === '-h') { + throw Object.assign(new Error(HELP.trim()), { help: true }); + } + if (argument === '--keep') { + keep = true; + continue; + } + if (argument === '--constructive-db') { + constructiveDbPath = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--database' || argument === '--db') { + platformDatabase = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--manifest') { + manifestPath = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--graphql-origin') { + graphqlOrigin = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--domain') { + domain = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--host') { + host = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument === '--port') { + port = positivePort(optionValue(argv, index, argument)); + index += 1; + continue; + } + if (argument === '--user') { + user = optionValue(argv, index, argument); + index += 1; + continue; + } + throw new Error(`Unknown fixture option: ${argument}`); + } + + if (!platformDatabase) throw new Error('--database is required and must match fun up --db.'); + if ((command === 'provision' || command === 'run') && !constructiveDbPath) { + throw new Error('--constructive-db is required for provision and run.'); + } + if (constructiveDbPath && !isAbsolute(constructiveDbPath)) { + throw new Error('--constructive-db must be an absolute path.'); + } + if (!isAbsolute(manifestPath)) manifestPath = resolve(cwd, manifestPath); + const parsedOrigin = new URL(graphqlOrigin); + if (parsedOrigin.origin !== graphqlOrigin.replace(/\/$/u, '')) { + throw new Error('--graphql-origin must contain only a scheme, host, and optional port.'); + } + if (command === 'run' && childCommand.length === 0) { + throw new Error('run requires a command after --.'); + } + if (command !== 'run' && childCommand.length > 0) { + throw new Error('Only run accepts a command after --.'); + } + + return { + command, + constructiveDbPath, + platformDatabase, + manifestPath, + graphqlOrigin: parsedOrigin.origin, + domain, + host, + port, + user, + keep, + childCommand + }; +} + +type PsqlOptions = Pick; + +async function psql( + options: PsqlOptions, + request: SqlRequest, + env: NodeJS.ProcessEnv = process.env +): Promise { + const args = [ + '--host', options.host, + '--port', String(options.port), + '--username', options.user, + '--dbname', options.platformDatabase, + '--no-psqlrc', + '--quiet', + '--tuples-only', + '--no-align', + '--set=ON_ERROR_STOP=1', + ...Object.entries(request.variables ?? {}).map(([name, value]) => `--set=${name}=${value}`) + ]; + + return new Promise((resolvePromise, reject) => { + const child = spawn('psql', args, { + env: { ...env, PGPASSWORD: env.PGPASSWORD || 'password' }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (code) => { + const output = Buffer.concat(stdout).toString('utf8').trim(); + const errorOutput = Buffer.concat(stderr).toString('utf8').trim(); + if (code !== 0) { + reject(new Error(`psql exited with ${code}: ${errorOutput || 'unknown PostgreSQL error'}`)); + return; + } + resolvePromise(output); + }); + child.stdin.end(`${request.sql.trim()}\n`); + }); +} + +export class PsqlNativeFixtureDatabase implements NativeFixtureDatabase { + constructor(private readonly options: PsqlOptions) {} + + async json(request: SqlRequest): Promise { + const output = await psql(this.options, request); + if (!output) throw new Error('The PostgreSQL fixture query returned no JSON.'); + try { + return JSON.parse(output) as T; + } catch (error) { + throw new Error(`The PostgreSQL fixture query returned invalid JSON: ${output}`, { cause: error }); + } + } + + async execute(request: SqlRequest): Promise { + await psql(this.options, request); + } +} + +export async function writeNativeFixtureManifest( + path: string, + manifest: NativeFixtureManifest +): Promise { + assertNativeFixtureManifest(manifest); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporaryPath = `${path}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); + await chmod(path, 0o600); +} + +export async function readNativeFixtureManifest(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, 'utf8')); + assertNativeFixtureManifest(value); + return value; +} + +export async function assertNativeFixtureManifestSlotAvailable(path: string): Promise { + try { + const existing = await readNativeFixtureManifest(path); + if (existing.status !== 'cleaned') { + throw new Error( + `Manifest ${path} still owns ${existing.databaseIds.length} tenant database ID(s). ` + + 'Run cleanup or choose a different --manifest path before provisioning again.' + ); + } + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) { + return; + } + throw error; + } +} + +async function spawnCommand(command: readonly string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command[0]!, command.slice(1), { env, stdio: 'inherit' }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (signal) { + reject(new Error(`Fixture command was terminated by ${signal}.`)); + return; + } + resolvePromise(code ?? 1); + }); + }); +} + +function printReady(manifestPath: string, manifest: NativeFixtureManifest): void { + console.log(`Native tenant fixture ready: ${manifestPath}`); + for (const tenant of manifest.tenants) { + console.log(` ${tenant.profile.padEnd(16)} ${tenant.database.id}`); + } + console.log(` export CONSOLE_KIT_TENANT_MANIFEST=${manifestPath}`); + console.log(' pnpm fixture:console-kit cleanup --database ' + + `${manifest.platformDatabase} --manifest ${manifestPath}`); +} + +export async function runNativeFixtureCli(options: NativeFixtureCliOptions): Promise { + const database = new PsqlNativeFixtureDatabase(options); + + if (options.command === 'cleanup') { + const manifest = await readNativeFixtureManifest(options.manifestPath); + if (manifest.platformDatabase !== options.platformDatabase) { + throw new Error( + `Manifest targets ${manifest.platformDatabase}, not ${options.platformDatabase}; cleanup refused.` + ); + } + const cleaned = await cleanupNativeFixture(database, manifest); + await writeNativeFixtureManifest(options.manifestPath, cleaned); + console.log(`Cleaned ${cleaned.databaseIds.length} exact tenant database IDs.`); + return 0; + } + + await assertNativeFixtureManifestSlotAvailable(options.manifestPath); + const manifest = await provisionNativeFixture(database, { + constructiveDbPath: options.constructiveDbPath!, + platformDatabase: options.platformDatabase, + graphqlOrigin: options.graphqlOrigin, + domain: options.domain, + keepOnFailure: options.keep, + writeManifest: (value) => writeNativeFixtureManifest(options.manifestPath, value) + }); + printReady(options.manifestPath, manifest); + + if (options.command === 'provision') return 0; + + let childExitCode = 1; + let commandError: unknown; + try { + childExitCode = await spawnCommand(options.childCommand, { + ...process.env, + CONSOLE_KIT_INTEGRATION: '1', + CONSOLE_KIT_TENANT_MANIFEST: options.manifestPath, + CONSOLE_KIT_BASE_URL: process.env.CONSOLE_KIT_BASE_URL || + 'http://localhost:3005/__integration/console-kit' + }); + } catch (error) { + commandError = error; + } finally { + if (!options.keep) { + const currentManifest = await readNativeFixtureManifest(options.manifestPath); + const cleaned = await cleanupNativeFixture(database, currentManifest); + await writeNativeFixtureManifest(options.manifestPath, cleaned); + console.log(`Cleaned ${cleaned.databaseIds.length} exact tenant database IDs.`); + } else { + console.log(`Kept native tenants for review; cleanup manifest: ${options.manifestPath}`); + } + } + if (commandError) throw commandError; + return childExitCode; +} + +async function main(): Promise { + try { + const options = parseNativeFixtureCliArgs(process.argv.slice(2)); + process.exitCode = await runNativeFixtureCli(options); + } catch (error) { + const isHelp = error instanceof Error && 'help' in error; + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = isHelp ? 0 : 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + void main(); +} diff --git a/apps/blocks/scripts/refresh-sdk-fixtures.ts b/apps/blocks/scripts/refresh-sdk-fixtures.ts deleted file mode 100644 index d7f8786..0000000 --- a/apps/blocks/scripts/refresh-sdk-fixtures.ts +++ /dev/null @@ -1,233 +0,0 @@ -// @ts-nocheck -- This maintenance-only importer traverses heterogeneous external SDK output. -import fs from 'node:fs'; -import path from 'node:path'; - -import ts from 'typescript'; - -import { - appDir, - collectRequirements, - collectGeneratedImports, - fixtureManifestPath, - fixtureRoot, - namespaces, - operationExport, - readKnownMissing, - serialiseImports, - serialiseRequirements, -} from './sdk-fixture-utils'; - -const sourceRoot = path.resolve( - process.env.BLOCKS_SDK_SOURCE_ROOT ?? - process.argv.find((argument) => argument.startsWith('--source='))?.slice('--source='.length) ?? - '', -); - -if (!process.env.BLOCKS_SDK_SOURCE_ROOT && !process.argv.some((argument) => argument.startsWith('--source='))) { - throw new Error( - 'Set BLOCKS_SDK_SOURCE_ROOT (or --source=...) to a directory containing auth-sdk/api, admin-sdk/api, schema-builder-sdk/api, and modules-sdk/api.', - ); -} - -const imports = collectGeneratedImports(); -const requirements = collectRequirements(); -const knownMissing = readKnownMissing(); -const discoveredMissing = new Set(); - -function listTypeScriptFiles(root) { - const files = []; - for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const target = path.join(root, entry.name); - if (entry.isDirectory()) files.push(...listTypeScriptFiles(target)); - else if (/\.[cm]?[jt]sx?$/.test(entry.name)) files.push(target); - } - return files; -} - -function resolveRelative(fromFile, specifier) { - const unresolved = path.resolve(path.dirname(fromFile), specifier); - return [ - unresolved, - `${unresolved}.ts`, - `${unresolved}.tsx`, - path.join(unresolved, 'index.ts'), - path.join(unresolved, 'index.tsx'), - ].find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); -} - -function dependencyClosure(seedFiles, sourceApi) { - const pending = [...seedFiles]; - const files = new Set(); - while (pending.length) { - const file = pending.pop(); - if (!file || files.has(file)) continue; - if (!file.startsWith(`${sourceApi}${path.sep}`)) throw new Error(`SDK dependency escaped ${sourceApi}: ${file}`); - files.add(file); - const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); - const specifiers = source.statements.flatMap((statement) => { - if ( - (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && - statement.moduleSpecifier && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.moduleSpecifier.text.startsWith('.') - ) { - return [statement.moduleSpecifier.text]; - } - return []; - }); - for (const specifier of specifiers) { - const dependency = resolveRelative(file, specifier); - if (!dependency) throw new Error(`Cannot resolve ${specifier} from ${file}`); - pending.push(dependency); - } - } - return files; -} - -function findSymbolFile(sourceApi, name) { - if (name === 'configure' || name === 'getClient') return path.join(sourceApi, 'hooks', 'client.ts'); - const byBasename = listTypeScriptFiles(sourceApi).filter((file) => path.basename(file, path.extname(file)) === name); - if (byBasename.length === 1) return byBasename[0]; - if (byBasename.length > 1) throw new Error(`Ambiguous generated export '${name}': ${byBasename.join(', ')}`); - - const declaration = new RegExp( - `export\\s+(?:declare\\s+)?(?:async\\s+)?(?:type|interface|class|const|function|enum)\\s+${name}\\b`, - ); - const exportList = new RegExp(`export\\s+(?:type\\s+)?\\{[^}]*\\b${name}\\b[^}]*\\}`); - const matches = listTypeScriptFiles(sourceApi).filter((file) => { - const source = fs.readFileSync(file, 'utf8'); - return declaration.test(source) || exportList.test(source); - }); - if (matches.length === 0) throw new Error(`Unable to locate generated export '${name}' under ${sourceApi}`); - return matches.sort((a, b) => { - const aHooks = a.includes(`${path.sep}hooks${path.sep}`) ? 0 : 1; - const bHooks = b.includes(`${path.sep}hooks${path.sep}`) ? 0 : 1; - return aHooks - bHooks || a.length - b.length || a.localeCompare(b); - })[0]; -} - -function subpathFile(sourceApi, subpath) { - const relative = subpath.replace(/^\//, ''); - return resolveRelative(path.join(sourceApi, 'index.ts'), `./${relative}`); -} - -const manifest = { - generatedBy: 'apps/blocks/scripts/refresh-sdk-fixtures.ts', - generatedAt: null, - imports: serialiseImports(imports), - requirements: serialiseRequirements(requirements), - knownMissing: [...knownMissing.keys()].sort(), - namespaces: {}, -}; - -fs.mkdirSync(fixtureRoot, { recursive: true }); -const stagingRoot = fs.mkdtempSync(path.join(fixtureRoot, '.staging-')); -process.once('exit', () => fs.rmSync(stagingRoot, { recursive: true, force: true })); - -for (const namespace of namespaces) { - const sourceApi = path.join(sourceRoot, `${namespace}-sdk`, 'api'); - if (!fs.existsSync(sourceApi)) throw new Error(`Missing generated SDK source: ${sourceApi}`); - - const seeds = new Set(); - const modelExports = new Map(); - for (const [subpath, names] of imports[namespace]) { - if (subpath) { - const file = subpathFile(sourceApi, subpath); - if (!file) throw new Error(`Cannot resolve @/generated/${namespace}${subpath} in ${sourceApi}`); - seeds.add(file); - } - for (const name of names) { - if (name === 'default' || name === '*') { - throw new Error(`Fixture pruning requires named imports; found '${name}' for @/generated/${namespace}${subpath}`); - } - seeds.add(findSymbolFile(sourceApi, name)); - } - } - - const namespaceRequirements = requirements[namespace] ?? { - queries: new Set(), - mutations: new Set(), - models: new Set(), - }; - for (const kind of ['queries', 'mutations']) { - for (const name of namespaceRequirements[kind]) { - const symbol = operationExport(kind, name); - try { - seeds.add(findSymbolFile(sourceApi, symbol)); - } catch { - discoveredMissing.add(`${namespace}:${kind}:${name}`); - } - } - } - for (const model of namespaceRequirements.models) { - try { - const file = findSymbolFile(sourceApi, model); - seeds.add(file); - modelExports.set(model, file); - } catch { - discoveredMissing.add(`${namespace}:models:${model}`); - } - } - - const files = dependencyClosure(seeds, sourceApi); - const destination = path.join(stagingRoot, namespace); - for (const file of [...files].sort()) { - const relative = path.relative(sourceApi, file); - const target = path.join(destination, relative); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.copyFileSync(file, target); - } - - const hookSeeds = [...seeds] - .filter((file) => file.includes(`${path.sep}hooks${path.sep}`) && path.basename(file) !== 'client.ts') - .map((file) => `./${path.relative(path.join(sourceApi, 'hooks'), file).replace(/\\/g, '/').replace(/\.[^.]+$/, '')}`) - .sort(); - fs.mkdirSync(path.join(destination, 'hooks'), { recursive: true }); - fs.writeFileSync( - path.join(destination, 'hooks', 'index.ts'), - [`export * from './client';`, ...hookSeeds.map((specifier) => `export * from '${specifier}';`), ''].join('\n'), - ); - const rootExports = [`export * from './hooks';`]; - for (const [model, file] of [...modelExports.entries()].sort(([a], [b]) => a.localeCompare(b))) { - const specifier = `./${path.relative(sourceApi, file).replace(/\\/g, '/').replace(/\.[^.]+$/, '')}`; - rootExports.push(`export type { ${model} } from '${specifier}';`); - } - fs.writeFileSync(path.join(destination, 'index.ts'), `${rootExports.join('\n')}\n`); - - manifest.namespaces[namespace] = { - files: [...files] - .map((file) => path.relative(sourceApi, file).replace(/\\/g, '/')) - .concat(['hooks/index.ts', 'index.ts']) - .filter((file, index, all) => all.indexOf(file) === index) - .sort(), - }; -} - -for (const [namespace, entry] of Object.entries(requirements)) { - if (namespaces.includes(namespace)) continue; - for (const kind of ['queries', 'mutations', 'models']) { - for (const name of entry[kind]) discoveredMissing.add(`${namespace}:${kind}:${name}`); - } -} - -const newGaps = [...discoveredMissing].filter((key) => !knownMissing.has(key)).sort(); -const staleGaps = [...knownMissing.keys()].filter((key) => !discoveredMissing.has(key)).sort(); -if (newGaps.length || staleGaps.length) { - throw new Error( - [ - newGaps.length ? `Undocumented SDK gaps: ${newGaps.join(', ')}` : '', - staleGaps.length ? `Stale known-missing SDK entries: ${staleGaps.join(', ')}` : '', - ] - .filter(Boolean) - .join('\n'), - ); -} - -for (const namespace of namespaces) { - const destination = path.join(fixtureRoot, namespace); - fs.rmSync(destination, { recursive: true, force: true }); - fs.renameSync(path.join(stagingRoot, namespace), destination); -} -fs.writeFileSync(fixtureManifestPath, `${JSON.stringify(manifest, null, 2)}\n`); -fs.rmSync(stagingRoot, { recursive: true, force: true }); -console.log(`Refreshed pruned SDK fixtures in ${path.relative(appDir, fixtureRoot)}.`); diff --git a/apps/blocks/scripts/sdk-fixture-known-missing.json b/apps/blocks/scripts/sdk-fixture-known-missing.json deleted file mode 100644 index c79d7d9..0000000 --- a/apps/blocks/scripts/sdk-fixture-known-missing.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "namespace": "auth", - "kind": "mutations", - "names": [ - "anonymousSignIn", - "completeMfaChallenge", - "confirmTotpSetup", - "disableTotp", - "enableTotp", - "generateBackupCodes", - "passkeyBeginRegistration", - "passkeyBeginSignIn", - "passkeyFinishRegistration", - "passkeyFinishSignIn", - "requestMagicLink", - "sendEmailOtp", - "sendSmsOtp", - "signInEmailOtp", - "signInMagicLink", - "switchContext", - "verifyPhoneOtp" - ], - "reason": "These operations are declared by backend-pending auth blocks, but the corresponding procedures are absent from the current generated auth schema. The blocks use explicit host override seams until those procedures ship." - }, - { - "namespace": "admin", - "kind": "mutations", - "names": ["removeOrgMember", "transferOrgOwnership"], - "reason": "The membership UI documents these future actions, but the current generated admin schema exposes only updateOrgMembership and deleteOrgMembership." - }, - { - "namespace": "public", - "kind": "queries", - "names": ["notifications"], - "reason": "Notification blocks are API-config-pending and accept host-owned data; this repository has no generated public SDK fixture yet." - }, - { - "namespace": "public", - "kind": "mutations", - "names": ["dismissNotification", "markAllNotificationsRead", "markNotificationRead"], - "reason": "Notification blocks are API-config-pending and expose host callback seams; this repository has no generated public SDK fixture yet." - } -] diff --git a/apps/blocks/scripts/sdk-fixture-utils.ts b/apps/blocks/scripts/sdk-fixture-utils.ts deleted file mode 100644 index 0ee5cc3..0000000 --- a/apps/blocks/scripts/sdk-fixture-utils.ts +++ /dev/null @@ -1,144 +0,0 @@ -// @ts-nocheck -- This compatibility utility models heterogeneous generated SDK graphs at runtime. -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import ts from 'typescript'; - -export const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -export const appDir = path.resolve(scriptDir, '..'); -export const repoRoot = path.resolve(appDir, '..', '..'); -export const fixtureRoot = path.join(appDir, 'src', 'generated'); -export const fixtureManifestPath = path.join(fixtureRoot, 'fixture-manifest.json'); -export const knownMissingPath = path.join(scriptDir, 'sdk-fixture-known-missing.json'); -export const namespaces = ['auth', 'admin', 'schema-builder', 'modules']; - -function sourceFiles(root) { - if (!fs.existsSync(root)) return []; - const files = []; - for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const target = path.join(root, entry.name); - if (entry.isDirectory()) { - if (entry.name === 'generated') continue; - files.push(...sourceFiles(target)); - } else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { - files.push(target); - } - } - return files; -} - -export function collectGeneratedImports() { - const collected = Object.fromEntries(namespaces.map((namespace) => [namespace, new Map()])); - const canonicalSourceRoots = [ - path.join(appDir, 'src'), - path.join(repoRoot, 'packages', 'schema-builder', 'src', 'schema'), - path.join(repoRoot, 'packages', 'schema-builder', 'registry-support'), - ]; - for (const file of canonicalSourceRoots.flatMap(sourceFiles).sort()) { - const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); - for (const statement of source.statements) { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; - const match = statement.moduleSpecifier.text.match(/^@\/generated\/(auth|admin|schema-builder|modules)(\/.*)?$/); - if (!match) continue; - const [, namespace, subpath = ''] = match; - const clause = statement.importClause; - if (!clause) continue; - const names = collected[namespace].get(subpath) ?? new Set(); - if (clause.name) names.add('default'); - if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { - for (const element of clause.namedBindings.elements) names.add(element.propertyName?.text ?? element.name.text); - } else if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { - names.add('*'); - } - collected[namespace].set(subpath, names); - } - } - return collected; -} - -export function serialiseImports(collected) { - return Object.fromEntries( - namespaces.map((namespace) => [ - namespace, - Object.fromEntries( - [...collected[namespace].entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([subpath, names]) => [subpath || '.', [...names].sort()]), - ), - ]), - ); -} - -function requirementFiles(root) { - if (!fs.existsSync(root)) return []; - return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { - const target = path.join(root, entry.name); - if (entry.isDirectory()) return requirementFiles(target); - return entry.name.endsWith('.requires.json') ? [target] : []; - }); -} - -export function normaliseModelName(name) { - if (/^[A-Z]/.test(name)) return name; - let singular = name; - if (singular.endsWith('ies')) singular = `${singular.slice(0, -3)}y`; - else if (singular.endsWith('s')) singular = singular.slice(0, -1); - return singular - .split(/[-_]/) - .filter(Boolean) - .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) - .join(''); -} - -export function operationExport(kind, name) { - const operation = `${name[0].toUpperCase()}${name.slice(1)}`; - return kind === 'queries' ? `use${operation}Query` : `use${operation}Mutation`; -} - -export function collectRequirements() { - const roots = [path.join(appDir, 'src', 'blocks'), path.join(repoRoot, 'packages', 'schema-builder')]; - const requirements = {}; - for (const file of roots.flatMap(requirementFiles).sort()) { - const value = JSON.parse(fs.readFileSync(file, 'utf8')); - for (const requirement of value.requires ?? [value]) { - const namespace = requirement.namespace; - if (!namespace) throw new Error(`${file} contains a requirement without a namespace.`); - const entry = requirements[namespace] ?? { queries: new Set(), mutations: new Set(), models: new Set() }; - for (const query of requirement.queries ?? []) entry.queries.add(query); - for (const mutation of requirement.mutations ?? []) entry.mutations.add(mutation); - for (const model of requirement.models ?? []) entry.models.add(normaliseModelName(model)); - requirements[namespace] = entry; - } - } - return requirements; -} - -export function serialiseRequirements(requirements) { - return Object.fromEntries( - Object.entries(requirements) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([namespace, entry]) => [ - namespace, - { - queries: [...entry.queries].sort(), - mutations: [...entry.mutations].sort(), - models: [...entry.models].sort(), - }, - ]), - ); -} - -export function readKnownMissing() { - const groups = JSON.parse(fs.readFileSync(knownMissingPath, 'utf8')); - const entries = new Map(); - for (const group of groups) { - if (!group.reason?.trim()) throw new Error('Every known SDK gap needs a reason.'); - for (const name of group.names ?? []) { - const key = `${group.namespace}:${group.kind}:${name}`; - if (entries.has(key)) throw new Error(`Duplicate known SDK gap: ${key}`); - entries.set(key, group.reason); - } - } - return entries; -} diff --git a/apps/blocks/src/app/%5F_integration/console-kit/console-kit-proof-client.tsx b/apps/blocks/src/app/%5F_integration/console-kit/console-kit-proof-client.tsx new file mode 100644 index 0000000..1adf691 --- /dev/null +++ b/apps/blocks/src/app/%5F_integration/console-kit/console-kit-proof-client.tsx @@ -0,0 +1,321 @@ +'use client'; + +import * as React from 'react'; +import { + CheckCircle2Icon, + CheckIcon, + CopyIcon, + DatabaseIcon +} from 'lucide-react'; + +import { Badge } from '@constructive-io/ui/badge'; +import { Button } from '@constructive-io/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleIcon, + CollapsibleTrigger +} from '@constructive-io/ui/collapsible'; +import { Field } from '@constructive-io/ui/field'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@constructive-io/ui/select'; + +import type { ConstructiveTenantDatabase } from '@/blocks/console-kit/constructive/constructive-console-kit'; +import { AuthHardenedConsoleKit } from '@/blocks/presets/auth-hardened-console-kit'; +import { B2BStorageConsoleKit } from '@/blocks/presets/b2b-storage-console-kit'; +import { FullConsoleKit } from '@/blocks/presets/full-console-kit'; +import { cn } from '@/lib/utils'; + +export type ConsoleKitProofProfile = + | 'auth-hardened' + | 'b2b-storage' + | 'full' + | 'storage-routed'; + +export type ConsoleKitProofTenant = Readonly<{ + profile: ConsoleKitProofProfile; + preset: 'auth:hardened' | 'b2b:storage' | 'full'; + database: ConstructiveTenantDatabase; + endpointSummary: string; +}>; + +type ConsoleKitProofEmailVerification = Readonly<{ + databaseId: string; + emailId: string; + token: string; +}>; + +const ENDPOINT_KINDS = [ + 'data', + 'auth', + 'admin', + 'billing', + 'storage', + 'notifications' +] as const; + +function tenantLabel(tenant: ConsoleKitProofTenant): string { + return `${tenant.profile} · ${tenant.preset}`; +} + +function endpointUrl( + endpoint: ConstructiveTenantDatabase['endpoints'][typeof ENDPOINT_KINDS[number]] +): string | null { + if (typeof endpoint === 'string') return endpoint; + return endpoint?.url ?? null; +} + +function CopyValueButton({ label, value }: Readonly<{ label: string; value: string }>) { + const [copied, setCopied] = React.useState(false); + const resetTimer = React.useRef | undefined>(undefined); + + React.useEffect(() => () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }, []); + + return ( + + ); +} + +function PresetConsoleKit({ + tenant, + verification +}: Readonly<{ + tenant: ConsoleKitProofTenant; + verification?: ConsoleKitProofEmailVerification; +}>) { + const callback = React.useMemo(() => { + if (!verification || verification.databaseId !== tenant.database.id) { + return false as const; + } + return new URLSearchParams({ + callback: 'email-verification', + database_id: verification.databaseId, + email_id: verification.emailId, + verification_token: verification.token + }); + }, [ + tenant.database.id, + verification?.databaseId, + verification?.emailId, + verification?.token + ]); + const props = { + className: 'min-h-svh', + database: tenant.database, + // Product chrome stays neutral; profile/preset live in proof controls only. + brand: { name: 'Application' }, + showUnavailable: true, + callback + } as const; + + switch (tenant.profile) { + case 'auth-hardened': + return ; + case 'b2b-storage': + case 'storage-routed': + return ; + case 'full': + return ; + } +} + +export function ConsoleKitProofClient({ + initialProfile, + membershipFixtureMode, + runId, + tenants +}: Readonly<{ + initialProfile?: ConsoleKitProofProfile; + membershipFixtureMode: 'auto-approved-and-verified'; + runId: string; + tenants: readonly ConsoleKitProofTenant[]; +}>) { + const initialTenant = tenants.find((tenant) => tenant.profile === initialProfile) ?? tenants[0]; + const [databaseId, setDatabaseId] = React.useState(initialTenant?.database.id ?? ''); + const [emailVerification, setEmailVerification] = + React.useState(); + const [fragmentReady, setFragmentReady] = React.useState(false); + const [controlsOpen, setControlsOpen] = React.useState(false); + + React.useEffect(() => { + const consumeVerificationFragment = () => { + const fragment = new URLSearchParams(window.location.hash.replace(/^#/u, '')); + const hasSensitiveValue = fragment.has('email_id') || fragment.has('verification_token'); + const verification = { + databaseId: fragment.get('verification_database_id') ?? '', + emailId: fragment.get('email_id') ?? '', + token: fragment.get('verification_token') ?? '' + }; + if (hasSensitiveValue) { + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${window.location.search}` + ); + } + if ( + verification.databaseId && + verification.emailId && + verification.token && + tenants.some((candidate) => candidate.database.id === verification.databaseId) + ) { + setDatabaseId(verification.databaseId); + setEmailVerification(verification); + window.removeEventListener('hashchange', consumeVerificationFragment); + } + }; + window.addEventListener('hashchange', consumeVerificationFragment); + consumeVerificationFragment(); + setFragmentReady(true); + return () => window.removeEventListener('hashchange', consumeVerificationFragment); + }, [tenants]); + + if (!fragmentReady) { + return ( +
+

Preparing the tenant console…

+
+ ); + } + + const tenant = tenants.find((candidate) => candidate.database.id === databaseId) ?? tenants[0]; + + if (!tenant) { + return ( +
+

The native fixture does not contain a tenant database.

+
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/blocks/src/app/%5F_integration/console-kit/page.tsx b/apps/blocks/src/app/%5F_integration/console-kit/page.tsx new file mode 100644 index 0000000..cdbc1c3 --- /dev/null +++ b/apps/blocks/src/app/%5F_integration/console-kit/page.tsx @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { notFound } from 'next/navigation'; + +import type { ConsoleEndpointMap } from '@/blocks/console-runtime'; + +import { + assertNativeFixtureManifest, + tenantEndpoint, + type FixtureProfile, + type NativeFixtureTenant +} from '../../../../e2e-live/native-fixture-contract'; +import { + ConsoleKitProofClient, + type ConsoleKitProofTenant +} from './console-kit-proof-client'; + +const API_NAME_TO_KIND = { + api: 'data', + auth: 'auth', + admin: 'admin', + usage: 'billing', + notifications: 'notifications' +} as const; + +const PRESET_BY_PROFILE = { + 'auth-hardened': 'auth:hardened', + 'b2b-storage': 'b2b:storage', + full: 'full', + 'storage-routed': 'b2b:storage' +} as const; + +function requiredManifestPath(): string { + const path = process.env.CONSOLE_KIT_TENANT_MANIFEST; + if (!path || !isAbsolute(path)) { + throw new Error('CONSOLE_KIT_TENANT_MANIFEST must be an absolute path.'); + } + return path; +} + +function readNativeFixture() { + const raw: unknown = JSON.parse(readFileSync(requiredManifestPath(), 'utf8')); + assertNativeFixtureManifest(raw); + if (raw.status !== 'ready') { + throw new Error(`The native tenant fixture is ${raw.status}, not ready.`); + } + return raw; +} + +function endpointDescriptor( + endpoint: NativeFixtureTenant['endpoints'][number] +) { + return { + id: endpoint.apiId, + url: endpoint.url + }; +} + +function toTenant(tenant: NativeFixtureTenant): ConsoleKitProofTenant { + if (tenant.preset !== PRESET_BY_PROFILE[tenant.profile]) { + throw new Error( + `${tenant.profile} must use the ${PRESET_BY_PROFILE[tenant.profile]} preset.` + ); + } + const endpoints: ConsoleEndpointMap = {}; + + for (const endpoint of tenant.endpoints) { + const kind = API_NAME_TO_KIND[ + endpoint.apiName as keyof typeof API_NAME_TO_KIND + ]; + if (kind) endpoints[kind] = endpointDescriptor(endpoint); + } + + const storageApiName = tenant.capabilityBindings.storageApiName; + if (storageApiName) { + const storageEndpoint = tenantEndpoint(tenant, storageApiName); + if (!storageEndpoint) { + throw new Error( + `${tenant.profile} binds Storage to ${storageApiName}, but that API is not routed.` + ); + } + endpoints.storage = endpointDescriptor(storageEndpoint); + } + + const routedKinds = Object.keys(endpoints); + return { + profile: tenant.profile, + preset: tenant.preset, + database: { + id: tenant.database.id, + name: `${tenant.profile} · ${tenant.preset}`, + endpoints + }, + endpointSummary: `${routedKinds.join(', ') || 'No'} endpoints routed from services_public` + }; +} + +function requestedProfile( + value: string | string[] | undefined, + tenants: readonly ConsoleKitProofTenant[] +): FixtureProfile | undefined { + const profile = Array.isArray(value) ? value[0] : value; + return tenants.find((tenant) => tenant.profile === profile)?.profile; +} + +export default async function ConsoleKitProofPage({ + searchParams +}: Readonly<{ + searchParams: Promise>; +}>) { + if (process.env.CONSOLE_KIT_INTEGRATION !== '1') notFound(); + + const fixture = readNativeFixture(); + const tenants = fixture.tenants.map(toTenant); + const query = await searchParams; + + return ( + + ); +} diff --git a/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx b/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx index dee8028..40cce00 100644 --- a/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx +++ b/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx @@ -4,13 +4,10 @@ import { Suspense } from 'react'; import { BillingShowcaseEmbed } from '@/components/billing-showcase/billing-showcase-embed'; import { BILLING_BLOCKS, getBillingBlock } from '@/lib/billing-blocks'; +import { withBase } from '@/lib/site'; type PageProps = { params: Promise<{ name: string }> }; -export const metadata: Metadata = { - robots: { follow: false, index: false } -}; - export default async function BillingBlockPreviewPage({ params }: PageProps) { const { name } = await params; const block = getBillingBlock(name); @@ -34,3 +31,15 @@ export default async function BillingBlockPreviewPage({ params }: PageProps) { export function generateStaticParams() { return BILLING_BLOCKS.map(({ name }) => ({ name })); } + +export async function generateMetadata({ params }: PageProps): Promise { + const { name } = await params; + const block = getBillingBlock(name); + + return { + alternates: block + ? { canonical: withBase(`/blocks/billing/${block.name}`) } + : undefined, + robots: { follow: false, index: false } + }; +} diff --git a/apps/blocks/src/app/blocks/console-kit/page.tsx b/apps/blocks/src/app/blocks/console-kit/page.tsx new file mode 100644 index 0000000..7e6ef87 --- /dev/null +++ b/apps/blocks/src/app/blocks/console-kit/page.tsx @@ -0,0 +1,786 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { Badge } from '@constructive-io/ui/badge'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@constructive-io/ui/table'; + +import { ConsoleKitShowcasePreview } from '@/components/console-kit-showcase/console-kit-showcase-preview'; +import { CodeBlock } from '@/components/docs/code-block'; +import { registryAdd } from '@/lib/install-mode'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const INSTALL_MATRIX = [ + { + surface: 'console-kit-nextjs', + installs: 'Core + all seven modules + shell graph', + evidence: 'Any tenant with routable endpoints for the modules you open', + degrades: 'Unavailable modules stay navigable with diagnostics' + }, + { + surface: 'console-kit-core', + installs: 'Shell, store, runtime, no feature modules', + evidence: 'Host-supplied featureModules list', + degrades: 'Empty main until modules are composed' + }, + { + surface: 'console-module-*', + installs: 'Console integration + matching feature-pack view', + evidence: 'Module-required endpoints, capabilities, and _meta', + degrades: 'Setup badge + endpoint-specific unavailable panel' + }, + { + surface: 'feature-pack-*', + installs: 'Standalone provider-neutral view only', + evidence: 'Host-supplied resource/actions props', + degrades: 'Loading, empty, error, ready resource states only' + }, + { + surface: 'preset-auth-hardened', + installs: 'Data, Auth, Users', + evidence: 'data + auth endpoints; users after sign-in', + degrades: 'Signed-out users/data show Sign in' + }, + { + surface: 'preset-b2b-storage', + installs: 'Data, Auth, Users, Organizations, Storage', + evidence: 'auth + data + admin/org contracts; storage route when public', + degrades: 'Stock storage often unavailable without a public route' + }, + { + surface: 'preset-full', + installs: 'All seven modules', + evidence: 'Per-module public contracts', + degrades: 'Billing/notifications/storage degrade independently' + } +] as const; + +const TITLE = 'Console Kit for Next.js'; +const DESCRIPTION = + 'A full-page, route-neutral Constructive application console composed from an installable core and feature modules.'; + +const CONSOLE_EXAMPLE = `'use client'; + +import { + ConstructiveConsoleKit, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/constructive'; + +type TenantEndpoint = Readonly<{ + apiId: string | null; + url: string | null; + routable: boolean; +}>; + +type TenantManifest = Readonly<{ + database: Readonly<{ id: string; name: string }>; + endpoints: Readonly>; +}>; + +function routed(endpoint: TenantEndpoint) { + return endpoint.routable && endpoint.apiId && endpoint.url + ? { id: endpoint.apiId, url: endpoint.url } + : undefined; +} + +export function ApplicationConsole({ tenant }: Readonly<{ tenant: TenantManifest }>) { + const database: ConstructiveTenantDatabase = { + id: tenant.database.id, + name: tenant.database.name, + endpoints: { + data: routed(tenant.endpoints.data), + auth: routed(tenant.endpoints.auth), + admin: routed(tenant.endpoints.admin), + billing: routed(tenant.endpoints.billing), + storage: routed(tenant.endpoints.storage), + notifications: routed(tenant.endpoints.notifications) + } + }; + + return ; +}`; + +const LOWER_LEVEL_EXAMPLE = `'use client'; + +import { + ConstructiveConsoleKitCore, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/console-kit-core'; +import { authConsoleModule } from '@/blocks/feature-packs/auth/auth-console-module'; +import { dataConsoleModule } from '@/blocks/feature-packs/data/data-console-module'; + +const featureModules = [dataConsoleModule, authConsoleModule] as const; + +export function SelectedPackConsole({ + database +}: Readonly<{ database: ConstructiveTenantDatabase }>) { + return ( + + ); +}`; + +const ENDPOINT_RESOLVER_EXAMPLE = `resolveEndpoint: ({ databaseId, kind }) => ({ + id: \`\${databaseId}:\${kind}\`, + url: endpointDirectory.graphqlUrl(databaseId, kind) +})`; + +const STORE_EXAMPLE = `'use client'; + +import * as React from 'react'; + +import { + ConsoleKit, + createConsoleKitStore, + type ConsoleKitConfig, + type ConsoleKitFeatureModule +} from '@/blocks/console-kit/console-kit-core'; +import { dataConsoleModule } from '@/blocks/feature-packs/data/data-console-module'; +import { storageConsoleModule } from '@/blocks/feature-packs/storage/storage-console-module'; + +const featureModules: readonly ConsoleKitFeatureModule[] = [ + dataConsoleModule, + storageConsoleModule +]; + +export function ObservableConsole({ + config +}: Readonly<{ config: ConsoleKitConfig }>) { + const [store] = React.useState(() => createConsoleKitStore( + 'data', + { databaseId: config.databaseId, organizationId: null }, + featureModules.flatMap((module) => + module.storeSlice ? [module.storeSlice] : [] + ) + )); + + return ( + + ); +}`; + +const NATIVE_PROOF_EXAMPLE = `# Start the untouched Constructive DB runtime. +cd /absolute/path/to/constructive-db/functions +fun up --local --db consolekitblocks + +# From the Blocks repository in another terminal, provision, test, and clean up. +pnpm fixture:console-kit run \\ + --constructive-db /absolute/path/to/constructive-db \\ + --database consolekitblocks \\ + -- pnpm --filter blocks test:e2e:live`; + +const CSRF_PROVIDER_EXAMPLE = `import { + ConstructiveConsoleKit, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/constructive'; +import type { ConsoleCsrfTokenProvider } from '@/blocks/console-runtime'; + +const csrfTokenProvider: ConsoleCsrfTokenProvider = async ({ databaseId, operation }) => { + const response = await fetch('/api/constructive/auth/csrf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ databaseId, operation }) + }); + if (!response.ok) throw new Error('CSRF bootstrap failed.'); + const payload = await response.json() as { csrfToken: string }; + return payload.csrfToken; +}; + +export function HardenedTenantConsole({ + database +}: Readonly<{ database: ConstructiveTenantDatabase }>) { + return ( + + ); +}`; + +const EMAIL_VERIFICATION_EXAMPLE = `'use client'; + +import { + ConstructiveConsoleKit, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/constructive'; + +export function VerificationConsole({ + database +}: Readonly<{ database: ConstructiveTenantDatabase }>) { + // Console Kit captures a supported callback from the current URL into its + // closure-owned credential vault and scrubs the credential before rendering. + return ; +}`; + +const ROUTING_EXAMPLE = `'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + ConstructiveConsoleKit, + type ConsoleKitRoute, + type ConstructiveTenantDatabase +} from '@/blocks/console-kit/constructive'; + +const getConsoleHref = (route: ConsoleKitRoute) => + \`/console?route=\${encodeURIComponent(JSON.stringify(route))}\`; + +export function RoutedConsole({ + database, + route +}: Readonly<{ + database: ConstructiveTenantDatabase; + route: ConsoleKitRoute; +}>) { + const router = useRouter(); + return ( + router.push(getConsoleHref(nextRoute)), + renderLink: ({ href, ...props }) => + }} + /> + ); +} + +export function InternalConsole({ + database +}: Readonly<{ database: ConstructiveTenantDatabase }>) { + // Omit routes.route so Console Kit owns its per-instance route. + return ( + + ); +}`; + +export default function ConsoleKitPage() { + return ( +
+
+

Application kit

+

+ Console Kit for Next.js +

+

+ {DESCRIPTION} Pass the secret-free tenant descriptor returned by + provisioning; the first-party wrapper handles standalone auth, + capability discovery, endpoint-scoped requests, and pack adapters. +

+
+ +
+
+
+

+ Product states +

+

+ Console Kit is a tenant-user console. Switch presets and signed-out, + discovering, partial, ready, incompatible, and unavailable states + without a live fixture. Missing public routes are expected degraded + states, not silent backend failures. +

+
+ +
+ +
+
+

+ Choose an install surface +

+

+ One matrix covers the umbrella, core, Console modules, standalone + packs, and official presets. Installed code never grants authority; + each surface only becomes interactive when public evidence supports it. +

+
+ + + Install surfaces, what they install, expected public evidence, and + degradation behavior. + + + + Surface + Installs + Public evidence + When evidence is absent + + + + {INSTALL_MATRIX.map((row) => ( + + + {row.surface} + + + {row.installs} + + + {row.evidence} + + + {row.degrades} + + + ))} + +
+
+ +
+
+

+ Install the full console +

+

+ One registry item installs the Console Kit, all seven feature + modules, the runtime and catalog contracts, the single modular + Zustand store, and the shadcn Base UI app shell dependency graph. +

+
+ + {registryAdd('console-kit-nextjs')} + + +
+

+ Install an official preset +

+

+ Each preset registry item installs the core and the exact feature + modules mapped to its Constructive DB preset. Runtime navigation + still follows the contracts discovered from the active tenant, + so installed code never grants a capability by itself. +

+
+
+ {[ + { + title: 'Auth hardened', + registryName: 'preset-auth-hardened', + modules: 'Data, Auth, Users' + }, + { + title: 'B2B with Storage', + registryName: 'preset-b2b-storage', + modules: 'Data, Auth, Users, Organizations, Storage' + }, + { + title: 'Full', + registryName: 'preset-full', + modules: 'All seven feature modules' + } + ].map((preset) => ( +
+

+ {preset.title} +

+

+ {preset.modules} +

+ + {registryAdd(preset.registryName)} + +
+ ))} +
+ +
+

+ Compose feature modules independently +

+

+ Install the leaf-independent core and only the Console Kit modules + your product needs. Every console module installs its standalone, + provider-neutral feature-pack view transitively, then adds + discovery, navigation, its Constructive adapter when available, + and any pack-owned store slice. Install a feature-pack item alone + when the host supplies the view props without Console Kit. +

+
+ + {[ + 'console-kit-core', + 'console-module-data', + 'console-module-auth', + 'console-module-users', + 'console-module-organizations', + 'console-module-storage', + 'console-module-billing', + 'console-module-notifications' + ].map(registryAdd).join('\n')} + + + {LOWER_LEVEL_EXAMPLE} + +
+ +
+
+

+ Start from the tenant endpoint contract +

+

+ The host supplies a database ID and its routable public GraphQL + endpoints; Console Kit does not need a preset name or a + control-plane receipt at runtime. Keep credentials on the user + side of the sign-in form, because the secret-free descriptor is + safe to pass from a server component to the client. A host-owned + session must carry the same database ID, which prevents identity + state from crossing between tenants. +

+
+ +
+ {[ + { + title: 'Endpoints', + badge: 'six semantic kinds', + body: 'Pass only routable data, auth, admin, billing, storage, and notifications endpoints; no sibling endpoint is inferred.' + }, + { + title: 'Session', + badge: 'standalone default', + body: 'The first-party wrapper scopes in-memory and browser session state to the database, then resolves a fresh token per request.' + }, + { + title: 'Capabilities', + badge: 'discovered at runtime', + body: 'Installed modules become available only when endpoint metadata, GraphQL operations, and the active identity support them.' + } + ].map((boundary) => ( +
+
+

+ {boundary.title} +

+ {boundary.badge} +
+

+ {boundary.body} +

+
+ ))} +
+ + + {CONSOLE_EXAMPLE} + + +
+

+ Use the lower-level Console Kit endpoint resolver for embedded or + non-Constructive providers. Endpoint fallback stays opt-in, so an + admin operation cannot silently cross onto the data endpoint. +

+ + {ENDPOINT_RESOLVER_EXAMPLE} + +
+ +
+

+ Bootstrap CSRF for hardened auth +

+

+ When a tenant enables{' '} + + require_csrf_for_auth + + , pass an async token provider. Its host endpoint creates a + short-lived anonymous session with a cryptographically random + secret through a trusted backend connection and returns that + secret once; Console Kit sends it only as the auth mutation's{' '} + + csrfToken + + . Constructive consumes the anonymous session after successful + authentication, so the provider must mint a fresh token for each + attempt and must never cache it in browser storage. +

+ + {CSRF_PROVIDER_EXAMPLE} + +
+ +
+

+ Complete email verification without exposing the token +

+

+ Unverified signed-in accounts can send a fresh verification + email from Account security. Your verification route reads the{' '} + + email_id + {' '} + and{' '} + + verification_token + {' '} + values from a URL fragment. When{' '} + + callback + {' '} + is omitted, Console Kit captures those credentials into its closure-owned + vault, scrubs the fragment, and then the auth adapter calls{' '} + + verifyEmail + {' '} + using the freshly delivered credential, including from a fresh signed-out + browser. After sign-in, Console Kit reloads the account so the + verified state is authoritative. Treat the token as a credential: + route these values through the URL's{' '} + + # + {' '} + fragment so they stay out of the HTTP request. Pass{' '} + + callback={'{false}'} + {' '} + only when the host owns the complete callback lifecycle. +

+ + {EMAIL_VERIFICATION_EXAMPLE} + +
+
+ +
+
+

+ One store, modular slices +

+

+ Each Console Kit instance creates one Zustand vanilla store and + composes the core navigation, tenant, session, endpoint, + discovery, runtime, and adapter slices with slices contributed by + installed feature modules. The store is scoped to the mounted + console, so server renders and multiple consoles cannot leak + identity or navigation state across instances. If the host owns + the store, it must compose the same module slices; Console Kit + verifies that composition and resets its scoped state when the + tenant or identity changes. +

+
+ + {STORE_EXAMPLE} + +
+ +
+
+
+

+ Metadata is a compatibility gate +

+ _meta 2026-07 +
+

+ The runtime checks every configured, reachable endpoint with + standard GraphQL introspection and Constructive's versioned{' '} + + _meta + {' '} + contract. This release accepts contract version{' '} + + 2026-07 + {' '} + only; each feature module selects its evidence from the endpoint + map, and incompatible metadata stops that feature instead of + guessing at tables, relations, or mutation shapes. +

+
+ +
+
    + {[ + { + title: 'Preflight', + body: 'Resolve each explicit semantic endpoint and read a fresh token from the active database-scoped session.' + }, + { + title: 'Discover', + body: 'Load _meta plus GraphQL root operations, types, and input objects independently for every reachable endpoint.' + }, + { + title: 'Expose', + body: 'Show a module only when its schema contract is exposed, then let PostgreSQL privileges and RLS decide each operation.' + } + ].map((step, index) => ( +
  1. + + {String(index + 1).padStart(2, '0')} + +

    + {step.title} +

    +

    + {step.body} +

    +
  2. + ))} +
+
+
+ +
+
+

+ Prove compatibility against native tenants +

+

+ Start the untouched Constructive DB services with{' '} + + fun up + + , then let the Blocks fixture call the native provisioning + procedure for the three official presets and one supported + storage-routed tenant. The generated secret-free manifest feeds + the integration route, while the live suite exercises auth, + identity-scoped CRUD, cross-tenant rejection, capability + discovery, and RLS through public GraphQL endpoints. Cleanup uses + only the exact tenant database IDs recorded by that run. +

+
+ + {NATIVE_PROOF_EXAMPLE} + +
+ +
+
+

+ Route-neutral app shell +

+

+ Console Kit composes the Constructive App Shell, App Bar, and the + shadcn Base UI Sidebar. The shell renders typed navigation, + breadcrumbs, brand, account actions, search, and action slots, but + links remain plain anchors until your host supplies a Next.js or + other framework renderer. +

+

+ Omit a controlled route to let Console Kit own navigation, optionally + starting from defaultRoute. + For URL-owned navigation, parse the current URL into a typed semantic + route and provide the route, href encoder, change callback, and link renderer together. +

+
+ + + {ROUTING_EXAMPLE} + + +
+ + {registryAdd('app-shell')} + +
+

+ Compose only what you need +

+

+ Use the shell directly for a custom application, install a + focused pack for one workflow, or start with Console Kit when + you want the complete discovery and navigation layer. +

+ + Browse feature packs + +
+
+
+
+
+ ); +} + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: withBase('/blocks/console-kit') }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: withBase('/blocks/console-kit'), + images: [OG_IMAGE] + } +}; diff --git a/apps/blocks/src/app/blocks/features/[pack]/page.tsx b/apps/blocks/src/app/blocks/features/[pack]/page.tsx new file mode 100644 index 0000000..8dcc667 --- /dev/null +++ b/apps/blocks/src/app/blocks/features/[pack]/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { FeaturePackDocsPage } from '@/components/feature-pack-showcase/feature-pack-docs-page'; +import { FEATURE_PACK_DOCS, getFeaturePackDoc } from '@/lib/feature-packs'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +type PageProps = { params: Promise<{ pack: string }> }; + +export default async function FeaturePackPage({ params }: PageProps) { + const { pack: packId } = await params; + const block = getFeaturePackDoc(packId); + if (!block) return notFound(); + + const index = FEATURE_PACK_DOCS.findIndex((item) => item.id === block.id); + const previous = index > 0 ? FEATURE_PACK_DOCS[index - 1] : undefined; + const next = index < FEATURE_PACK_DOCS.length - 1 ? FEATURE_PACK_DOCS[index + 1] : undefined; + + return ; +} + +export function generateStaticParams() { + return FEATURE_PACK_DOCS.map(({ id: pack }) => ({ pack })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { pack: packId } = await params; + const block = getFeaturePackDoc(packId); + if (!block) return {}; + + const title = `${block.title} feature pack`; + const url = withBase(`/blocks/features/${block.id}`); + return { + title, + description: block.description, + alternates: { canonical: url }, + openGraph: { + title, + description: block.description, + url, + images: [OG_IMAGE], + }, + }; +} diff --git a/apps/blocks/src/app/blocks/features/[pack]/preview/page.tsx b/apps/blocks/src/app/blocks/features/[pack]/preview/page.tsx new file mode 100644 index 0000000..04f76b7 --- /dev/null +++ b/apps/blocks/src/app/blocks/features/[pack]/preview/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { FeaturePackShowcaseEmbed } from '@/components/feature-pack-showcase/feature-pack-showcase-embed'; +import { FeaturePackPreviewLoading } from '@/components/feature-pack-showcase/feature-pack-preview-loading'; +import { FEATURE_PACK_DOCS, getFeaturePackDoc } from '@/lib/feature-packs'; +import { withBase } from '@/lib/site'; + +type PageProps = { params: Promise<{ pack: string }> }; + +export default async function FeaturePackPreviewPage({ params }: PageProps) { + const { pack: packId } = await params; + const block = getFeaturePackDoc(packId); + if (!block) return notFound(); + + return ( + + + + )}> + + + ); +} + +export function generateStaticParams() { + return FEATURE_PACK_DOCS.map(({ id: pack }) => ({ pack })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { pack: packId } = await params; + const block = getFeaturePackDoc(packId); + + return { + alternates: block ? { canonical: withBase(`/blocks/features/${block.id}`) } : undefined, + robots: { follow: false, index: false }, + }; +} diff --git a/apps/blocks/src/app/blocks/features/page.tsx b/apps/blocks/src/app/blocks/features/page.tsx new file mode 100644 index 0000000..67cc298 --- /dev/null +++ b/apps/blocks/src/app/blocks/features/page.tsx @@ -0,0 +1,183 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowUpRightIcon } from 'lucide-react'; + +import { Badge } from '@constructive-io/ui/badge'; + +import { CodeBlock } from '@/components/docs/code-block'; +import { FEATURE_PACK_DOCS, PRESET_PROFILE_DOCS } from '@/lib/feature-packs'; +import { registryAdd } from '@/lib/install-mode'; +import { OG_IMAGE, withBase } from '@/lib/site'; +import { cn } from '@/lib/utils'; + +const TITLE = 'Feature packs'; +const DESCRIPTION = + 'Capability-aligned Constructive UI packs for data, authentication, users, organizations, storage, billing, and notifications.'; + +const FEATURE_PACK_INSTALLS = FEATURE_PACK_DOCS.map((pack) => registryAdd(pack.registryName)).join('\n'); + +export default function FeaturePacksPage() { + return ( +
+
+

Application blocks

+

Feature packs

+

+ {DESCRIPTION} Each registry item copies its presentational views, provider-neutral contracts, and a + machine-readable manifest into your project, so the UI and the database preset can describe the same + capability boundary. +

+
+ +
+
+
+

+ The contract between a preset and its UI +

+

+ A manifest declares dependencies, endpoint kinds, required and optional capabilities, and the metadata + sections a feature needs. Adapters bind those contracts to live resources and actions, while PostgreSQL + grants and RLS remain the authorization boundary for every request. +

+
+ +
+ {[ + { + step: '1', + title: 'Discover', + body: 'The console checks the versioned _meta contract and standard GraphQL introspection before exposing data-backed features.', + }, + { + step: '2', + title: 'Resolve', + body: 'The manifest matches the available endpoint kinds and capabilities to the feature pack’s requirements.', + }, + { + step: '3', + title: 'Adapt', + body: 'The host injects resources and actions, so provider and deployment details stay outside the reusable UI.', + }, + ].map((item) => ( +
+ {item.step.padStart(2, '0')} +

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+

+ First-release catalog +

+

+ Install one pack when you are composing a focused surface. The registry resolves its declared pack + dependencies automatically. +

+
+ +
    + {FEATURE_PACK_DOCS.map((pack) => ( +
  • + +
    +
    +

    {pack.title}

    +

    {pack.id}

    +
    + + {pack.endpoints} + +
    +

    {pack.description}

    +
    +
    +
    Feature dependencies
    +
    + {pack.dependencies.length > 0 ? pack.dependencies.join(', ') : 'None'} +
    +
    +
    +
    Registry
    +
    @constructive/{pack.registryName}
    +
    +
    + +
  • + ))} +
+ + + {FEATURE_PACK_INSTALLS} + +
+ +
+
+
+

+ Preset profiles +

+ Install profiles +
+

+ These profiles map the current backend preset slugs to their stable frontend module compositions. + Installing a profile copies its manifest and transitive Console Kit modules, but it does not provision or + migrate a database; runtime availability still comes from endpoint discovery, PostgreSQL grants, and RLS. +

+
+ +
+
    + {PRESET_PROFILE_DOCS.map((preset) => ( +
  • +
    +

    {preset.title}

    +

    {preset.presetSlug}

    +
    +
    +

    {preset.featurePacks.join(' · ')}

    +

    + {registryAdd(preset.registryName)} +

    +
    +
  • + ))} +
+
+
+
+
+ ); +} + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: withBase('/blocks/features') }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: withBase('/blocks/features'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/page.tsx b/apps/blocks/src/app/blocks/page.tsx index 3277495..40bd49b 100644 --- a/apps/blocks/src/app/blocks/page.tsx +++ b/apps/blocks/src/app/blocks/page.tsx @@ -35,6 +35,49 @@ export default function SetupPage() { }} /> +
+
+

+ Application blocks +

+

+ Start with a capability-aligned feature pack or install the full + Next.js console with its route-neutral app shell and dynamic data + explorer. +

+
+
    + {[ + { + href: '/blocks/features', + title: 'Feature packs', + description: + 'Data, authentication, users, organizations, storage, billing, and notifications.' + }, + { + href: '/blocks/console-kit', + title: 'Console Kit for Next.js', + description: + 'A full-page console driven by injected endpoints, session state, adapters, and versioned _meta.' + } + ].map((item) => ( +
  • + + + {item.title} + + + {item.description} + + +
  • + ))} +
+
+

diff --git a/apps/blocks/src/app/globals.css b/apps/blocks/src/app/globals.css index 75bb8e8..a7c36e2 100644 --- a/apps/blocks/src/app/globals.css +++ b/apps/blocks/src/app/globals.css @@ -9,7 +9,7 @@ html { body { text-rendering: optimizeLegibility; font-family: var(--font-sans-loaded, var(--font-sans)); - letter-spacing: -0.01em; + font-synthesis: none; } ::selection { diff --git a/apps/blocks/src/app/layout.tsx b/apps/blocks/src/app/layout.tsx index 840d076..d6046cf 100644 --- a/apps/blocks/src/app/layout.tsx +++ b/apps/blocks/src/app/layout.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react'; import { PortalRoot } from '@constructive-io/ui/portal'; import { RegistryShell } from '@/components/site/registry-shell'; +import { SkipLink } from '@/components/site/skip-link'; import { ThemeProvider } from '@/components/site/theme-provider'; import { OG_IMAGE, SITE_NAME, SITE_ORIGIN, withBase } from '@/lib/site'; @@ -18,7 +19,7 @@ const openSans = Open_Sans({ const SITE_TITLE = 'Constructive Blocks'; const SITE_DESCRIPTION = - 'A shadcn-compatible registry of Constructive UI primitives — npm or source install.'; + 'A shadcn-compatible registry of Constructive UI primitives, feature packs, billing blocks, and Console Kit.'; export const metadata: Metadata = { metadataBase: new URL(SITE_ORIGIN), @@ -42,15 +43,8 @@ export default function RootLayout({ children }: Readonly<{ children: ReactNode - - Skip to content - - -
{children}
-
+ + {children} {/* Optional shared host keeps docs overlays within one predictable layer. Package and registry consumers fall back to the nearest portal or body. */} diff --git a/apps/blocks/src/app/not-found.tsx b/apps/blocks/src/app/not-found.tsx index 4cf9b1e..717571f 100644 --- a/apps/blocks/src/app/not-found.tsx +++ b/apps/blocks/src/app/not-found.tsx @@ -8,7 +8,7 @@ export default function NotFound() {

404

Page not found

- The requested page is not part of the base primitive catalog. + The requested page is not part of the published Blocks documentation.

diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index 932595d..a4a9ca1 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -2,25 +2,25 @@ import { describe, expect, it } from 'vitest'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; +import { FEATURE_PACK_DOCS } from '@/lib/feature-packs'; import sitemap from './sitemap'; describe('sitemap', () => { - it('contains foundations, 29 primitives, and the complete billing catalog', () => { + it('contains foundations, application docs, seven feature packs, 29 primitives, and the complete billing catalog', () => { const entries = sitemap(); expect(BASE_PRIMITIVES).toHaveLength(29); - expect(entries).toHaveLength( - BASE_PRIMITIVES.length + BILLING_BLOCKS.length + 4 - ); + expect(entries).toHaveLength(BASE_PRIMITIVES.length + FEATURE_PACK_DOCS.length + BILLING_BLOCKS.length + 6); expect(entries.map(({ url }) => url)).toEqual([ 'http://localhost:3005/', 'http://localhost:3005/blocks', 'http://localhost:3005/blocks/styling', + 'http://localhost:3005/blocks/features', + ...FEATURE_PACK_DOCS.map(({ id }) => `http://localhost:3005/blocks/features/${id}`), + 'http://localhost:3005/blocks/console-kit', ...BASE_PRIMITIVES.map(({ name }) => `http://localhost:3005/blocks/ui/${name}`), 'http://localhost:3005/blocks/billing', - ...BILLING_BLOCKS.map( - ({ name }) => `http://localhost:3005/blocks/billing/${name}` - ), + ...BILLING_BLOCKS.map(({ name }) => `http://localhost:3005/blocks/billing/${name}`), ]); }); }); diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index 74e5d81..c918951 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -2,6 +2,7 @@ import type { MetadataRoute } from 'next'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; +import { FEATURE_PACK_DOCS } from '@/lib/feature-packs'; import { BASE_PATH, SITE_ORIGIN, withBase } from '@/lib/site'; export const dynamic = 'force-static'; @@ -11,6 +12,9 @@ export default function sitemap(): MetadataRoute.Sitemap { '/', '/blocks', '/blocks/styling', + '/blocks/features', + ...FEATURE_PACK_DOCS.map(({ id }) => `/blocks/features/${id}`), + '/blocks/console-kit', ...BASE_PRIMITIVES.map(({ name }) => `/blocks/ui/${name}`), '/blocks/billing', ...BILLING_BLOCKS.map(({ name }) => `/blocks/billing/${name}`), @@ -25,6 +29,8 @@ export default function sitemap(): MetadataRoute.Sitemap { ? 1 : path === '/blocks' || path === '/blocks/styling' || + path === '/blocks/features' || + path === '/blocks/console-kit' || path === '/blocks/billing' ? 0.9 : 0.7, diff --git a/apps/blocks/src/blocks/auth/account-api-keys-list/account-api-keys-list.tsx b/apps/blocks/src/blocks/auth/account-api-keys-list/account-api-keys-list.tsx deleted file mode 100644 index 9653f0f..0000000 --- a/apps/blocks/src/blocks/auth/account-api-keys-list/account-api-keys-list.tsx +++ /dev/null @@ -1,382 +0,0 @@ -'use client'; - -/** - * account-api-keys-list (registry: auth-account-api-keys-list) - * - * Displays the signed-in user's API keys and provides revoke + create actions. - * Because `user_api_keys` is a `constructive_auth_private` view with NO public - * API, there is no generated list hook — the list is supplied by the host via the - * `keys` adapter prop (default: empty array, renders the empty state). - * Only the `revokeApiKey` mutation is bindable today via `useRevokeApiKeyMutation` - * from `@/generated/auth`. - * - * SDK gap: no `useUserApiKeysQuery` hook exists (sdk-binding-contract.md §10). - * When a `UserApiKeysConnection` ships, add `useUserApiKeysQuery` from - * `@/generated/auth` and update `requires.json` with `"queries":["userApiKeys"]`. - * - * Key creation: delegated to `ApiKeyCreateDialog` (auth-api-key-create-dialog), - * which enforces step-up tier:'high' internally. After creation, `ApiKeyCreatedModal` - * (auth-api-key-created-modal) shows the one-time raw key. - * - * Revocation: single revoke uses a confirmation dialog (no step-up — spec §Step-up). - * - * Binding doctrine (sdk-binding-contract.md §3, §5): - * • Generated hook imported from `@/generated/auth` — never `@constructive-io/data`. - * • No `configure()`/`getClient()`, no `QueryClientProvider`. Host mounts blocks-runtime. - * • `onRevokeSubmit` override seam replaces the default hook call. - */ - -import { useState } from 'react'; - -import { Badge } from '@constructive-io/ui/badge'; -import { Button } from '@constructive-io/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@constructive-io/ui/dialog'; -import { Separator } from '@constructive-io/ui/separator'; - -import { cn } from '@/lib/utils'; -import { useRevokeApiKeyMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { ApiKeyCreateDialog, type ApiKeyCreatedResult } from '@/blocks/auth/api-key-create-dialog/api-key-create-dialog'; -import { ApiKeyCreatedModal } from '@/blocks/auth/api-key-created-modal/api-key-created-modal'; - -import { - defaultAccountApiKeysListMessages, - type AccountApiKeysListMessages, - type AccountApiKeysListMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export type { ApiKeyCreatedResult }; - -/** - * A single API key row. The host supplies rows from whatever list source it has. - * There is NO generated list hook for `user_api_keys` (private view, no public API - * → no `*Connection` type). sdk-binding-contract.md §10 documents this gap. - */ -export type ApiKeyRow = { - id: string; - name: string; - /** First visible chars of the raw key, stored at creation time. */ - keyPrefix: string; - accessLevel: string; - mfaLevel: string; - lastUsedAt: string | null; - expiresAt: string | null; - createdAt: string; -}; - -/** Variables passed to the `onRevokeSubmit` override. */ -export type RevokeApiKeyVars = { - keyId: string; -}; - -/** Result shape; mirrors the `revokeApiKey` payload fields this block selects. */ -export type RevokeApiKeyResult = { - result: boolean | null; -}; - -export type AccountApiKeysListProps = { - /** - * The list of API keys to display. There is NO generated list hook for - * `user_api_keys` (it is in `constructive_auth_private`, no public API → - * no `*Connection` type). The host must supply rows; the default is `[]` - * which renders the empty state. - * - * sdk-binding-contract.md §10 documents this gap. - */ - keys?: ApiKeyRow[]; - /** Maximum number of API keys allowed per user. Used to gate the create button. */ - maxKeys?: number; - /** Override the `useRevokeApiKeyMutation` call. */ - onRevokeSubmit?: (vars: RevokeApiKeyVars) => Promise; - /** Fires after a key is successfully revoked. Always fires. */ - onKeyRevoked?: (keyId: string) => void; - /** Fires after `auth-api-key-create-dialog` succeeds. Always fires. */ - onKeyCreated?: (result: ApiKeyCreatedResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - messages?: AccountApiKeysListMessageOverrides; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Format ISO date for display. */ -function formatDate(iso: string | null, fallback: string): string { - if (!iso) return fallback; - try { - return new Date(iso).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - } catch { - return fallback; - } -} - -/** Returns true if expiresAt is in the past. */ -function isExpired(expiresAt: string | null): boolean { - if (!expiresAt) return false; - return new Date(expiresAt).getTime() < Date.now(); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountApiKeysList({ - keys = [], - maxKeys, - onRevokeSubmit: onRevokeSubmitOverride, - onKeyRevoked, - onKeyCreated, - onError, - onMessage, - messages: messageOverrides, - className -}: AccountApiKeysListProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountApiKeysListMessages = { - ...defaultAccountApiKeysListMessages, - ...messageOverrides, - errors: { ...defaultAccountApiKeysListMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook — `revokeApiKey` takes `{ input: { keyId } }`. - // Payload: `{ revokeApiKey: { result: boolean | null } | null }`. - const defaultRevokeMutation = useRevokeApiKeyMutation({ - selection: { - fields: { result: true } - } - }); - - // Hybrid pending: override path tracks its own pending state. - const [overridePending, setOverridePending] = useState(false); - const isRevokePending = onRevokeSubmitOverride ? overridePending : defaultRevokeMutation.isPending; - - // Confirmation dialog state - const [confirmKey, setConfirmKey] = useState(null); - const [error, setError] = useState(null); - - // Create dialog state - const [createOpen, setCreateOpen] = useState(false); - - // Created modal state — holds the raw key after creation (shown once) - const [pendingCreatedKey, setPendingCreatedKey] = useState(null); - - const isMaxReached = maxKeys !== undefined && keys.length >= maxKeys; - - async function runRevoke(keyId: string): Promise { - if (onRevokeSubmitOverride) return onRevokeSubmitOverride({ keyId }); - const data = await defaultRevokeMutation.mutateAsync({ input: { keyId } }); - if (!data.revokeApiKey) return null; - return { result: data.revokeApiKey.result ?? null }; - } - - async function handleRevoke(key: ApiKeyRow) { - setError(null); - if (onRevokeSubmitOverride) setOverridePending(true); - try { - await runRevoke(key.id); - setConfirmKey(null); - onMessage?.({ kind: 'success', key: 'revokeApiKey.success', message: merged.keyRevokedMessage }); - onKeyRevoked?.(key.id); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const errKey = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key: errKey, message }); - onError?.({ message, code: errKey }); - } finally { - if (onRevokeSubmitOverride) setOverridePending(false); - } - } - - function handleCreateSuccess(result: ApiKeyCreatedResult) { - // Close create dialog and open created-modal with the raw key. - setCreateOpen(false); - setPendingCreatedKey(result); - onKeyCreated?.(result); - onMessage?.({ kind: 'success', key: 'createApiKey.success' }); - } - - function handleCreatedModalDismissed() { - setPendingCreatedKey(null); - } - - return ( - - -
-
- {merged.title} - {merged.description} -
- -
- {isMaxReached && ( -

- {merged.maxKeysReached} -

- )} -
- - - - - {keys.length === 0 ? ( -

{merged.noKeysDescription}

- ) : ( -
    - {keys.map((key, idx) => { - const expired = isExpired(key.expiresAt); - const expiryLabel = key.expiresAt - ? expired - ? merged.expired - : formatDate(key.expiresAt, merged.noExpiry) - : merged.noExpiry; - const lastUsedLabel = formatDate(key.lastUsedAt, merged.neverUsed); - - return ( -
  • - {idx > 0 && } -
    -
    -
    - - {key.name} - - {expired && ( - - {merged.expired} - - )} -
    - - {key.keyPrefix}… - -
    - - {merged.accessLevelHeader}: {key.accessLevel} - - - {merged.lastUsedHeader}: {lastUsedLabel} - - - {merged.expiresHeader}: {expiryLabel} - -
    -
    - - -
    -
  • - ); - })} -
- )} -
- - {/* Revoke confirmation dialog */} - { - if (!open) setConfirmKey(null); - }} - > - - - {merged.revokeConfirmTitle} - {merged.revokeConfirmDescription} - - - - - - { - if (confirmKey) handleRevoke(confirmKey); - }} - data-testid="revoke-confirm-button" - > - {merged.revokeConfirmButton} - - - - - - {/* Create API key dialog (step-up is handled inside ApiKeyCreateDialog) */} - - - {/* Created-modal: shows the one-time raw key after creation */} - {pendingCreatedKey && ( - { - if (!open) setPendingCreatedKey(null); - }} - apiKey={pendingCreatedKey.rawKey} - keyName={pendingCreatedKey.name} - expiresAt={pendingCreatedKey.expiresAt} - onDismissed={handleCreatedModalDismissed} - /> - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-api-keys-list/auth-account-api-keys-list.requires.json b/apps/blocks/src/blocks/auth/account-api-keys-list/auth-account-api-keys-list.requires.json deleted file mode 100644 index abaf575..0000000 --- a/apps/blocks/src/blocks/auth/account-api-keys-list/auth-account-api-keys-list.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["revokeApiKey"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-api-keys-list/messages.ts b/apps/blocks/src/blocks/auth/account-api-keys-list/messages.ts deleted file mode 100644 index 5a80557..0000000 --- a/apps/blocks/src/blocks/auth/account-api-keys-list/messages.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * account-api-keys-list — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * NOTE: The list-query surface is out-of-frontend-scope (sdk-binding-contract.md §10). - * Only the `revokeApiKey` mutation is bindable today. The host supplies rows via - * the `keys` adapter prop; the default is `[]` which renders the empty state. - */ - -export type AccountApiKeysListMessages = { - title: string; - description: string; - createButton: string; - nameHeader: string; - prefixHeader: string; - accessLevelHeader: string; - lastUsedHeader: string; - expiresHeader: string; - revokeButton: string; - revokeConfirmTitle: string; - revokeConfirmDescription: string; - revokeConfirmButton: string; - revokeCancelButton: string; - keyRevokedMessage: string; - neverUsed: string; - noExpiry: string; - expired: string; - maxKeysReached: string; - noKeysDescription: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: top-level copy is shallow-partial; `errors` is - * itself partial so a host can localize a single error code without restating - * the whole map. - */ -export type AccountApiKeysListMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAccountApiKeysListMessages: AccountApiKeysListMessages = { - title: 'API keys', - description: 'API keys allow programmatic access to your account. Treat them like passwords.', - createButton: 'Create API key', - nameHeader: 'Name', - prefixHeader: 'Key', - accessLevelHeader: 'Access', - lastUsedHeader: 'Last used', - expiresHeader: 'Expires', - revokeButton: 'Revoke', - revokeConfirmTitle: 'Revoke API key?', - revokeConfirmDescription: 'This key will stop working immediately. This action cannot be undone.', - revokeConfirmButton: 'Revoke key', - revokeCancelButton: 'Cancel', - keyRevokedMessage: 'API key revoked.', - neverUsed: 'Never', - noExpiry: 'No expiry', - expired: 'Expired', - maxKeysReached: 'Maximum number of API keys reached.', - noKeysDescription: 'No API keys yet. Create one to get started.', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-connected-accounts/account-connected-accounts.tsx b/apps/blocks/src/blocks/auth/account-connected-accounts/account-connected-accounts.tsx deleted file mode 100644 index 0bcaf31..0000000 --- a/apps/blocks/src/blocks/auth/account-connected-accounts/account-connected-accounts.tsx +++ /dev/null @@ -1,436 +0,0 @@ -'use client'; - -/** - * account-connected-accounts (registry: auth-account-connected-accounts) - * - * Settings card listing linked OAuth providers with a disconnect action. - * Also renders "Connect [provider]" links for configured providers that are - * not yet linked. The disconnect action is gated behind a step-up (tier:medium) - * identity re-verification dialog. - * - * Binding doctrine (sdk-binding-contract.md, MASTER-PROMPT §5): - * • Data path = `useDisconnectAccountMutation` from `@/generated/auth`, called - * with a `selection` field-picker. No fetch, no GraphQL document string, no - * hardcoded URL, no `@constructive-io/data`. - * • NO client bootstrap: never calls `configure()`/`getClient()`, never mounts - * ``. The host's `@constructive/blocks-runtime` does that. - * • Override seam: `onSubmitDisconnect` fully replaces the generated-hook call. - * • Error mapping via `parseGraphQLError` from the `auth-errors` foundation lib. - * • Connected-account list and identity-provider list are CONDITIONAL: the spec - * confirms no public Connection types exist yet (sdk-binding-contract.md §10). - * The block accepts `connectedAccounts` and `providers` as props (static data - * supplied by the host) until the backend exposes Connection types. If the - * host omits them, the block renders an empty but valid state. - * - * Step-up flow: - * Disconnect button → confirmation dialog → step-up (tier: medium) → mutation. - * If step-up is cancelled, the confirmation dialog re-opens silently. - */ - -import { useState } from 'react'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Separator } from '@constructive-io/ui/separator'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; -import { Avatar, AvatarFallback } from '@constructive-io/ui/avatar'; - -import { cn } from '@/lib/utils'; -import { useDisconnectAccountMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; - -import { - defaultAccountConnectedAccountsMessages, - type AccountConnectedAccountsMessageOverrides, - type AccountConnectedAccountsMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** A linked OAuth account row. */ -export type ConnectedAccountRow = { - /** Internal record id (uuid). */ - id: string; - /** Provider slug, e.g. 'google', 'github', 'apple'. */ - service: string; - /** Display name for the linked identity (email or username). */ - identifier: string; - /** Whether the OAuth identity has been verified. */ - isVerified: boolean; - /** ISO timestamp when the link was created. */ - createdAt: string; -}; - -/** - * An identity provider that is configured but may not be linked. - * Coordinate this shape with auth-social-providers-grid. - */ -export type IdentityProvider = { - id: string; - /** Provider slug, e.g. 'google', 'github'. */ - slug: string; - /** Human-readable display name shown in the UI. */ - displayName: string; - kind: 'oidc' | 'oauth2'; - enabled: boolean; -}; - -/** - * Variables the disconnect call receives. - * The override `onSubmitDisconnect` gets these verbatim. - */ -export type DisconnectAccountVars = { - accountId: string; -}; - -/** Result returned by the disconnect call. */ -export type DisconnectAccountResult = { - success: boolean; -}; - -export type AccountConnectedAccountsProps = { - /** - * Pre-fetched connected account rows. - * When omitted, the block renders an empty connected list (no Connection query - * exists yet — sdk-binding-contract.md §10 FLAG). The host supplies these from - * its own query until a public ConnectedAccountsConnection type is confirmed. - */ - connectedAccounts?: ConnectedAccountRow[]; - /** - * Static list of identity providers to render "Connect" links for. - * Providers already in `connectedAccounts` are shown as connected; others as - * "not connected". When omitted, falls back to an empty list. - */ - providers?: IdentityProvider[]; - /** - * Base URL for initiating an OAuth connection flow. - * The block appends `?provider=&action=connect` to this URL. - * Default: '/auth/social'. - */ - oauthRedirectBase?: string; - messages?: AccountConnectedAccountsMessageOverrides; - /** Replace the default `useDisconnectAccountMutation` call. */ - onSubmitDisconnect?: (vars: DisconnectAccountVars) => Promise; - /** Fires after a successful disconnect. Always fires. */ - onAccountDisconnected?: (accountId: string, provider: string) => void; - /** Fires when the host signals a successful OAuth connection back to the block. */ - onAccountConnected?: (provider: string) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Provider initials fallback for the avatar. */ -function providerInitials(slug: string): string { - return slug.slice(0, 2).toUpperCase(); -} - -/** Build an OAuth connect URL from the base + provider slug. */ -function connectUrl(base: string, slug: string): string { - const sep = base.includes('?') ? '&' : '?'; - return `${base}${sep}provider=${encodeURIComponent(slug)}&action=connect`; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountConnectedAccounts({ - connectedAccounts = [], - providers = [], - oauthRedirectBase = '/auth/social', - messages: messageOverrides, - onSubmitDisconnect: onSubmitDisconnectOverride, - onAccountDisconnected, - onAccountConnected: _onAccountConnected, - onError, - onMessage, - className -}: AccountConnectedAccountsProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountConnectedAccountsMessages = { - ...defaultAccountConnectedAccountsMessages, - ...messageOverrides, - errors: { ...defaultAccountConnectedAccountsMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook from the host's `auth` SDK. - // Payload shape (verified from DisconnectAccountPayload): - // { disconnectAccount: { clientMutationId?: string|null; result?: boolean|null } | null } - const defaultMutation = useDisconnectAccountMutation({ - selection: { fields: { result: true } } - }); - - // Hybrid pending: generated hook tracks its own; the override path does not. - const [overridePending, setOverridePending] = useState(false); - // Step-up pending: prevents double-clicking confirm while step-up is awaiting user input. - const [stepUpPending, setStepUpPending] = useState(false); - const isPending = stepUpPending || (onSubmitDisconnectOverride ? overridePending : defaultMutation.isPending); - - // Step-up hook (tier: medium — password re-verification, per step-up-contract §6). - const stepUp = useStepUp(); - - // Row-level error state — shown inline above the list. - const [rowError, setRowError] = useState(null); - - // Disconnect confirmation state. - const [confirmTarget, setConfirmTarget] = useState(null); - - // --------------------------------------------------------------------------- - // Derived data — build a unified provider list sorted: connected first, - // then unconnected; each group alphabetical by service/slug. - // --------------------------------------------------------------------------- - - const connectedIds = new Set(connectedAccounts.map((a) => a.service)); - - // Rows for connected accounts that have provider metadata. - const connectedRows = connectedAccounts - .map((account) => { - const meta = providers.find((p) => p.slug === account.service); - return { account, meta }; - }) - .sort((a, b) => a.account.service.localeCompare(b.account.service)); - - // Providers not yet connected. - const unconnectedProviders = providers - .filter((p) => p.enabled && !connectedIds.has(p.slug)) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - const isEmpty = connectedRows.length === 0 && unconnectedProviders.length === 0; - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - - /** Called after the confirmation dialog is confirmed — gates on step-up. */ - async function handleDisconnectConfirm() { - if (!confirmTarget) return; - const target = confirmTarget; - setRowError(null); - - try { - // Step-up: tier medium → password re-verification (step-up-contract.md §6). - // setStepUpPending guards against double-click during the step-up modal. - setStepUpPending(true); - await stepUp({ tier: 'medium' }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - // User cancelled step-up — re-show the confirmation dialog silently. - setConfirmTarget(target); - return; - } - // Unexpected step-up failure — surface as error. - const message = merged.errors.UNKNOWN_ERROR; - const code = 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key: code, message }); - onError?.({ message, code }); - return; - } finally { - // Always clear step-up pending (runs before any early return too). - setStepUpPending(false); - } - - // Step-up passed — execute the disconnect. - if (onSubmitDisconnectOverride) setOverridePending(true); - try { - const vars: DisconnectAccountVars = { accountId: target.id }; - - if (onSubmitDisconnectOverride) { - await onSubmitDisconnectOverride(vars); - } else { - const data = await defaultMutation.mutateAsync({ input: vars }); - const success = data.disconnectAccount?.result ?? false; - if (!success) { - throw Object.assign(new Error('Disconnect returned false'), { - extensions: { code: 'UNKNOWN_ERROR' } - }); - } - } - - setConfirmTarget(null); - onMessage?.({ kind: 'success', key: 'disconnectAccount.success', message: merged.disconnectedToast }); - onAccountDisconnected?.(target.id, target.service); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - setConfirmTarget(null); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitDisconnectOverride) setOverridePending(false); - } - } - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- - - return ( - <> - - - {merged.title} - {merged.description} - - - - {rowError && ( -
- -
- )} - - {isEmpty ? ( -

- {merged.noProvidersMessage} -

- ) : ( -
    - {/* Connected accounts */} - {connectedRows.map(({ account, meta }, idx) => { - const displayName = meta?.displayName ?? account.service; - return ( -
  • - {idx > 0 && } -
    - - - {providerInitials(account.service)} - - - -
    - - {displayName} - - - {account.identifier} - -
    - -
    - {account.isVerified ? ( - - {merged.verifiedBadge} - - ) : ( - - {merged.connectedLabel} - - )} - -
    -
    -
  • - ); - })} - - {/* Unconnected providers */} - {unconnectedProviders.map((provider, idx) => ( -
  • - {(connectedRows.length > 0 || idx > 0) && } -
    - - - {providerInitials(provider.slug)} - - - -
    - - {provider.displayName} - - - {merged.notConnectedLabel} - -
    - - -
    -
  • - ))} -
- )} -
-
- - {/* Disconnect confirmation dialog */} - { - if (!isOpen) setConfirmTarget(null); - }} - > - - - {merged.disconnectConfirmTitle} - {merged.disconnectConfirmDescription} - - - - - - {merged.disconnectConfirmButton} - - - - - - ); -} diff --git a/apps/blocks/src/blocks/auth/account-connected-accounts/auth-account-connected-accounts.requires.json b/apps/blocks/src/blocks/auth/account-connected-accounts/auth-account-connected-accounts.requires.json deleted file mode 100644 index 559fbc0..0000000 --- a/apps/blocks/src/blocks/auth/account-connected-accounts/auth-account-connected-accounts.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["disconnectAccount"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-connected-accounts/messages.ts b/apps/blocks/src/blocks/auth/account-connected-accounts/messages.ts deleted file mode 100644 index 4635dc6..0000000 --- a/apps/blocks/src/blocks/auth/account-connected-accounts/messages.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * account-connected-accounts — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localises any code by overriding a single key. - * - * The override type uses a deep-partial so callers can override a single error - * code without restating the entire map. - */ - -export type AccountConnectedAccountsMessages = { - title: string; - description: string; - connectedLabel: string; - notConnectedLabel: string; - disconnectButton: string; - connectButton: (providerName: string) => string; - disconnectConfirmTitle: string; - disconnectConfirmDescription: string; - disconnectConfirmButton: string; - disconnectCancelButton: string; - disconnectedToast: string; - verifiedBadge: string; - loadingLabel: string; - noProvidersMessage: string; - errors: { - LAST_AUTH_METHOD: string; - UNKNOWN_ERROR: string; - }; -}; - -export type AccountConnectedAccountsMessageOverrides = Partial< - Omit -> & { - connectButton?: (providerName: string) => string; - errors?: Partial; -}; - -export const defaultAccountConnectedAccountsMessages: AccountConnectedAccountsMessages = { - title: 'Connected accounts', - description: 'Link third-party accounts for sign-in and data access.', - connectedLabel: 'Connected', - notConnectedLabel: 'Not connected', - disconnectButton: 'Disconnect', - connectButton: (name) => `Connect ${name}`, - disconnectConfirmTitle: 'Disconnect account?', - disconnectConfirmDescription: 'You will no longer be able to sign in with this account.', - disconnectConfirmButton: 'Disconnect', - disconnectCancelButton: 'Cancel', - disconnectedToast: 'Account disconnected.', - verifiedBadge: 'Verified', - loadingLabel: 'Loading…', - noProvidersMessage: 'No identity providers are configured.', - errors: { - LAST_AUTH_METHOD: - 'Cannot disconnect your only sign-in method. Add a password or another account first.', - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-danger-card/account-danger-card.tsx b/apps/blocks/src/blocks/auth/account-danger-card/account-danger-card.tsx deleted file mode 100644 index 0cb70f0..0000000 --- a/apps/blocks/src/blocks/auth/account-danger-card/account-danger-card.tsx +++ /dev/null @@ -1,222 +0,0 @@ -'use client'; - -/** - * account-danger-card (registry: auth-account-danger-card) - * - * Danger zone card that initiates the account-deletion flow. The flow is: - * 1. User clicks "Delete account" → confirmation dialog opens. - * 2. User clicks "Send deletion email" in dialog → step-up tier:high fires. - * 3. Step-up resolves → `sendAccountDeletionEmail` mutation is called. - * 4. Success → dialog closes; card shows inline success state. - * - * Data path: `useSendAccountDeletionEmailMutation` from `@/generated/auth`. - * The hook name is VERIFIED against the generated SDK source. Input shape is - * `{ input: SendAccountDeletionEmailInput }` where the input is empty (only - * optional `clientMutationId`). Payload: `{ sendAccountDeletionEmail: { result } }`. - * - * Step-up: `useStepUp()` from the `use-step-up` registry block, tier: 'high'. - * If the user cancels step-up the dialog re-opens (returns to confirm state). - * - * Binding rules (sdk-binding-contract.md §11 — ALL honoured): - * • Generated hook only; no fetch, no doc string, no configure()/getClient(). - * • No QueryClientProvider / QueryClient in this file. - * • Override seam: `onSubmit` fully replaces the mutation call. - * • `requires.json` co-located. - */ - -import { useState } from 'react'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@constructive-io/ui/dialog'; - -import { cn } from '@/lib/utils'; -import { useSendAccountDeletionEmailMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; - -import { defaultAccountDangerCardMessages, type AccountDangerCardMessages, type AccountDangerCardMessageOverrides } from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AccountDangerCardProps = { - messages?: AccountDangerCardMessageOverrides; - /** Replace the default `useSendAccountDeletionEmailMutation` call. */ - onSubmit?: () => Promise; - /** Fires after `sendAccountDeletionEmail` succeeds. */ - onDeletionEmailSent?: () => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountDangerCard({ - messages: messageOverrides, - onSubmit: onSubmitOverride, - onDeletionEmailSent, - onError, - onMessage, - className -}: AccountDangerCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountDangerCardMessages = { - ...defaultAccountDangerCardMessages, - ...messageOverrides, - errors: { ...defaultAccountDangerCardMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook from the host's `auth` SDK. - // Payload: { sendAccountDeletionEmail: { result?: boolean | null } | null } - const defaultMutation = useSendAccountDeletionEmailMutation({ - selection: { - fields: { - result: true - } - } - }); - - // Hybrid pending: generated hook tracks its own; override path does not. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - const stepUp = useStepUp(); - - // Dialog state: 'closed' | 'confirm' (dialog open) | 'done' (email sent) - const [dialogOpen, setDialogOpen] = useState(false); - const [emailSent, setEmailSent] = useState(false); - const [error, setError] = useState(null); - - async function handleConfirm() { - setError(null); - try { - // Step-up gate: tier 'high' → MFA preferred, password fallback. - await stepUp({ tier: 'high', messages: { passwordDescription: merged.stepUpPrompt } }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - // Cancelled: keep the dialog open so the user can re-attempt. - onMessage?.({ kind: 'warning', key: 'STEP_UP_CANCELLED', message: merged.stepUpCancelled }); - return; - } - // Step-up error (not cancel) — close dialog, surface error. - setDialogOpen(false); - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - return; - } - - // Step-up passed — fire the mutation. - try { - if (onSubmitOverride) { - setOverridePending(true); - await onSubmitOverride(); - } else { - await defaultMutation.mutateAsync({ input: {} }).then((d) => d.sendAccountDeletionEmail); - } - - setDialogOpen(false); - setEmailSent(true); - onMessage?.({ kind: 'success', key: 'sendAccountDeletionEmail.success', message: merged.emailSentTitle }); - onDeletionEmailSent?.(); - } catch (err) { - setDialogOpen(false); - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - return ( - - - {merged.title} - {merged.description} - - - - - - {emailSent ? ( -
-

{merged.emailSentTitle}

-

{merged.emailSentDescription}

-
- ) : ( - - )} -
- - {/* Confirmation dialog */} - { if (!open) setDialogOpen(false); }}> - - - {merged.confirmDialogTitle} - {merged.confirmDialogDescription} - - -

{merged.confirmDialogBody}

- - - - - {merged.confirmButton} - - -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-danger-card/auth-account-danger-card.requires.json b/apps/blocks/src/blocks/auth/account-danger-card/auth-account-danger-card.requires.json deleted file mode 100644 index 53d72a3..0000000 --- a/apps/blocks/src/blocks/auth/account-danger-card/auth-account-danger-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["sendAccountDeletionEmail"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-danger-card/messages.ts b/apps/blocks/src/blocks/auth/account-danger-card/messages.ts deleted file mode 100644 index 0e2c067..0000000 --- a/apps/blocks/src/blocks/auth/account-danger-card/messages.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * account-danger-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localises any code by overriding a single key. - */ - -export type AccountDangerCardMessages = { - title: string; - description: string; - deleteButton: string; - confirmDialogTitle: string; - confirmDialogDescription: string; - confirmDialogBody: string; - confirmButton: string; - cancelButton: string; - stepUpPrompt: string; - emailSentTitle: string; - emailSentDescription: string; - stepUpCancelled: string; - loadingLabel: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -export type AccountDangerCardMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAccountDangerCardMessages: AccountDangerCardMessages = { - title: 'Danger zone', - description: 'Permanently delete your account and all associated data.', - deleteButton: 'Delete account', - confirmDialogTitle: 'Delete your account?', - confirmDialogDescription: 'This action cannot be undone. All your data will be permanently deleted.', - confirmDialogBody: 'We will send you a confirmation email. Click the link in that email to complete deletion.', - confirmButton: 'Send deletion email', - cancelButton: 'Cancel', - stepUpPrompt: 'Confirm your identity before deleting your account.', - emailSentTitle: 'Check your inbox', - emailSentDescription: - 'A confirmation email has been sent. Follow the link in the email to permanently delete your account.', - stepUpCancelled: 'Step-up verification cancelled.', - loadingLabel: 'Sending...', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page.tsx b/apps/blocks/src/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page.tsx deleted file mode 100644 index 808657e..0000000 --- a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page.tsx +++ /dev/null @@ -1,369 +0,0 @@ -'use client'; - -/** - * account-deletion-confirm-page (registry: auth-account-deletion-confirm-page) - * - * Next.js page that handles the /auth/delete-account?token=…&user_id=… link - * from the deletion confirmation email. Calls `confirmDeleteAccount` once on - * mount and renders three outcome states: - * - * • processing — spinner while the mutation is in-flight - * • success — account deleted; redirects to sign-in after 2 s - * • error — expired or invalid token (inline state, no redirect) - * - * Data path: generated hook `useConfirmDeleteAccountMutation` imported from - * `@/generated/auth`. No fetch, no GraphQL document string, no client bootstrap. - * - * Binding doctrine: sdk-binding-contract.md §3–§7 - * Canonical anatomy: MASTER-PROMPT §5 - * - * Editable constants after install: - * const DEFAULT_REDIRECT = '/auth/sign-in'; - * const ACCOUNT_SETTINGS_HREF = '/account/settings'; - * const REDIRECT_DELAY_MS = 2000; - */ - -import { useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { useConfirmDeleteAccountMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; - -import { - defaultAccountDeletionConfirmMessages, - type AccountDeletionConfirmMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Editable constants (installed page — consumer modifies these in place) -// --------------------------------------------------------------------------- -const DEFAULT_REDIRECT = '/auth/sign-in'; -const ACCOUNT_SETTINGS_HREF = '/account/settings'; -const REDIRECT_DELAY_MS = 2000; - -// --------------------------------------------------------------------------- -// Outcome states -// --------------------------------------------------------------------------- - -type ConfirmStatus = 'pending' | 'success' | 'expired' | 'invalid' | 'error'; - -// --------------------------------------------------------------------------- -// Error code → status mapping -// --------------------------------------------------------------------------- - -const EXPIRED_CODES = new Set(['TOKEN_EXPIRED', 'LINK_EXPIRED', 'DELETION_TOKEN_EXPIRED']); -const INVALID_CODES = new Set([ - 'TOKEN_INVALID', - 'LINK_INVALID', - 'INVALID_TOKEN', - 'TOKEN_ALREADY_USED', - 'DELETION_TOKEN_INVALID' -]); - -function codeToStatus(code: string | null): 'expired' | 'invalid' | 'error' { - if (code && EXPIRED_CODES.has(code)) return 'expired'; - if (code && INVALID_CODES.has(code)) return 'invalid'; - return 'error'; -} - -/** - * Extract the raw error code from an error object before parseGraphQLError - * may strip it (parseGraphQLError returns code: null for unknown codes). - * Used for status routing (expired vs invalid vs generic error). - */ -function extractRawCode(err: unknown): string | null { - if (!err || typeof err !== 'object') return null; - const e = err as Record; - // extensions.code (GraphQL format) - if (e.extensions && typeof e.extensions === 'object') { - const ext = e.extensions as Record; - if (typeof ext.code === 'string') return ext.code; - } - // .errors[0].extensions.code (GraphQLRequestError format) - if (Array.isArray(e.errors) && e.errors.length > 0) { - const first = e.errors[0] as Record; - if (first.extensions && typeof first.extensions === 'object') { - const ext = first.extensions as Record; - if (typeof ext.code === 'string') return ext.code; - } - } - return null; -} - -// --------------------------------------------------------------------------- -// Message overrides type -// --------------------------------------------------------------------------- - -export type AccountDeletionConfirmMessageOverrides = Partial> & { - errors?: Partial; -}; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -export type AccountDeletionConfirmPageProps = { - /** Deletion token read from URL query param `token`. */ - token: string; - /** User id read from URL query param `user_id`. */ - userId: string; - messages?: AccountDeletionConfirmMessageOverrides; - /** Path to redirect to after successful deletion. Defaults to `/auth/sign-in`. */ - redirectTo?: string; - /** - * Override the account settings href shown in the expired-token CTA. - * Defaults to `/account/settings`. - * v1 extension — not in the base spec `AccountDeletionConfirmViewProps`. - */ - accountSettingsHref?: string; - /** Replace the default `useConfirmDeleteAccountMutation` call. */ - onSubmit?: (vars: { userId: string; token: string }) => Promise; - /** Fires after successful deletion (before redirect). */ - onSuccess?: (result: { userId: string }) => void; - /** Fires on expired token error. */ - onExpired?: () => void; - /** Fires on invalid or already-used token error. */ - onInvalid?: () => void; - /** Fires after a mapped error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, errors, and non-fatal branches. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountDeletionConfirmPage({ - token, - userId, - messages: messageOverrides, - redirectTo = DEFAULT_REDIRECT, - accountSettingsHref = ACCOUNT_SETTINGS_HREF, - onSubmit: onSubmitOverride, - onSuccess, - onExpired, - onInvalid, - onError, - onMessage, - className -}: AccountDeletionConfirmPageProps) { - // Deep merge - const merged: AccountDeletionConfirmMessages = { - ...defaultAccountDeletionConfirmMessages, - ...messageOverrides, - errors: { ...defaultAccountDeletionConfirmMessages.errors, ...messageOverrides?.errors } - }; - - const router = useRouter(); - - // Track outcome state - const [status, setStatus] = useState('pending'); - const [errorMessage, setErrorMessage] = useState(null); - - // Generated hook — no client bootstrap, no provider - const defaultMutation = useConfirmDeleteAccountMutation({ - selection: { fields: { result: true } } - }); - - // Override pending state - const [overridePending, setOverridePending] = useState(false); - - // The irreversible confirmation request is shared across Strict Mode effect - // replays, while each effect setup owns whether it may commit the result. - const confirmationPromiseRef = useRef | null>(null); - - useEffect(() => { - let active = true; - let redirectTimer: ReturnType | null = null; - - // Guard: missing params → invalid state immediately, no API call - if (!userId || !token) { - setStatus('invalid'); - return () => { - active = false; - if (redirectTimer !== null) clearTimeout(redirectTimer); - }; - } - - async function startConfirmation(): Promise { - if (onSubmitOverride) { - setOverridePending(true); - return onSubmitOverride({ userId, token }); - } - - const data = await defaultMutation.mutateAsync({ input: { userId, token } }); - return data.confirmDeleteAccount?.result ?? null; - } - - confirmationPromiseRef.current ??= startConfirmation(); - const confirmationPromise = confirmationPromiseRef.current; - - void confirmationPromise - .then((deleted) => { - if (!active) return; - if (onSubmitOverride) setOverridePending(false); - - if (deleted) { - setStatus('success'); - onMessage?.({ kind: 'success', key: 'confirmDeleteAccount.success' }); - onSuccess?.({ userId }); - // Redirect after a brief delay so the user sees the success state. - redirectTimer = setTimeout(() => { - if (active) router.push(redirectTo); - }, REDIRECT_DELAY_MS); - } else { - // Server returned false / null without throwing → treat as invalid - setStatus('invalid'); - const msg = merged.errors.UNKNOWN_ERROR; - setErrorMessage(msg); - onMessage?.({ kind: 'error', key: 'UNKNOWN_ERROR', message: msg }); - onInvalid?.(); - onError?.({ message: msg, code: 'UNKNOWN_ERROR' }); - } - }) - .catch((err: unknown) => { - if (!active) return; - if (onSubmitOverride) setOverridePending(false); - // extractRawCode reads the code BEFORE parseGraphQLError may strip it - // for unknown codes (parseGraphQLError returns code: null when the code - // is not in ERROR_CODES, which means TOKEN_EXPIRED etc. are unknown). - const rawCode = extractRawCode(err); - const parsed = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - // Use rawCode for status routing (covers expired/invalid token codes), - // but use parsed code as the notification key (fallback to rawCode if null). - const notifyCode = parsed.code ?? rawCode ?? 'UNKNOWN_ERROR'; - // When there is no recognised code at all, fall back to the UNKNOWN_ERROR - // message from the messages catalog so overrides work end-to-end. - const displayMessage = - parsed.code !== null ? parsed.message : (merged.errors.UNKNOWN_ERROR ?? parsed.message); - const derivedStatus = codeToStatus(rawCode); - setStatus(derivedStatus); - // For expired/invalid, suppress the redundant generic error message - // — the state already renders its own descriptive heading + description. - setErrorMessage(derivedStatus === 'expired' || derivedStatus === 'invalid' ? null : displayMessage); - onMessage?.({ kind: 'error', key: notifyCode, message: displayMessage }); - if (derivedStatus === 'expired') onExpired?.(); - else if (derivedStatus === 'invalid') onInvalid?.(); - onError?.({ message: displayMessage, code: notifyCode }); - }); - - return () => { - active = false; - if (redirectTimer !== null) clearTimeout(redirectTimer); - }; - // Intentionally snapshot the initial token, user, mutation, handlers, messages, - // router, and redirect target: a confirmation link is processed only once. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - return ( -
- - {/* Processing state */} - {(status === 'pending' || isPending) && ( - <> - - {merged.processingTitle} - {merged.processingDescription} - - -
- -
-
- - )} - - {/* Success state */} - {status === 'success' && ( - <> - - {merged.successTitle} - {merged.successDescription} - - - - - - )} - - {/* Expired state */} - {status === 'expired' && ( - <> - - {merged.expiredTitle} - {merged.expiredDescription} - - - - - - - - - )} - - {/* Invalid / error state */} - {(status === 'invalid' || status === 'error') && ( - <> - - {merged.invalidTitle} - {merged.invalidDescription} - - - - - - - - - )} -
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/auth-account-deletion-confirm-page.requires.json b/apps/blocks/src/blocks/auth/account-deletion-confirm-page/auth-account-deletion-confirm-page.requires.json deleted file mode 100644 index fda44ad..0000000 --- a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/auth-account-deletion-confirm-page.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["confirmDeleteAccount"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/messages.ts b/apps/blocks/src/blocks/auth/account-deletion-confirm-page/messages.ts deleted file mode 100644 index ca1875b..0000000 --- a/apps/blocks/src/blocks/auth/account-deletion-confirm-page/messages.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * account-deletion-confirm-page — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type AccountDeletionConfirmMessages = { - processingTitle: string; - processingDescription: string; - successTitle: string; - successDescription: string; - successButton: string; - expiredTitle: string; - expiredDescription: string; - expiredButton: string; - invalidTitle: string; - invalidDescription: string; - invalidButton: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -export const defaultAccountDeletionConfirmMessages: AccountDeletionConfirmMessages = { - processingTitle: 'Deleting your account…', - processingDescription: 'Please wait while we process your request.', - successTitle: 'Account deleted', - successDescription: - 'Your account and all associated data have been permanently deleted. Thank you for using our service.', - successButton: 'Go to sign in', - expiredTitle: 'Link expired', - expiredDescription: - 'This deletion link has expired. Please request a new deletion email from your account settings.', - expiredButton: 'Go to account settings', - invalidTitle: 'Invalid link', - invalidDescription: - 'This deletion link is invalid or has already been used. If you believe this is an error, contact support.', - invalidButton: 'Go to sign in', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-emails-list/account-emails-list.tsx b/apps/blocks/src/blocks/auth/account-emails-list/account-emails-list.tsx deleted file mode 100644 index d2aef65..0000000 --- a/apps/blocks/src/blocks/auth/account-emails-list/account-emails-list.tsx +++ /dev/null @@ -1,594 +0,0 @@ -'use client'; - -/** - * account-emails-list (registry: auth-account-emails-list) - * - * Manages the signed-in user's email addresses. Displays all rows from the - * generated `useEmailsQuery`, lets the user add a new address (via - * `useCreateEmailMutation` + `useSendVerificationEmailMutation`), promote any - * verified address to primary (`useUpdateEmailMutation`), and delete non-primary - * addresses (`useDeleteEmailMutation`). Each row shows verified/unverified - * badges plus a "Verify" CTA for unverified addresses. - * - * ADD-EMAIL PATH: uses `createEmail` first (inserts the row), then - * `sendVerificationEmail` (queues the verification email). This two-step path - * is required because `sendVerificationEmail` only sends to an existing address - * — its `input.email` field is optional and refers to an already-registered row. - * Using `createEmail` alone (without verification send) would leave the row - * permanently unverified with no inbox prompt. - * - * Binding doctrine: - * • All data via generated hooks from `@/generated/auth`. NO fetch, NO GraphQL - * document strings, NO `@constructive-io/data`, NO `configure()`/`getClient()`. - * • Override seams: `onSubmitAdd`, `onSubmitSetPrimary`, `onSubmitDelete` fully - * replace the respective generated-hook call. - * • Error mapping via `parseGraphQLError`; inline `` for form - * errors; per-action errors reported via `onError` / `onMessage`. - * • `onMessage`/`onSuccess`-style seams fire on every operation. - */ - -import { useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Separator } from '@constructive-io/ui/separator'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; - -import { cn } from '@/lib/utils'; -import { - useEmailsQuery, - useCreateEmailMutation, - useSendVerificationEmailMutation, - useUpdateEmailMutation, - useDeleteEmailMutation -} from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { addEmailSchema, type AddEmailFormData } from '@/blocks/lib/schemas'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; - -import { defaultAccountEmailsListMessages, type AccountEmailsListMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type EmailRow = { - id: string; - email: string; - isPrimary: boolean; - isVerified: boolean; - name: string | null; - createdAt: string; -}; - -/** - * Message overrides. Top-level copy is shallow-partial; `errors` is itself - * partial so a host can localize a single error code without restating the map. - */ -export type AccountEmailsListMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type AccountEmailsListProps = { - /** Fires after a new email row is created and verification email queued. */ - onEmailAdded?: (email: EmailRow) => void; - /** Fires after primary is promoted. */ - onPrimaryChanged?: (email: EmailRow) => void; - /** Fires after a non-primary email is deleted. */ - onEmailDeleted?: (emailId: string) => void; - /** Fires after a mapped error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and non-fatal branches. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - /** Override the add-email operation (createEmail + sendVerificationEmail). */ - onSubmitAdd?: (emailAddress: string) => Promise; - /** Override the set-primary operation. */ - onSubmitSetPrimary?: (emailId: string) => Promise; - /** Override the delete operation. */ - onSubmitDelete?: (emailId: string) => Promise; - /** Override the resend-verification operation. */ - onSubmitResendVerification?: (emailAddress: string) => Promise; - messages?: AccountEmailsListMessageOverrides; - /** Disables add/delete/primary operations. Read-only display mode. */ - readOnly?: boolean; - /** Max number of email addresses allowed. Default: 10. */ - maxEmails?: number; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Email field selection — mirrors EmailRow shape -// --------------------------------------------------------------------------- - -const EMAIL_FIELDS = { - id: true, - email: true, - isPrimary: true, - isVerified: true, - name: true, - createdAt: true -} as const; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountEmailsList({ - onEmailAdded, - onPrimaryChanged, - onEmailDeleted, - onError, - onMessage, - onSubmitAdd: onSubmitAddOverride, - onSubmitSetPrimary: onSubmitSetPrimaryOverride, - onSubmitDelete: onSubmitDeleteOverride, - onSubmitResendVerification: onSubmitResendVerificationOverride, - messages: messageOverrides, - readOnly = false, - maxEmails = 10, - className -}: AccountEmailsListProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountEmailsListMessages = { - ...defaultAccountEmailsListMessages, - ...messageOverrides, - errors: { ...defaultAccountEmailsListMessages.errors, ...messageOverrides?.errors } - }; - - // ------------------------------------------------------------------------- - // Query — list emails - // ------------------------------------------------------------------------- - - const emailsQuery = useEmailsQuery({ - selection: { - fields: EMAIL_FIELDS, - orderBy: ['CREATED_AT_DESC'] - } - }); - - const emails: EmailRow[] = (emailsQuery.data?.emails?.nodes ?? []) as EmailRow[]; - - // ------------------------------------------------------------------------- - // Mutations - // ------------------------------------------------------------------------- - - const createEmailMutation = useCreateEmailMutation({ - selection: { fields: EMAIL_FIELDS } - }); - - const sendVerificationMutation = useSendVerificationEmailMutation({ - selection: { fields: { result: true } } - }); - - const updateEmailMutation = useUpdateEmailMutation({ - selection: { fields: EMAIL_FIELDS } - }); - - const deleteEmailMutation = useDeleteEmailMutation({ - selection: { fields: { id: true } } - }); - - // ------------------------------------------------------------------------- - // Dialog state — add email - // ------------------------------------------------------------------------- - - const [addDialogOpen, setAddDialogOpen] = useState(false); - const [addError, setAddError] = useState(null); - const [addOverridePending, setAddOverridePending] = useState(false); - - const isAddPending = onSubmitAddOverride - ? addOverridePending - : createEmailMutation.isPending || sendVerificationMutation.isPending; - - // ------------------------------------------------------------------------- - // Delete confirm state - // ------------------------------------------------------------------------- - - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [deleteOverridePending, setDeleteOverridePending] = useState(false); - const isDeletePending = onSubmitDeleteOverride ? deleteOverridePending : deleteEmailMutation.isPending; - - // ------------------------------------------------------------------------- - // Per-row action pending tracking - // ------------------------------------------------------------------------- - - const [primaryPendingId, setPrimaryPendingId] = useState(null); - const [verifyPendingId, setVerifyPendingId] = useState(null); - const [rowError, setRowError] = useState(null); - - // ------------------------------------------------------------------------- - // Add email form - // ------------------------------------------------------------------------- - - const addForm = useForm({ - defaultValues: { email: '' } as AddEmailFormData, - onSubmit: async ({ value }) => { - await handleAdd(value.email); - } - }); - - // ------------------------------------------------------------------------- - // Handlers - // ------------------------------------------------------------------------- - - async function handleAdd(emailAddress: string) { - setAddError(null); - if (onSubmitAddOverride) setAddOverridePending(true); - try { - addEmailSchema.parse({ email: emailAddress }); - - let newRow: EmailRow; - if (onSubmitAddOverride) { - newRow = await onSubmitAddOverride(emailAddress); - } else { - // Step 1: create the email row - const createData = await createEmailMutation.mutateAsync({ email: emailAddress }); - newRow = createData.createEmail.email as unknown as EmailRow; - // Step 2: queue the verification email - await sendVerificationMutation.mutateAsync({ input: { email: emailAddress } }); - } - - onMessage?.({ kind: 'success', key: 'emailAdded', message: merged.emailAddedMessage }); - onEmailAdded?.(newRow); - setAddDialogOpen(false); - addForm.reset(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setAddError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitAddOverride) setAddOverridePending(false); - } - } - - async function handleSetPrimary(emailId: string) { - setRowError(null); - setPrimaryPendingId(emailId); - try { - let updatedRow: EmailRow; - if (onSubmitSetPrimaryOverride) { - updatedRow = await onSubmitSetPrimaryOverride(emailId); - } else { - const data = await updateEmailMutation.mutateAsync({ id: emailId, emailPatch: { isPrimary: true } }); - updatedRow = data.updateEmail.email as unknown as EmailRow; - } - onMessage?.({ kind: 'success', key: 'primaryChanged', message: merged.primaryChangedMessage }); - onPrimaryChanged?.(updatedRow); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setPrimaryPendingId(null); - } - } - - async function handleResendVerification(row: EmailRow) { - setRowError(null); - setVerifyPendingId(row.id); - try { - if (onSubmitResendVerificationOverride) { - await onSubmitResendVerificationOverride(row.email); - } else { - await sendVerificationMutation.mutateAsync({ input: { email: row.email } }); - } - onMessage?.({ kind: 'info', key: 'verificationSent', message: merged.verificationSentMessage }); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setVerifyPendingId(null); - } - } - - async function handleDeleteConfirm() { - if (!deleteTargetId) return; - setRowError(null); - if (onSubmitDeleteOverride) setDeleteOverridePending(true); - try { - if (onSubmitDeleteOverride) { - await onSubmitDeleteOverride(deleteTargetId); - } else { - await deleteEmailMutation.mutateAsync({ id: deleteTargetId }); - } - const deletedId = deleteTargetId; - setDeleteTargetId(null); - onMessage?.({ kind: 'success', key: 'emailDeleted', message: merged.emailDeletedMessage }); - onEmailDeleted?.(deletedId); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - setDeleteTargetId(null); - } finally { - if (onSubmitDeleteOverride) setDeleteOverridePending(false); - } - } - - // ------------------------------------------------------------------------- - // Derived - // ------------------------------------------------------------------------- - - const atMax = emails.length >= maxEmails; - const deleteTarget = emails.find((e) => e.id === deleteTargetId); - - // ------------------------------------------------------------------------- - // Render - // ------------------------------------------------------------------------- - - return ( - <> - - -
- {merged.title} - {merged.description} -
- {!readOnly && ( - - )} -
- - - {rowError && ( -
- -
- )} - - {emailsQuery.isLoading ? ( -
- Loading… -
- ) : emails.length === 0 ? ( -
- No email addresses found. -
- ) : ( -
    - {emails.map((row, idx) => ( -
  • - {idx > 0 && } -
    - {/* Email + badges */} -
    - - {row.email} - -
    - {row.isPrimary && ( - - {merged.primaryBadge} - - )} - {row.isVerified ? ( - - {merged.verifiedBadge} - - ) : ( - - {merged.unverifiedBadge} - - )} -
    -
    - - {/* Actions */} - {!readOnly && ( -
    - {/* Resend verification */} - {!row.isVerified && ( - - )} - - {/* Set primary — hidden for already-primary rows */} - {!row.isPrimary && row.isVerified && ( - - )} - - {/* Delete — disabled for primary email */} - -
    - )} -
    -
  • - ))} -
- )} -
-
- - {/* --------------------------------------------------------------- - Add email dialog - --------------------------------------------------------------- */} - { - if (!isOpen) { - setAddDialogOpen(false); - setAddError(null); - } - }} - > - - - {merged.addEmailDialogTitle} - - - -
- - -
{ - e.preventDefault(); - e.stopPropagation(); - addForm.handleSubmit(); - }} - > - { - if (!value) return 'Email is required'; - if (!/\S+@\S+\.\S+/.test(value)) return 'Please enter a valid email'; - return undefined; - } - }} - > - {(field) => ( - - )} - - - - - - {merged.addEmailSubmit} - - -
-
-
-
- - {/* --------------------------------------------------------------- - Delete confirm dialog - --------------------------------------------------------------- */} - { - if (!isOpen) setDeleteTargetId(null); - }} - > - - - {merged.deleteConfirmTitle} - {merged.deleteConfirmDescription} - - - - - - {merged.deleteConfirmButton} - - - - - - {/* Invisible element to expose deleteTarget for tests */} - {deleteTarget && ( - - {deleteTarget.email} - - )} - - ); -} diff --git a/apps/blocks/src/blocks/auth/account-emails-list/auth-account-emails-list.requires.json b/apps/blocks/src/blocks/auth/account-emails-list/auth-account-emails-list.requires.json deleted file mode 100644 index 1ace750..0000000 --- a/apps/blocks/src/blocks/auth/account-emails-list/auth-account-emails-list.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["createEmail", "sendVerificationEmail", "updateEmail", "deleteEmail"], - "queries": ["emails"], - "models": ["email"] -} diff --git a/apps/blocks/src/blocks/auth/account-emails-list/messages.ts b/apps/blocks/src/blocks/auth/account-emails-list/messages.ts deleted file mode 100644 index 5de2b2d..0000000 --- a/apps/blocks/src/blocks/auth/account-emails-list/messages.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * account-emails-list — message catalog - * - * Canonical block-messages pattern: top-level camelCase keys are UI copy; - * the nested `errors` map is keyed by backend error CODE (UPPER_SNAKE_CASE) - * and is handed straight to `parseGraphQLError` as `customMessages`, so a - * host localises any code by overriding a single key. - */ - -export type AccountEmailsListMessages = { - title: string; - description: string; - addEmailButton: string; - addEmailDialogTitle: string; - addEmailLabel: string; - addEmailPlaceholder: string; - addEmailSubmit: string; - addEmailSubmitting: string; - primaryBadge: string; - verifiedBadge: string; - unverifiedBadge: string; - verifyButton: string; - setPrimaryButton: string; - deleteButton: string; - deleteConfirmTitle: string; - deleteConfirmDescription: string; - deleteConfirmButton: string; - deleteCancelButton: string; - verificationSentMessage: string; - emailAddedMessage: string; - primaryChangedMessage: string; - emailDeletedMessage: string; - cannotDeletePrimary: string; - errors: { - EMAIL_TAKEN: string; - RATE_LIMITED: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultAccountEmailsListMessages: AccountEmailsListMessages = { - title: 'Email addresses', - description: 'Manage your email addresses. Your primary email is used for sign-in and notifications.', - addEmailButton: 'Add email address', - addEmailDialogTitle: 'Add email address', - addEmailLabel: 'Email address', - addEmailPlaceholder: 'you@example.com', - addEmailSubmit: 'Add address', - addEmailSubmitting: 'Adding…', - primaryBadge: 'Primary', - verifiedBadge: 'Verified', - unverifiedBadge: 'Unverified', - verifyButton: 'Verify', - setPrimaryButton: 'Set as primary', - deleteButton: 'Remove', - deleteConfirmTitle: 'Remove email address?', - deleteConfirmDescription: 'This email address will be removed from your account.', - deleteConfirmButton: 'Remove', - deleteCancelButton: 'Cancel', - verificationSentMessage: 'Verification email sent.', - emailAddedMessage: 'Email address added. Check your inbox to verify it.', - primaryChangedMessage: 'Primary email address updated.', - emailDeletedMessage: 'Email address removed.', - cannotDeletePrimary: 'You cannot remove your primary email address.', - errors: { - EMAIL_TAKEN: 'This email address is already associated with another account.', - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-phones-list/account-phones-list.tsx b/apps/blocks/src/blocks/auth/account-phones-list/account-phones-list.tsx deleted file mode 100644 index 73aeb5e..0000000 --- a/apps/blocks/src/blocks/auth/account-phones-list/account-phones-list.tsx +++ /dev/null @@ -1,1062 +0,0 @@ -'use client'; - -/** - * account-phones-list (registry: auth-account-phones-list) - * - * Multi-phone management card. Lists the signed-in user's phone numbers from the - * generated `usePhoneNumbersQuery`, lets the user add a new number (create row - * via `useCreatePhoneNumberMutation` then trigger OTP send via the - * `onSubmitSendOtp` override seam — SMS procedures are backend-pending), verify - * with an inline 6-digit OTP (`onSubmitVerifyOtp` override seam), set a primary - * number (`useUpdatePhoneNumberMutation`), and delete with confirmation - * (`useDeletePhoneNumberMutation`). - * - * BACKEND-PENDING (CASE b): `send_sms_otp` and `verify_phone_otp` procedures - * are NOT yet deployed in constructive_auth_public, so their generated hooks - * (`useSendSmsOtpMutation`, `useVerifyPhoneOtpMutation`) do NOT exist in the - * SDK. The add/verify flow therefore uses `onSubmitSendOtp` / `onSubmitVerifyOtp` - * as the primary (required) seams for those two operations. Hosts wire the - * generated bindings once they regenerate the SDK after deployment. - * `requires.json` names both pending ops so `check-sdk-fixtures.ts` fails clearly. - * - * Binding doctrine: - * • All list/CRUD data via generated hooks from `@/generated/auth`. NO fetch, - * NO GraphQL document strings, NO `@constructive-io/data`, NO `configure()`. - * • Override seams: `onSubmitAdd`, `onSubmitSendOtp`, `onSubmitVerifyOtp`, - * `onSubmitSetPrimary`, `onSubmitDelete` fully replace respective calls. - * • Error mapping via `parseGraphQLError`; inline `` for form - * errors; per-action errors reported via `onError` / `onMessage`. - * • OTP dialog stays open after phone creation; user enters the code inline. - * • 60-second resend cooldown tracked with a useEffect timeout. - */ - -import { useEffect, useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Separator } from '@constructive-io/ui/separator'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; - -import { cn } from '@/lib/utils'; -import { - usePhoneNumbersQuery, - useCreatePhoneNumberMutation, - useUpdatePhoneNumberMutation, - useDeletePhoneNumberMutation -} from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; - -import { - defaultAccountPhonesListMessages, - type AccountPhonesListMessages, - type AccountPhonesListMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Country codes — minimal list (no libphonenumber dependency) -// --------------------------------------------------------------------------- - -const COUNTRY_CODES = [ - { code: '+1', label: 'US / CA (+1)', value: '+1' }, - { code: '+44', label: 'GB (+44)', value: '+44' }, - { code: '+61', label: 'AU (+61)', value: '+61' }, - { code: '+33', label: 'FR (+33)', value: '+33' }, - { code: '+49', label: 'DE (+49)', value: '+49' }, - { code: '+81', label: 'JP (+81)', value: '+81' }, - { code: '+82', label: 'KR (+82)', value: '+82' }, - { code: '+86', label: 'CN (+86)', value: '+86' }, - { code: '+91', label: 'IN (+91)', value: '+91' }, - { code: '+52', label: 'MX (+52)', value: '+52' }, - { code: '+55', label: 'BR (+55)', value: '+55' }, - { code: '+34', label: 'ES (+34)', value: '+34' }, - { code: '+39', label: 'IT (+39)', value: '+39' }, - { code: '+7', label: 'RU (+7)', value: '+7' }, - { code: '+31', label: 'NL (+31)', value: '+31' }, - { code: '+46', label: 'SE (+46)', value: '+46' }, - { code: '+47', label: 'NO (+47)', value: '+47' }, - { code: '+45', label: 'DK (+45)', value: '+45' }, - { code: '+358', label: 'FI (+358)', value: '+358' }, - { code: '+41', label: 'CH (+41)', value: '+41' } -]; - -const OTP_RESEND_SECONDS = 60; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type PhoneRow = { - id: string; - /** Country calling code, e.g. '+1' */ - cc: string; - /** Phone number without country code */ - number: string; - isPrimary: boolean; - isVerified: boolean; - createdAt: string | null; -}; - -type AddPhoneFormData = { - cc: string; - number: string; -}; - -type OtpFormData = { - otp: string; -}; - -export type AccountPhonesListProps = { - /** Fires after a new phone row is created and OTP sent. */ - onPhoneAdded?: (phone: PhoneRow) => void; - /** Fires after OTP verified successfully. */ - onPhoneVerified?: (phone: PhoneRow) => void; - /** Fires after primary promotion. */ - onPrimaryChanged?: (phone: PhoneRow) => void; - /** Fires after deletion. */ - onPhoneDeleted?: (phoneId: string) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and info events. */ - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - /** - * Override the add-phone + send-OTP operation. - * - * BACKEND-PENDING: `send_sms_otp` is not yet deployed. This seam is the - * PRIMARY path for add+OTP-send until the generated `useSendSmsOtpMutation` - * exists. The host creates the phone row AND sends the OTP within this fn. - * Return the created PhoneRow. - */ - onSubmitAdd?: (cc: string, number: string) => Promise; - /** - * Override the send/resend OTP operation for an existing unverified phone. - * - * BACKEND-PENDING: wraps the pending `send_sms_otp` procedure. - */ - onSubmitSendOtp?: (cc: string, number: string) => Promise; - /** - * Override the OTP verify operation. - * - * BACKEND-PENDING: wraps the pending `verify_phone_otp` procedure. - * Receives the phone number (E.164 = cc+number) and the 6-digit OTP. - * Return the updated PhoneRow on success. - */ - onSubmitVerifyOtp?: (phoneE164: string, otp: string) => Promise; - /** Override the set-primary operation. */ - onSubmitSetPrimary?: (phoneId: string) => Promise; - /** Override the delete operation. */ - onSubmitDelete?: (phoneId: string) => Promise; - messages?: AccountPhonesListMessageOverrides; - /** Default country code for the picker. Default: '+1'. */ - defaultCountry?: string; - /** Disables add/delete/primary operations. Read-only display mode. */ - readOnly?: boolean; - /** Max phone numbers allowed. Default: 5. */ - maxPhones?: number; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Field selection — mirrors PhoneRow shape -// --------------------------------------------------------------------------- - -const PHONE_FIELDS = { - id: true, - cc: true, - number: true, - isPrimary: true, - isVerified: true, - createdAt: true -} as const; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function toE164(cc: string, number: string): string { - // Strip any non-digit chars from the local number, prepend cc - const localDigits = number.replace(/\D/g, ''); - const prefix = cc.startsWith('+') ? cc : `+${cc}`; - return `${prefix}${localDigits}`; -} - -function validatePhone(number: string): boolean { - const digits = number.replace(/\D/g, ''); - return digits.length >= 7 && digits.length <= 15; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountPhonesList({ - onPhoneAdded, - onPhoneVerified, - onPrimaryChanged, - onPhoneDeleted, - onError, - onMessage, - onSubmitAdd: onSubmitAddOverride, - onSubmitSendOtp: onSubmitSendOtpOverride, - onSubmitVerifyOtp: onSubmitVerifyOtpOverride, - onSubmitSetPrimary: onSubmitSetPrimaryOverride, - onSubmitDelete: onSubmitDeleteOverride, - messages: messageOverrides, - defaultCountry = '+1', - readOnly = false, - maxPhones = 5, - className -}: AccountPhonesListProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountPhonesListMessages = { - ...defaultAccountPhonesListMessages, - ...messageOverrides, - errors: { ...defaultAccountPhonesListMessages.errors, ...messageOverrides?.errors } - }; - - // ------------------------------------------------------------------------- - // Query — list phone numbers - // ------------------------------------------------------------------------- - - const phonesQuery = usePhoneNumbersQuery({ - selection: { - fields: PHONE_FIELDS, - orderBy: ['CREATED_AT_DESC'] - } - }); - - const phones: PhoneRow[] = (phonesQuery.data?.phoneNumbers?.nodes ?? []) as PhoneRow[]; - - // ------------------------------------------------------------------------- - // Mutations - // ------------------------------------------------------------------------- - - const createPhoneMutation = useCreatePhoneNumberMutation({ - selection: { fields: PHONE_FIELDS } - }); - - const updatePhoneMutation = useUpdatePhoneNumberMutation({ - selection: { fields: PHONE_FIELDS } - }); - - const deletePhoneMutation = useDeletePhoneNumberMutation({ - selection: { fields: { id: true } } - }); - - // ------------------------------------------------------------------------- - // Dialog state — add phone (two steps: number → OTP) - // Step 0 = closed; Step 1 = enter phone number; Step 2 = enter OTP - // ------------------------------------------------------------------------- - - const [addStep, setAddStep] = useState<0 | 1 | 2>(0); - const [pendingPhone, setPendingPhone] = useState(null); - const [addError, setAddError] = useState(null); - const [addOverridePending, setAddOverridePending] = useState(false); - - const isAddPending = onSubmitAddOverride ? addOverridePending : createPhoneMutation.isPending; - - // OTP step pending (no generated hook — backend-pending CASE b) - const [otpError, setOtpError] = useState(null); - const [otpOverridePending, setOtpOverridePending] = useState(false); - - // Resend cooldown - const [resendCountdown, setResendCountdown] = useState(0); - - function startResendCountdown() { - setResendCountdown(OTP_RESEND_SECONDS); - } - - useEffect(() => { - if (resendCountdown <= 0) return; - const timeout = setTimeout(() => { - setResendCountdown((seconds) => Math.max(0, seconds - 1)); - }, 1000); - return () => clearTimeout(timeout); - }, [resendCountdown]); - - // ------------------------------------------------------------------------- - // Delete confirm state - // ------------------------------------------------------------------------- - - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [deleteOverridePending, setDeleteOverridePending] = useState(false); - const isDeletePending = onSubmitDeleteOverride ? deleteOverridePending : deletePhoneMutation.isPending; - - // ------------------------------------------------------------------------- - // Per-row action pending tracking - // ------------------------------------------------------------------------- - - const [primaryPendingId, setPrimaryPendingId] = useState(null); - const [rowError, setRowError] = useState(null); - - // Inline OTP entry: which phone row is being verified - const [inlineVerifyPhoneId, setInlineVerifyPhoneId] = useState(null); - const [inlineOtpError, setInlineOtpError] = useState(null); - const [inlineOtpPending, setInlineOtpPending] = useState(false); - - // ------------------------------------------------------------------------- - // Add-phone form (step 1) - // ------------------------------------------------------------------------- - - const addForm = useForm({ - defaultValues: { cc: defaultCountry, number: '' } as AddPhoneFormData, - onSubmit: async ({ value }) => { - await handleAdd(value.cc, value.number); - } - }); - - // OTP form (step 2 in dialog) - const otpForm = useForm({ - defaultValues: { otp: '' } as OtpFormData, - onSubmit: async ({ value }) => { - await handleVerifyOtp(value.otp); - } - }); - - // Inline OTP form (for rows shown in the list) - const inlineOtpForm = useForm({ - defaultValues: { otp: '' } as OtpFormData, - onSubmit: async ({ value }) => { - await handleInlineVerifyOtp(value.otp); - } - }); - - // ------------------------------------------------------------------------- - // Handlers - // ------------------------------------------------------------------------- - - async function handleAdd(cc: string, number: string) { - setAddError(null); - - if (!validatePhone(number)) { - setAddError(merged.errors.INVALID_PHONE); - return; - } - - if (onSubmitAddOverride) setAddOverridePending(true); - - try { - let newRow: PhoneRow; - - if (onSubmitAddOverride) { - // Host override: creates row + sends OTP in one call - newRow = await onSubmitAddOverride(cc, number); - setPendingPhone(newRow); - onMessage?.({ kind: 'success', key: 'phoneAdded', message: merged.phoneAddedMessage }); - onPhoneAdded?.(newRow); - startResendCountdown(); - setAddStep(2); - } else { - // Default path: create the phone row (CASE b — no sendSmsOtp generated hook). - // The OTP-send seam requires onSubmitSendOtp override when SMS is needed. - const createData = await createPhoneMutation.mutateAsync({ cc, number }); - newRow = createData.createPhoneNumber.phoneNumber as unknown as PhoneRow; - setPendingPhone(newRow); - onMessage?.({ kind: 'success', key: 'phoneAdded', message: merged.phoneAddedMessage }); - onPhoneAdded?.(newRow); - - // If the host has provided a send-OTP override, call it now - if (onSubmitSendOtpOverride) { - try { - await onSubmitSendOtpOverride(cc, number); - onMessage?.({ kind: 'info', key: 'otpSent', message: merged.otpSentMessage }); - } catch (otpErr) { - const { code, message } = parseGraphQLError(otpErr, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - // Don't block — phone was created, OTP send failed - } - } - startResendCountdown(); - setAddStep(2); - } - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setAddError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitAddOverride) setAddOverridePending(false); - } - } - - async function handleVerifyOtp(otp: string) { - if (!pendingPhone) return; - setOtpError(null); - - if (!otp || otp.length !== 6) { - setOtpError(merged.errors.INVALID_OTP); - return; - } - - setOtpOverridePending(true); - const phoneE164 = toE164(pendingPhone.cc, pendingPhone.number); - - try { - if (onSubmitVerifyOtpOverride) { - const updatedRow = await onSubmitVerifyOtpOverride(phoneE164, otp); - onMessage?.({ kind: 'success', key: 'phoneVerified', message: merged.phoneVerifiedMessage }); - onPhoneVerified?.(updatedRow); - } else { - // CASE b — no generated verify hook. Surface PROCEDURE_NOT_FOUND. - throw Object.assign(new Error('verify_phone_otp not deployed'), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - } - // Close dialog after successful verify - setAddStep(0); - setPendingPhone(null); - otpForm.reset(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setOtpError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setOtpOverridePending(false); - } - } - - async function handleResendOtp() { - if (!pendingPhone || resendCountdown > 0) return; - setOtpError(null); - - try { - if (onSubmitSendOtpOverride) { - await onSubmitSendOtpOverride(pendingPhone.cc, pendingPhone.number); - onMessage?.({ kind: 'info', key: 'otpSent', message: merged.otpSentMessage }); - } else { - throw Object.assign(new Error('send_sms_otp not deployed'), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - } - startResendCountdown(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setOtpError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } - } - - async function handleSetPrimary(phoneId: string) { - setRowError(null); - setPrimaryPendingId(phoneId); - try { - let updatedRow: PhoneRow; - if (onSubmitSetPrimaryOverride) { - updatedRow = await onSubmitSetPrimaryOverride(phoneId); - } else { - const data = await updatePhoneMutation.mutateAsync({ - id: phoneId, - phoneNumberPatch: { isPrimary: true } - }); - updatedRow = data.updatePhoneNumber.phoneNumber as unknown as PhoneRow; - } - onMessage?.({ kind: 'success', key: 'primaryChanged', message: merged.primaryChangedMessage }); - onPrimaryChanged?.(updatedRow); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setPrimaryPendingId(null); - } - } - - async function handleDeleteConfirm() { - if (!deleteTargetId) return; - setRowError(null); - if (onSubmitDeleteOverride) setDeleteOverridePending(true); - try { - if (onSubmitDeleteOverride) { - await onSubmitDeleteOverride(deleteTargetId); - } else { - await deletePhoneMutation.mutateAsync({ id: deleteTargetId }); - } - const deletedId = deleteTargetId; - setDeleteTargetId(null); - onMessage?.({ kind: 'success', key: 'phoneDeleted', message: merged.phoneDeletedMessage }); - onPhoneDeleted?.(deletedId); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - setDeleteTargetId(null); - } finally { - if (onSubmitDeleteOverride) setDeleteOverridePending(false); - } - } - - async function handleInlineSendOtp(phone: PhoneRow) { - setInlineOtpError(null); - setInlineVerifyPhoneId(phone.id); - inlineOtpForm.reset(); - try { - if (onSubmitSendOtpOverride) { - await onSubmitSendOtpOverride(phone.cc, phone.number); - onMessage?.({ kind: 'info', key: 'otpSent', message: merged.otpSentMessage }); - } else { - throw Object.assign(new Error('send_sms_otp not deployed'), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - } - startResendCountdown(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setRowError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } - } - - async function handleInlineVerifyOtp(otp: string) { - const phone = phones.find((p) => p.id === inlineVerifyPhoneId); - if (!phone) return; - setInlineOtpError(null); - - if (!otp || otp.length !== 6) { - setInlineOtpError(merged.errors.INVALID_OTP); - return; - } - - setInlineOtpPending(true); - const phoneE164 = toE164(phone.cc, phone.number); - - try { - if (onSubmitVerifyOtpOverride) { - const updatedRow = await onSubmitVerifyOtpOverride(phoneE164, otp); - onMessage?.({ kind: 'success', key: 'phoneVerified', message: merged.phoneVerifiedMessage }); - onPhoneVerified?.(updatedRow); - } else { - throw Object.assign(new Error('verify_phone_otp not deployed'), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - } - setInlineVerifyPhoneId(null); - inlineOtpForm.reset(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setInlineOtpError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setInlineOtpPending(false); - } - } - - // ------------------------------------------------------------------------- - // Derived - // ------------------------------------------------------------------------- - - const atMax = phones.length >= maxPhones; - const deleteTarget = phones.find((p) => p.id === deleteTargetId); - - function formatPhoneDisplay(phone: PhoneRow): string { - return `${phone.cc} ${phone.number}`; - } - - // ------------------------------------------------------------------------- - // Render - // ------------------------------------------------------------------------- - - return ( - <> - - -
- {merged.title} - {merged.description} -
- {!readOnly && !atMax && ( - - )} -
- - - {rowError && ( -
- -
- )} - - {phonesQuery.isLoading ? ( -
- Loading… -
- ) : phones.length === 0 ? ( -
- No phone numbers found. -
- ) : ( -
    - {phones.map((phone, idx) => ( -
  • - {idx > 0 && } -
    -
    - {/* Phone + badges */} -
    - - {formatPhoneDisplay(phone)} - -
    - {phone.isPrimary && ( - - {merged.primaryBadge} - - )} - - {phone.isVerified ? merged.verifiedBadge : merged.unverifiedBadge} - -
    -
    - - {/* Actions */} - {!readOnly && ( -
    - {/* Verify — for unverified phones */} - {!phone.isVerified && inlineVerifyPhoneId !== phone.id && ( - - )} - - {/* Set primary — only for verified non-primary */} - {!phone.isPrimary && phone.isVerified && ( - - )} - - {/* Delete — disabled for primary */} - -
    - )} -
    - - {/* Inline OTP entry for this row */} - {!readOnly && inlineVerifyPhoneId === phone.id && ( -
    - -
    { - e.preventDefault(); - e.stopPropagation(); - inlineOtpForm.handleSubmit(); - }} - > -
    - { - if (!value) return 'Code is required'; - if (!/^\d{6}$/.test(value)) return 'Enter the 6-digit code'; - return undefined; - } - }} - > - {(field) => ( - - )} - -
    - - {merged.otpSubmit} - -
    - -
    - - -
    -
    - )} -
    -
  • - ))} -
- )} -
-
- - {/* --------------------------------------------------------------- - Add phone dialog — step 1: enter phone number - — step 2: enter OTP - --------------------------------------------------------------- */} - 0} - onOpenChange={(isOpen) => { - if (!isOpen) { - setAddStep(0); - setPendingPhone(null); - setAddError(null); - setOtpError(null); - addForm.reset(); - otpForm.reset(); - } - }} - > - - - {merged.addPhoneDialogTitle} - - - - {addStep === 1 && ( -
- - -
{ - e.preventDefault(); - e.stopPropagation(); - addForm.handleSubmit(); - }} - > - {/* Country code selector */} -
- - - {(field) => ( - - )} - -
- - {/* Phone number input */} - { - if (!value) return 'Phone number is required'; - if (!validatePhone(value)) return merged.errors.INVALID_PHONE; - return undefined; - } - }} - > - {(field) => ( - - )} - - - - - - {merged.addPhoneSubmit} - - -
-
- )} - - {addStep === 2 && ( -
- {pendingPhone && ( -

- {merged.phoneAddedMessage} -

- )} - - - -
{ - e.preventDefault(); - e.stopPropagation(); - otpForm.handleSubmit(); - }} - > - { - if (!value) return 'Code is required'; - if (!/^\d{6}$/.test(value)) return 'Enter the 6-digit code'; - return undefined; - } - }} - > - {(field) => ( - - )} - - - -
-
- - - {merged.otpSubmit} - -
-
- -
-
-
-
-
- )} -
-
- - {/* --------------------------------------------------------------- - Delete confirm dialog - --------------------------------------------------------------- */} - { - if (!isOpen) setDeleteTargetId(null); - }} - > - - - {merged.deleteConfirmTitle} - {merged.deleteConfirmDescription} - - - - - - {merged.deleteConfirmButton} - - - - - - {/* Invisible element to expose deleteTarget for tests */} - {deleteTarget && ( - - {formatPhoneDisplay(deleteTarget)} - - )} - - ); -} diff --git a/apps/blocks/src/blocks/auth/account-phones-list/auth-account-phones-list.requires.json b/apps/blocks/src/blocks/auth/account-phones-list/auth-account-phones-list.requires.json deleted file mode 100644 index 23ee8ea..0000000 --- a/apps/blocks/src/blocks/auth/account-phones-list/auth-account-phones-list.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["createPhoneNumber", "updatePhoneNumber", "deletePhoneNumber", "sendSmsOtp", "verifyPhoneOtp"], - "queries": ["phoneNumbers"], - "models": ["phoneNumber"] -} diff --git a/apps/blocks/src/blocks/auth/account-phones-list/messages.ts b/apps/blocks/src/blocks/auth/account-phones-list/messages.ts deleted file mode 100644 index a5ae673..0000000 --- a/apps/blocks/src/blocks/auth/account-phones-list/messages.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * account-phones-list — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`. - * - * PROCEDURE_NOT_FOUND is included because SMS OTP procedures - * (`send_sms_otp` / `verify_phone_otp`) are backend-pending. When the block - * fires against a deployment that has not yet deployed those procedures, - * PostGraphile returns code PROCEDURE_NOT_FOUND — this key ensures a clear - * user-facing message rather than UNKNOWN_ERROR. - */ - -export type AccountPhonesListMessages = { - title: string; - description: string; - addPhoneButton: string; - addPhoneDialogTitle: string; - countryCodeLabel: string; - phoneLabel: string; - phonePlaceholder: string; - addPhoneSubmit: string; - addPhoneSubmitting: string; - primaryBadge: string; - verifiedBadge: string; - unverifiedBadge: string; - verifyButton: string; - resendButton: string; - setPrimaryButton: string; - deleteButton: string; - deleteConfirmTitle: string; - deleteConfirmDescription: string; - deleteConfirmButton: string; - deleteCancelButton: string; - otpLabel: string; - otpPlaceholder: string; - otpSubmit: string; - otpSubmitting: string; - otpSentMessage: string; - otpResendCooldown: string; - phoneAddedMessage: string; - phoneVerifiedMessage: string; - primaryChangedMessage: string; - phoneDeletedMessage: string; - cannotDeletePrimary: string; - errors: { - INVALID_PHONE: string; - INVALID_OTP: string; - RATE_LIMITED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type — top-level keys are individually optional; - * `errors` is itself partial so a host can override a single code. - */ -export type AccountPhonesListMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAccountPhonesListMessages: AccountPhonesListMessages = { - title: 'Phone numbers', - description: - 'Manage your phone numbers. Your primary number is used for SMS sign-in and notifications.', - addPhoneButton: 'Add phone number', - addPhoneDialogTitle: 'Add phone number', - countryCodeLabel: 'Country', - phoneLabel: 'Phone number', - phonePlaceholder: '(555) 000-0000', - addPhoneSubmit: 'Send verification code', - addPhoneSubmitting: 'Sending…', - primaryBadge: 'Primary', - verifiedBadge: 'Verified', - unverifiedBadge: 'Unverified', - verifyButton: 'Verify', - resendButton: 'Resend code', - setPrimaryButton: 'Set as primary', - deleteButton: 'Remove', - deleteConfirmTitle: 'Remove phone number?', - deleteConfirmDescription: 'This phone number will be removed from your account.', - deleteConfirmButton: 'Remove', - deleteCancelButton: 'Cancel', - otpLabel: 'Verification code', - otpPlaceholder: '000000', - otpSubmit: 'Verify', - otpSubmitting: 'Verifying…', - otpSentMessage: 'Verification code sent.', - otpResendCooldown: 'Resend in {{seconds}}s', - phoneAddedMessage: 'Phone number added. Enter the code we sent to verify it.', - phoneVerifiedMessage: 'Phone number verified.', - primaryChangedMessage: 'Primary phone number updated.', - phoneDeletedMessage: 'Phone number removed.', - cannotDeletePrimary: 'You cannot remove your primary phone number.', - errors: { - INVALID_PHONE: 'Please enter a valid phone number.', - INVALID_OTP: 'Incorrect code. Please try again.', - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - PROCEDURE_NOT_FOUND: - 'SMS verification is not yet available. Please contact support or try again later.', - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-profile-card/account-profile-card.tsx b/apps/blocks/src/blocks/auth/account-profile-card/account-profile-card.tsx deleted file mode 100644 index 97b3118..0000000 --- a/apps/blocks/src/blocks/auth/account-profile-card/account-profile-card.tsx +++ /dev/null @@ -1,503 +0,0 @@ -'use client'; - -/** - * account-profile-card (registry: auth-account-profile-card) - * - * Allows the signed-in user to update their display_name and profile_picture. - * Renders first/last or display name fields for 'person' users and an - * organization name field for 'organization' users. Profile picture changes - * use an optimistic preview before the presigned-URL upload completes. - * - * DATA PATH: - * • Read — `useCurrentUserQuery` from `@/generated/auth` (when `user` prop - * is not supplied by the consumer). - * • Write — `useUpdateUserMutation` from `@/generated/auth`. - * Hook takes flat args `{ id, userPatch }` and returns - * `{ updateUser: { user } }`. - * - * There is NO fetch, NO GraphQL document string, NO configure()/getClient(), - * NO QueryClientProvider in this file. The host mounts blocks-runtime once. - */ - -import { useRef, useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { useCurrentUserQuery, useUpdateUserMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; -import { UserAvatar } from '@/blocks/user/user-avatar/user-avatar'; - -import { - defaultAccountProfileCardMessages, - type AccountProfileCardMessageOverrides, - type AccountProfileCardMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/** - * Opaque image descriptor stored as jsonb in `users.profile_picture`. - * When the field is a URL string, consumers may read `profilePicture.url` - * or cast to string depending on their upload implementation. - */ -export type ImageJsonb = Record; - -export type UpdateProfileInput = { - /** The user's id — required so the mutation knows which row to update. */ - id: string; - displayName?: string; - /** Set to `null` to remove the current picture. When a new file was selected, - * `profilePictureUpload` carries the raw File; `profilePicture` is omitted. */ - profilePicture?: ImageJsonb | null; - /** Raw File object when the user selected a new picture but no presigned-PUT - * has been performed yet. Present only when `onSubmit` override is used and - * the consumer is responsible for the upload step. */ - profilePictureUpload?: File | null; -}; - -export type UpdateProfileResult = { - user: { - id: string; - type: 'person' | 'organization'; - displayName: string | null; - profilePicture: ImageJsonb | null; - }; -}; - -/** The user shape expected / returned by this block. */ -export type AccountProfileUser = { - id: string; - /** Normalised from the wire Int! (1 → 'person', 2 → 'organization'). */ - type: 'person' | 'organization'; - displayName?: string | null; - /** Raw value from `users.profile_picture` (jsonb image domain or null). */ - profilePicture?: ImageJsonb | null; -}; - -export type AccountProfileCardProps = { - /** Current user. When omitted the block calls `useCurrentUserQuery`. */ - user?: AccountProfileUser; - /** Pre-populate form fields. Falls back to `user` when not set. */ - defaultValues?: { - displayName?: string; - profilePicture?: ImageJsonb | null; - }; - /** Max file size in bytes for profile picture upload. Default: 5_000_000. */ - maxFileSize?: number; - /** Accepted MIME types for profile picture. Default: image/jpeg, image/png, image/webp. */ - acceptedImageTypes?: string[]; - messages?: AccountProfileCardMessageOverrides; - /** Replace the default `useUpdateUserMutation` call. */ - onSubmit?: (input: UpdateProfileInput) => Promise; - /** Fires after a successful save. Always fires. */ - onSuccess?: (result: UpdateProfileResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for all events. Always fires. */ - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Normalize the wire Int! type value to the block's discriminator. */ -function normalizeUserType(raw: number | null | undefined): 'person' | 'organization' { - if (raw === 2) return 'organization'; - return 'person'; -} - -/** - * Best-effort extraction of a display URL from the opaque image jsonb. - * The domain stores objects like `{ url, key, width, height, mimeType }`. - * Falls back to `null` when it cannot resolve a string URL. - */ -function resolveAvatarUrl(pic: ImageJsonb | null | undefined): string | null { - if (!pic) return null; - if (typeof pic === 'string') return pic; - if (typeof (pic as { url?: unknown }).url === 'string') return (pic as { url: string }).url; - return null; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountProfileCard({ - user: userProp, - defaultValues, - maxFileSize = 5_000_000, - acceptedImageTypes = ['image/jpeg', 'image/png', 'image/webp'], - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: AccountProfileCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountProfileCardMessages = { - ...defaultAccountProfileCardMessages, - ...messageOverrides, - errors: { - ...defaultAccountProfileCardMessages.errors, - ...messageOverrides?.errors - } - }; - - // --------------------------------------------------------------------------- - // Data — read current user when the consumer has not supplied a user prop. - // The query is skipped (`enabled: false`) when the consumer supplies `user`. - // --------------------------------------------------------------------------- - const { data: currentUserData } = useCurrentUserQuery({ - selection: { - fields: { - id: true, - type: true, - displayName: true, - profilePicture: true - } - }, - enabled: !userProp - }); - - const rawUser = userProp ?? currentUserData?.currentUser ?? null; - - const resolvedUser: AccountProfileUser | null = rawUser - ? { - id: rawUser.id, - type: userProp - ? userProp.type - : normalizeUserType((rawUser as { type?: number | null }).type as number | null), - displayName: (rawUser as { displayName?: string | null }).displayName, - profilePicture: (rawUser as { profilePicture?: ImageJsonb | null }).profilePicture - } - : null; - - // --------------------------------------------------------------------------- - // Data — update mutation - // --------------------------------------------------------------------------- - const defaultMutation = useUpdateUserMutation({ - selection: { - fields: { - id: true, - type: true, - displayName: true, - profilePicture: true - } - } - }); - - // Hybrid pending: generated hook tracks its own; override path does not. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - // --------------------------------------------------------------------------- - // File / upload state - // --------------------------------------------------------------------------- - const fileInputRef = useRef(null); - const [uploadError, setUploadError] = useState(null); - const [isUploading, setIsUploading] = useState(false); - /** - * Holds the optimistic preview + the raw File so `handleSave` can pass it - * via `userPatch.profilePictureUpload` — the ORM-generated field for - * multipart/binary uploads. `null` means no pending change; `'remove'` means - * clear the current picture. - * - * We intentionally do NOT store `File` inside an `ImageJsonb` (that would be - * non-serializable). The `profilePictureUpload` field on `UserPatch` is the - * correct contract point for raw File uploads. - */ - const [pendingPicture, setPendingPicture] = useState< - { preview: string; file: File } | null | 'remove' - >(null); - - const [formError, setFormError] = useState(null); - - // --------------------------------------------------------------------------- - // Form - // --------------------------------------------------------------------------- - const isOrg = resolvedUser?.type === 'organization'; - const initialDisplayName = - defaultValues?.displayName ?? - resolvedUser?.displayName ?? - ''; - - const form = useForm({ - defaultValues: { displayName: initialDisplayName as string }, - onSubmit: async ({ value }) => { - await handleSave(value.displayName); - } - }); - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - - function handleFileSelect(e: React.ChangeEvent) { - setUploadError(null); - const file = e.target.files?.[0]; - if (!file) return; - - if (file.size > maxFileSize) { - setUploadError(merged.fileTooLarge); - return; - } - if (!acceptedImageTypes.includes(file.type)) { - setUploadError(merged.fileTypeNotAccepted); - return; - } - - // Optimistic preview — the File is stored on pendingPicture.file so - // handleSave can pass it via `userPatch.profilePictureUpload`, the - // ORM-generated field that accepts a raw File for binary uploads. - // This avoids placing a non-serializable File inside an ImageJsonb, which - // would break GraphQL transport (see B1 fix). - // `isUploading` is set true during the actual mutation in handleSave; here - // we just stage the file and surface an info event for the consumer. - const previewUrl = URL.createObjectURL(file); - onMessage?.({ kind: 'info', key: 'uploading', message: merged.uploadingMessage }); - setPendingPicture({ preview: previewUrl, file }); - } - - function handleRemovePhoto() { - setPendingPicture('remove'); - setUploadError(null); - } - - async function handleSave(displayName: string) { - if (!resolvedUser) return; - setFormError(null); - // Show the avatar upload overlay while the mutation is in-flight with a file. - if (pendingPicture !== null && pendingPicture !== 'remove') { - setIsUploading(true); - } - - // Build the input shape for the onSubmit override (consumer-facing API). - // When the user removed the photo: profilePicture = null. - // When the user selected a new file: profilePictureUpload = File (the - // consumer is responsible for the upload step in the override path). - const input: UpdateProfileInput = { - id: resolvedUser.id, - displayName: displayName || undefined, - ...(pendingPicture === 'remove' - ? { profilePicture: null } - : pendingPicture !== null - ? { profilePictureUpload: pendingPicture.file } - : {}) - }; - - if (onSubmitOverride) setOverridePending(true); - try { - let result: UpdateProfileResult; - - if (onSubmitOverride) { - result = await onSubmitOverride(input); - } else { - // Build the userPatch for the ORM-generated mutation. - // `profilePictureUpload` is the correct field for a raw File object — - // the ORM/transport layer handles serialization. `profilePicture: null` - // clears the existing image. We never put a File inside `profilePicture` - // (which expects `ConstructiveInternalTypeImage = unknown` — a resolved - // jsonb object, not a DOM File). - const userPatch: { - displayName: string | null; - profilePicture?: null; - profilePictureUpload?: File; - } = { displayName: input.displayName ?? null }; - - if (pendingPicture === 'remove') { - userPatch.profilePicture = null; - } else if (pendingPicture !== null) { - userPatch.profilePictureUpload = pendingPicture.file; - } - - const data = await defaultMutation.mutateAsync({ - id: input.id, - userPatch - }); - const updatedUser = data.updateUser?.user; - result = { - user: { - id: updatedUser?.id ?? resolvedUser.id, - type: normalizeUserType((updatedUser as { type?: number | null } | undefined)?.type ?? null), - displayName: (updatedUser as { displayName?: string | null } | undefined)?.displayName ?? null, - profilePicture: - (updatedUser as { profilePicture?: ImageJsonb | null } | undefined)?.profilePicture ?? null - } - }; - } - - setPendingPicture(null); - setIsUploading(false); - onMessage?.({ kind: 'success', key: 'profileUpdated', message: merged.successToast }); - onSuccess?.(result); - } catch (err) { - setIsUploading(false); - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setFormError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - // --------------------------------------------------------------------------- - // Derived avatar state - // --------------------------------------------------------------------------- - const avatarUser = { - id: resolvedUser?.id ?? 'anon', - type: resolvedUser?.type ?? 'person', - displayName: (form.state.values.displayName || resolvedUser?.displayName || '') as string, - username: null, - profilePicture: - pendingPicture === 'remove' - ? null - : pendingPicture !== null - ? pendingPicture.preview - : resolveAvatarUrl(resolvedUser?.profilePicture) - }; - - const isAnyPending = isPending || isUploading; - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- - return ( - - - {merged.title} - {merged.description} - - - - {/* Profile picture */} - {/* `relative` makes this row the containing block for the sr-only file - input below; otherwise the absolutely-positioned (but visually - hidden) input keeps its in-flow static position at the row's right - edge and is measured against the page body, widening the document - and causing horizontal scroll at narrow viewports. */} -
-
- - {isUploading && ( -
- {merged.uploadingMessage} -
- )} -
- -
-

{merged.profilePictureHint}

-
- - {(resolvedUser?.profilePicture || pendingPicture) && pendingPicture !== 'remove' && ( - - )} -
-
- - {/* Visually-hidden file input. - `sr-only` makes the input `position: absolute` but does NOT set - `left`/`top`, so it would otherwise retain its static-position X - (far right, after the avatar + buttons). Pinning it to `left-0 - top-0` inside the `relative` row keeps its box at the card corner - so it can never extend the page's horizontal scroll bounds. */} - -
- - {/* Upload / form errors */} - - - {/* Display-name / org-name form */} -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - { - if (!value?.trim()) { - return isOrg ? 'Organization name is required' : 'Display name is required'; - } - return undefined; - } - }} - > - {(field) => ( - - )} - - - - {merged.saveButton} - -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-profile-card/auth-account-profile-card.requires.json b/apps/blocks/src/blocks/auth/account-profile-card/auth-account-profile-card.requires.json deleted file mode 100644 index 797a0f9..0000000 --- a/apps/blocks/src/blocks/auth/account-profile-card/auth-account-profile-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["updateUser"], - "queries": ["currentUser"], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-profile-card/messages.ts b/apps/blocks/src/blocks/auth/account-profile-card/messages.ts deleted file mode 100644 index ae10d3f..0000000 --- a/apps/blocks/src/blocks/auth/account-profile-card/messages.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * account-profile-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` - * as `customMessages`, so a host localises any code by overriding a single key. - */ - -export type AccountProfileCardMessages = { - title: string; - description: string; - displayNameLabel: string; - displayNamePlaceholder: string; - orgNameLabel: string; - orgNamePlaceholder: string; - profilePictureLabel: string; - profilePictureHint: string; - changePhotoButton: string; - removePhotoButton: string; - saveButton: string; - savingButton: string; - successToast: string; - uploadingMessage: string; - fileTooLarge: string; - fileTypeNotAccepted: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: top-level keys are each optional, AND the - * `errors` sub-map is itself partial so hosts can override one code at a time. - */ -export type AccountProfileCardMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAccountProfileCardMessages: AccountProfileCardMessages = { - title: 'Profile', - description: 'Update your display name and profile picture.', - displayNameLabel: 'Display name', - displayNamePlaceholder: 'Your name', - orgNameLabel: 'Organization name', - orgNamePlaceholder: 'Your organization name', - profilePictureLabel: 'Profile picture', - profilePictureHint: 'JPG, PNG or WebP. Max 5 MB.', - changePhotoButton: 'Change photo', - removePhotoButton: 'Remove photo', - saveButton: 'Save changes', - savingButton: 'Saving…', - successToast: 'Profile updated.', - uploadingMessage: 'Uploading photo…', - fileTooLarge: 'File exceeds maximum allowed size.', - fileTypeNotAccepted: 'File type not accepted.', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-security-card/account-security-card.tsx b/apps/blocks/src/blocks/auth/account-security-card/account-security-card.tsx deleted file mode 100644 index 07396a5..0000000 --- a/apps/blocks/src/blocks/auth/account-security-card/account-security-card.tsx +++ /dev/null @@ -1,322 +0,0 @@ -'use client'; - -/** - * account-security-card (registry: auth-account-security-card) - * - * At-a-glance security posture summary: password status, TOTP MFA status, and - * passkey count. Display-only — all actions are delegated via callbacks so the - * consumer decides how to navigate to the relevant management blocks. - * - * Data path (sdk-binding-contract.md §5, verified): - * • `useWebauthnCredentialsQuery` from `@/generated/auth` — lists passkeys to - * derive the count. `WebauthnCredentialsConnection` is confirmed in the - * generated SDK (useWebauthnCredentialsQuery.ts). - * • `adapter` prop: fully replaces the network call when provided (static - * value or async function), enabling non-Constructive backends, testing, - * and Storybook without a real QueryClient. - * - * SDK gap note: `totpEnabled` and `hasPassword` are NOT fields on the generated - * `User` type (`UserSelect` in orm/input-types.ts). They only appear on - * `SignInRecord` / session payloads. Until the backend exposes these on a public - * `currentUser()` query, they are read from the `currentUser` query's - * available fields. `totpEnabled` defaults to `false` (safe: prompts user to - * enroll rather than silently hiding the CTA). `hasPassword` defaults to `true` - * (safe: shows "Change password" rather than hiding the action). - * - * No mutations — this block is read-only. No form, no onSubmit override seam. - */ - -import { useEffect, useMemo, useState } from 'react'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Separator } from '@constructive-io/ui/separator'; -import { Skeleton } from '@constructive-io/ui/skeleton'; - -import { cn } from '@/lib/utils'; -import { useWebauthnCredentialsQuery } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; - -import { - defaultAccountSecurityCardMessages, - interpolate, - type AccountSecurityCardMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AccountSecurityCardMessageOverrides = Partial< - Omit -> & { - errors?: Partial; -}; - -/** Derived security posture values surfaced by the block. */ -export type SecurityStatus = { - hasPassword: boolean; - totpEnabled: boolean; - passkeyCount: number; -}; - -/** - * Query adapter — replaces `useWebauthnCredentialsQuery` when provided. - * Accepts a static result object or an async function that resolves to one. - * Enables non-Constructive backends, unit tests, and Storybook without a - * real QueryClient (sdk-binding-contract.md §4). - */ -export type AccountSecurityCardAdapter = - | { webauthnCredentials: { totalCount: number } } - | (() => Promise<{ webauthnCredentials: { totalCount: number } }>); - -export type AccountSecurityCardProps = { - /** Called when user clicks the change-password / set-password CTA. */ - onChangePassword?: () => void; - /** - * Called when user clicks the MFA enable/disable CTA. - * If undefined the MFA row CTA is hidden (backend-pending; consumer opts in - * only when TOTP enrollment is available). - */ - onManageMfa?: () => void; - /** Called when user clicks the manage passkeys CTA. */ - onManagePasskeys?: () => void; - /** - * Query adapter. When provided, fully replaces `useWebauthnCredentialsQuery`. - * Pass a static `{ webauthnCredentials: { totalCount: number } }` or an async - * factory function for dynamic data. - */ - adapter?: AccountSecurityCardAdapter; - messages?: AccountSecurityCardMessageOverrides; - /** Fires on query errors. Receives the normalised `{ message, code }` shape. */ - onError?: (err: { message: string; code: string }) => void; - /** Fires for any notification event. Always fires. */ - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountSecurityCard({ - onChangePassword, - onManageMfa, - onManagePasskeys, - adapter, - messages: messageOverrides, - onError, - onMessage, - className -}: AccountSecurityCardProps) { - // Deep merge — top-level + errors nested separately. - // Memoized so the useEffect dependency array stays stable across renders. - const merged: AccountSecurityCardMessages = useMemo( - () => ({ - ...defaultAccountSecurityCardMessages, - ...messageOverrides, - errors: { - ...defaultAccountSecurityCardMessages.errors, - ...messageOverrides?.errors - } - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(messageOverrides)] - ); - - // Local error string for inline display. - const [error, setError] = useState(null); - - // ------------------------------------------------------------------ - // Data: passkey count — adapter path - // When `adapter` is provided, resolve it (static object or async fn) - // and skip the generated hook entirely (sdk-binding-contract.md §4). - // Static objects are applied immediately; async functions load once. - // ------------------------------------------------------------------ - const [adapterData, setAdapterData] = useState<{ webauthnCredentials: { totalCount: number } } | null>( - () => adapter && typeof adapter !== 'function' ? adapter : null - ); - const [adapterLoading, setAdapterLoading] = useState(false); - - useEffect(() => { - if (!adapter || typeof adapter !== 'function') return; - setAdapterLoading(true); - adapter() - .then((result) => { - setAdapterData(result); - setAdapterLoading(false); - }) - .catch(() => setAdapterLoading(false)); - // Run once per adapter identity change only. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter]); - - // ------------------------------------------------------------------ - // Data: passkey count — generated hook path (skipped when adapter set) - // useWebauthnCredentialsQuery is confirmed in the generated auth SDK. - // The `totalCount` field on the ConnectionResult gives us the count - // without fetching full credential records (first: 0 is sufficient). - // React Query v5 removed onError from useQuery options; errors are - // handled via the returned `isError` / `error` fields instead. - // ------------------------------------------------------------------ - const credentialsQuery = useWebauthnCredentialsQuery({ - selection: { fields: { id: true }, first: 0 }, - enabled: !adapter - }); - - // Surface query errors via callbacks + inline alert. - useEffect(() => { - if (!credentialsQuery.isError || !credentialsQuery.error) return; - const err = credentialsQuery.error; - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key: `accountSecurity.${key}`, message }); - onError?.({ message, code: key }); - }, [credentialsQuery.isError, credentialsQuery.error, merged.errors, onError, onMessage]); - - const isLoading = adapter ? adapterLoading : credentialsQuery.isLoading; - const passkeyCount = adapter - ? (adapterData?.webauthnCredentials?.totalCount ?? 0) - : (credentialsQuery.data?.webauthnCredentials?.totalCount ?? 0); - - // ------------------------------------------------------------------ - // Security status - // - // SDK gap: `totpEnabled` and `hasPassword` are not on the `User` type - // in the generated auth SDK (they appear only on session/sign-in - // payloads). Until the backend exposes them on `currentUser()`, the - // block defaults both to safe values so display is always meaningful: - // hasPassword = true → shows "Change password" (not "Set password") - // totpEnabled = false → shows "Enable" (not "Manage") - // ------------------------------------------------------------------ - const hasPassword = true; - const totpEnabled = false; - - // ------------------------------------------------------------------ - // Skeleton loader - // ------------------------------------------------------------------ - if (isLoading) { - return ( - - - - - - - {[0, 1, 2].map((i) => ( -
-
- - -
- -
- ))} -
-
- ); - } - - // ------------------------------------------------------------------ - // Render - // ------------------------------------------------------------------ - const passkeysStatus = - passkeyCount > 0 - ? interpolate(merged.passkeysCountStatus, { count: passkeyCount }) - : merged.passkeysNoneStatus; - - return ( - - - {merged.title} - {merged.description} - - - - - - {/* Password row */} -
-
-
-
{merged.passwordLabel}
-
- - {hasPassword ? merged.passwordSetStatus : merged.passwordNotSetStatus} - -
-
- {onChangePassword && ( - - )} -
-
- - - - {/* MFA row */} -
-
-
-
{merged.mfaLabel}
-
- - {totpEnabled ? merged.mfaEnabledStatus : merged.mfaDisabledStatus} - -
-
- {onManageMfa && ( - - )} -
-
- - - - {/* Passkeys row */} -
-
-
-
{merged.passkeysLabel}
-
{passkeysStatus}
-
- {onManagePasskeys && ( - - )} -
-
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-security-card/auth-account-security-card.requires.json b/apps/blocks/src/blocks/auth/account-security-card/auth-account-security-card.requires.json deleted file mode 100644 index 6a76973..0000000 --- a/apps/blocks/src/blocks/auth/account-security-card/auth-account-security-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": [], - "queries": ["webauthnCredentials"], - "models": ["webauthnCredential"] -} diff --git a/apps/blocks/src/blocks/auth/account-security-card/messages.ts b/apps/blocks/src/blocks/auth/account-security-card/messages.ts deleted file mode 100644 index 8e09a90..0000000 --- a/apps/blocks/src/blocks/auth/account-security-card/messages.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * account-security-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * `passkeysCountStatus` uses `{{count}}` interpolation. Use - * `interpolate(messages.passkeysCountStatus, { count })` in the component - * (per i18n-contract §9 and the block spec). - */ - -export type AccountSecurityCardMessages = { - title: string; - description: string; - passwordLabel: string; - passwordSetStatus: string; - passwordNotSetStatus: string; - changePasswordButton: string; - setPasswordButton: string; - mfaLabel: string; - mfaEnabledStatus: string; - mfaDisabledStatus: string; - manageMfaButton: string; - enableMfaButton: string; - passkeysLabel: string; - /** Single interpolated string with {{count}} placeholder. */ - passkeysCountStatus: string; - passkeysNoneStatus: string; - managePasskeysButton: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -export const defaultAccountSecurityCardMessages: AccountSecurityCardMessages = { - title: 'Security', - description: 'Manage your password, two-factor authentication, and passkeys.', - passwordLabel: 'Password', - passwordSetStatus: 'Set', - passwordNotSetStatus: 'Not set', - changePasswordButton: 'Change password', - setPasswordButton: 'Set password', - mfaLabel: 'Two-factor authentication', - mfaEnabledStatus: 'Enabled', - mfaDisabledStatus: 'Disabled', - manageMfaButton: 'Manage', - enableMfaButton: 'Enable', - passkeysLabel: 'Passkeys', - passkeysCountStatus: '{{count}} passkey(s) registered', - passkeysNoneStatus: 'No passkeys registered', - managePasskeysButton: 'Manage passkeys', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; - -/** - * Simple `{{key}}` mustache interpolation. - * Replaces all `{{key}}` occurrences in `template` with the corresponding value - * from `vars`. Values are coerced to string. - */ -export function interpolate(template: string, vars: Record): string { - return Object.entries(vars).reduce( - (acc, [key, value]) => acc.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value)), - template - ); -} diff --git a/apps/blocks/src/blocks/auth/account-sessions-list/account-sessions-list.tsx b/apps/blocks/src/blocks/auth/account-sessions-list/account-sessions-list.tsx deleted file mode 100644 index 27de78e..0000000 --- a/apps/blocks/src/blocks/auth/account-sessions-list/account-sessions-list.tsx +++ /dev/null @@ -1,455 +0,0 @@ -'use client'; - -/** - * account-sessions-list (registry: auth-account-sessions-list) - * - * Displays the signed-in user's active sessions and provides revoke actions. - * Because `user_sessions` is a `constructive_auth_private` view with NO public - * API, there is no generated list hook — the list is supplied by the host via the - * `sessions` adapter prop (default: empty array, renders the empty state). - * Only the `revokeSession` mutation is bindable today via `useRevokeSessionMutation` - * from `@/generated/auth`. - * - * SDK gap: no `useUserSessionsQuery` hook exists (sdk-binding-contract.md §10). - * When a `UserSessionsConnection` ships, add `useUserSessionsQuery` from - * `@/generated/auth` and update `requires.json` with `"queries":["userSessions"]`. - * - * Step-up tiers (step-up-contract.md §6): - * - Single revoke → `tier: 'medium'` (password) - * - Revoke all others → `tier: 'high'` (MFA if enrolled, else password) - * - * Binding doctrine (sdk-binding-contract.md §3, §5): - * • Generated hook imported from `@/generated/auth` — never `@constructive-io/data`. - * • No `configure()`/`getClient()`, no `QueryClientProvider`. Host mounts blocks-runtime. - * • `onRevokeSubmit` override seam replaces the default hook call. - */ - -import { useState } from 'react'; - -import { Badge } from '@constructive-io/ui/badge'; -import { Button } from '@constructive-io/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@constructive-io/ui/dialog'; -import { Separator } from '@constructive-io/ui/separator'; - -import { cn } from '@/lib/utils'; -import { useRevokeSessionMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; - -import { - defaultAccountSessionsListMessages, - type AccountSessionsListMessages, - type AccountSessionsListMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export type ParsedDevice = { - browser: string | null; - os: string | null; - deviceType: 'desktop' | 'mobile' | 'tablet' | 'unknown'; -}; - -export type SessionRow = { - id: string; - isCurrent: boolean; - authMethod: 'password' | 'identity' | 'magic_link' | 'email_otp' | 'sms_otp' | 'anonymous' | string; - userAgent: string | null; - parsedDevice: ParsedDevice | null; - ip: string | null; - origin: string | null; - lastUsedAt: string | null; - createdAt: string; - expiresAt: string; -}; - -/** Variables passed to the `onRevokeSubmit` override. */ -export type RevokeSessionVars = { - sessionId: string; -}; - -/** Result shape; mirrors the `revokeSession` payload fields this block selects. */ -export type RevokeSessionResult = { - result: boolean | null; -}; - -export type AccountSessionsListProps = { - /** - * The list of sessions to display. There is NO generated list hook for - * `user_sessions` (it is in `constructive_auth_private`, no public API → - * no `*Connection` type). The host must supply rows; the default is `[]` - * which renders the empty state. - * - * sdk-binding-contract.md §10 documents this gap. - */ - sessions?: SessionRow[]; - /** Override the `useRevokeSessionMutation` call for a single revoke. */ - onRevokeSubmit?: (vars: RevokeSessionVars) => Promise; - /** Fires after a single session is successfully revoked. */ - onSessionRevoked?: (sessionId: string) => void; - /** Fires after all other sessions are successfully revoked. */ - onAllOtherSessionsRevoked?: () => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and step-up events. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - messages?: AccountSessionsListMessageOverrides; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * Lightweight user-agent parser. Returns basic device/browser labels without - * shipping a heavy UA-parser library. Good enough for "Chrome on macOS" level. - */ -function parseUserAgent(ua: string | null): ParsedDevice { - if (!ua) return { browser: null, os: null, deviceType: 'unknown' }; - - // Device type - let deviceType: ParsedDevice['deviceType'] = 'desktop'; - if (/mobile/i.test(ua)) deviceType = 'mobile'; - else if (/tablet|ipad/i.test(ua)) deviceType = 'tablet'; - - // Browser - let browser: string | null = null; - if (/edg\//i.test(ua)) browser = 'Edge'; - else if (/chrome\//i.test(ua) && !/chromium/i.test(ua)) browser = 'Chrome'; - else if (/firefox\//i.test(ua)) browser = 'Firefox'; - else if (/safari\//i.test(ua) && !/chrome/i.test(ua)) browser = 'Safari'; - else if (/opr\//i.test(ua)) browser = 'Opera'; - - // OS - let os: string | null = null; - if (/windows nt/i.test(ua)) os = 'Windows'; - else if (/macintosh|mac os x/i.test(ua)) os = 'macOS'; - else if (/iphone|ipad/i.test(ua)) os = 'iOS'; - else if (/android/i.test(ua)) os = 'Android'; - else if (/linux/i.test(ua)) os = 'Linux'; - - return { browser, os, deviceType }; -} - -/** Relative-time label for `lastUsedAt`. Uses Intl.RelativeTimeFormat. */ -function formatRelativeTime(isoDate: string | null): string | null { - if (!isoDate) return null; - const diffMs = Date.now() - new Date(isoDate).getTime(); - const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); - const diffSecs = Math.round(diffMs / 1000); - if (Math.abs(diffSecs) < 60) return rtf.format(-diffSecs, 'second'); - const diffMins = Math.round(diffSecs / 60); - if (Math.abs(diffMins) < 60) return rtf.format(-diffMins, 'minute'); - const diffHours = Math.round(diffMins / 60); - if (Math.abs(diffHours) < 24) return rtf.format(-diffHours, 'hour'); - const diffDays = Math.round(diffHours / 24); - return rtf.format(-diffDays, 'day'); -} - -/** Device icon text by device type (placeholder — hosts can style with SVG icons). */ -function deviceLabel(device: ParsedDevice | null): string { - if (!device) return '?'; - if (device.deviceType === 'mobile') return '📱'; - if (device.deviceType === 'tablet') return '📱'; - return '💻'; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function AccountSessionsList({ - sessions = [], - onRevokeSubmit: onRevokeSubmitOverride, - onSessionRevoked, - onAllOtherSessionsRevoked, - onError, - onMessage, - messages: messageOverrides, - className -}: AccountSessionsListProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: AccountSessionsListMessages = { - ...defaultAccountSessionsListMessages, - ...messageOverrides, - errors: { ...defaultAccountSessionsListMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook — `revokeSession` takes `{ input: { sessionId } }`. - // Payload: `{ revokeSession: { result: boolean | null } | null }`. - const defaultRevokeMutation = useRevokeSessionMutation({ - selection: { - fields: { result: true } - } - }); - - // Hybrid pending: override path tracks its own pending state. - const [overridePending, setOverridePending] = useState(false); - const isRevokePending = onRevokeSubmitOverride ? overridePending : defaultRevokeMutation.isPending; - - // Dialog state - const [confirmSession, setConfirmSession] = useState(null); - const [confirmRevokeAll, setConfirmRevokeAll] = useState(false); - const [error, setError] = useState(null); - - // step-up hook - const stepUp = useStepUp(); - - async function runRevoke(sessionId: string): Promise { - if (onRevokeSubmitOverride) return onRevokeSubmitOverride({ sessionId }); - const data = await defaultRevokeMutation.mutateAsync({ input: { sessionId } }); - if (!data.revokeSession) return null; - return { result: data.revokeSession.result ?? null }; - } - - async function handleRevokeSingle(session: SessionRow) { - setError(null); - if (onRevokeSubmitOverride) setOverridePending(true); - try { - // Step-up: medium tier (password) for single revoke. - await stepUp({ tier: 'medium' }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - onMessage?.({ kind: 'warning', key: 'STEP_UP_CANCELLED', message: merged.stepUpCancelled }); - setConfirmSession(null); - if (onRevokeSubmitOverride) setOverridePending(false); - return; - } - // Step-up infrastructure failure — surface as error. - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - if (onRevokeSubmitOverride) setOverridePending(false); - return; - } - - try { - await runRevoke(session.id); - setConfirmSession(null); - onMessage?.({ kind: 'success', key: 'revokeSession.success', message: merged.sessionRevokedMessage }); - onSessionRevoked?.(session.id); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onRevokeSubmitOverride) setOverridePending(false); - } - } - - async function handleRevokeAllOthers() { - setError(null); - if (onRevokeSubmitOverride) setOverridePending(true); - try { - // Step-up: high tier (MFA if enrolled, else password) for revoke-all-others. - await stepUp({ tier: 'high' }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - onMessage?.({ kind: 'warning', key: 'STEP_UP_CANCELLED', message: merged.stepUpCancelled }); - setConfirmRevokeAll(false); - if (onRevokeSubmitOverride) setOverridePending(false); - return; - } - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - if (onRevokeSubmitOverride) setOverridePending(false); - return; - } - - // No bulk procedure: iterate non-current sessions client-side. - // backend-spec/future-procedures.md: bulk_revoke_sessions is backend-pending. - const others = sessions.filter((s) => !s.isCurrent); - try { - await Promise.all(others.map((s) => runRevoke(s.id))); - setConfirmRevokeAll(false); - onMessage?.({ kind: 'success', key: 'revokeAllOthers.success', message: merged.allOtherRevokedMessage }); - onAllOtherSessionsRevoked?.(); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onRevokeSubmitOverride) setOverridePending(false); - } - } - - const otherSessionCount = sessions.filter((s) => !s.isCurrent).length; - - return ( - - - {merged.title} - {merged.description} - - - - - - {sessions.length === 0 ? ( -

{merged.noSessionsDescription}

- ) : ( -
    - {sessions.map((session, idx) => { - const device = session.parsedDevice ?? parseUserAgent(session.userAgent); - const relativeTime = formatRelativeTime(session.lastUsedAt); - const label = [device.browser, device.os].filter(Boolean).join(' on ') || merged.unknownDevice; - - return ( -
  • - {idx > 0 && } -
    -
    -
    - - {label} - {session.isCurrent && ( - - {merged.currentSessionBadge} - - )} -
    -
    - {relativeTime && ( - - {merged.lastUsedLabel}: {relativeTime} - - )} - {session.ip ? ( - - {merged.ipLabel}: {session.ip} - - ) : ( - {merged.unknownLocation} - )} -
    -
    - - -
    - {session.isCurrent && ( -

    - {merged.currentSessionBadge} -

    - )} -
  • - ); - })} -
- )} - - {otherSessionCount > 0 && ( - <> - - - - )} -
- - {/* Single-session revoke confirmation dialog */} - { if (!open) setConfirmSession(null); }}> - - - {merged.revokeConfirmTitle} - {merged.revokeConfirmDescription} - - - - - - { if (confirmSession) handleRevokeSingle(confirmSession); }} - data-testid="revoke-confirm-button" - > - {merged.revokeConfirmButton} - - - - - - {/* Revoke-all-others confirmation dialog */} - { if (!open) setConfirmRevokeAll(false); }}> - - - {merged.revokeAllConfirmTitle} - {merged.revokeAllConfirmDescription} - - - - - - - {merged.revokeAllConfirmButton} - - - - -
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-sessions-list/auth-account-sessions-list.requires.json b/apps/blocks/src/blocks/auth/account-sessions-list/auth-account-sessions-list.requires.json deleted file mode 100644 index fa3a2a9..0000000 --- a/apps/blocks/src/blocks/auth/account-sessions-list/auth-account-sessions-list.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["revokeSession"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-sessions-list/messages.ts b/apps/blocks/src/blocks/auth/account-sessions-list/messages.ts deleted file mode 100644 index d2ac9b1..0000000 --- a/apps/blocks/src/blocks/auth/account-sessions-list/messages.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * account-sessions-list — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type AccountSessionsListMessages = { - title: string; - description: string; - currentSessionBadge: string; - revokeButton: string; - revokeConfirmTitle: string; - revokeConfirmDescription: string; - revokeConfirmButton: string; - revokeCancelButton: string; - revokeAllOtherButton: string; - revokeAllConfirmTitle: string; - revokeAllConfirmDescription: string; - revokeAllConfirmButton: string; - revokeAllCancelButton: string; - sessionRevokedMessage: string; - allOtherRevokedMessage: string; - lastUsedLabel: string; - createdLabel: string; - ipLabel: string; - unknownDevice: string; - unknownLocation: string; - stepUpCancelled: string; - noSessionsDescription: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: top-level copy is shallow-partial; `errors` is - * itself partial so a host can localize a single error code without restating - * the whole map. - */ -export type AccountSessionsListMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAccountSessionsListMessages: AccountSessionsListMessages = { - title: 'Active sessions', - description: 'These are the devices currently signed in to your account. Revoke any session you do not recognise.', - currentSessionBadge: 'This device', - revokeButton: 'Revoke', - revokeConfirmTitle: 'Revoke session?', - revokeConfirmDescription: 'This device will be signed out immediately.', - revokeConfirmButton: 'Revoke', - revokeCancelButton: 'Cancel', - revokeAllOtherButton: 'Revoke all other sessions', - revokeAllConfirmTitle: 'Revoke all other sessions?', - revokeAllConfirmDescription: - 'All sessions except the current one will be signed out. You will remain signed in on this device.', - revokeAllConfirmButton: 'Revoke all', - revokeAllCancelButton: 'Cancel', - sessionRevokedMessage: 'Session revoked.', - allOtherRevokedMessage: 'All other sessions revoked.', - lastUsedLabel: 'Last active', - createdLabel: 'Signed in', - ipLabel: 'IP', - unknownDevice: 'Unknown device', - unknownLocation: 'Unknown location', - stepUpCancelled: 'Step-up verification cancelled.', - noSessionsDescription: 'No active sessions found.', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/account-settings-page/account-settings-page.tsx b/apps/blocks/src/blocks/auth/account-settings-page/account-settings-page.tsx deleted file mode 100644 index 7aa9167..0000000 --- a/apps/blocks/src/blocks/auth/account-settings-page/account-settings-page.tsx +++ /dev/null @@ -1,330 +0,0 @@ -'use client'; - -/** - * account-settings-page (registry: auth-account-settings-page) - * - * THE CONVERGENCE PAGE. Composes all account-settings section cards into a - * single tabbed layout. Installing this page automatically pulls in every - * section card via registryDependencies. - * - * DATA PATH: calls `useCurrentUserQuery` from `@/generated/auth` once at - * mount to read the current user's `id` and `type`. The result is used to - * gate the api-keys tab behind the `allowApiKeys` prop (feature flag) and - * provide a loading skeleton while the query resolves. - * - * Tab routing: reads `?tab=` from the URL via `useSearchParams()` and - * activates the matching tab. Changing tabs updates the URL for deep-linking. - * - * Pages MAY use `next/navigation`; Cards MUST NOT (block-contract.md §6). - * Ships `auth-account-settings-page.requires.json` per sdk-binding-contract §7. - * - * The Suspense boundary is required by Next.js 15 when `useSearchParams` is - * used anywhere in the component tree below a Client Component boundary. - * - * section cards composed: - * auth-account-profile-card, auth-account-emails-list, - * auth-account-security-card, auth-account-sessions-list, - * auth-account-api-keys-list, auth-account-connected-accounts, - * auth-account-phones-list, auth-account-danger-card - */ - -import { Suspense, useCallback } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; - -import { cn } from '@/lib/utils'; -import { useCurrentUserQuery } from '@/generated/auth'; - -import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card'; -import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list'; -import { AccountSecurityCard } from '@/blocks/auth/account-security-card/account-security-card'; -import { AccountSessionsList } from '@/blocks/auth/account-sessions-list/account-sessions-list'; -import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list'; -import { AccountConnectedAccounts } from '@/blocks/auth/account-connected-accounts/account-connected-accounts'; -import { AccountPhonesList } from '@/blocks/auth/account-phones-list/account-phones-list'; -import { AccountDangerCard } from '@/blocks/auth/account-danger-card/account-danger-card'; - -import { - defaultAccountSettingsPageMessages, - type AccountSettingsPageMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AccountSettingsSection = - | 'profile' - | 'emails' - | 'security' - | 'sessions' - | 'api-keys' - | 'connected-accounts' - | 'phones' - | 'danger'; - -const ALL_SECTIONS: AccountSettingsSection[] = [ - 'profile', - 'emails', - 'security', - 'sessions', - 'api-keys', - 'connected-accounts', - 'phones', - 'danger' -]; - -const DEFAULT_TAB: AccountSettingsSection = 'profile'; - -export type AccountSettingsPageMessageOverrides = Partial; - -export type AccountSettingsPageProps = { - /** - * Which sections to render. Defaults to all sections. - * Consumers can hide sections they don't need without forking the page. - */ - sections?: AccountSettingsSection[]; - messages?: AccountSettingsPageMessageOverrides; - /** - * Route to push after account deletion email is sent. - * Passed through to `auth-account-danger-card`. - */ - onDeletionEmailSent?: () => void; - /** - * Route to use for change-password action. - * Passed through to `auth-account-security-card`. - */ - onChangePassword?: () => void; - /** - * Route to use for manage-passkeys action. - * Passed through to `auth-account-security-card`. - */ - onManagePasskeys?: () => void; - /** - * Route to use for manage-MFA action. When undefined, the security card - * will hide the MFA management CTA (backend-pending in v1). - * Passed through to `auth-account-security-card`. - */ - onManageMfa?: () => void; - /** - * Feature flag: whether the host app has API keys enabled - * (`app_settings_auth.allow_api_keys`). Defaults to `true`. - * When `false`, the API keys tab is omitted from the tab list. - */ - allowApiKeys?: boolean; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Inner content component — must be inside because it calls -// useSearchParams() (Next.js 15 requirement). -// --------------------------------------------------------------------------- - -type AccountSettingsPageContentProps = Omit & { - merged: AccountSettingsPageMessages; - effectiveSections: AccountSettingsSection[]; -}; - -function AccountSettingsPageContent({ - merged, - effectiveSections, - onDeletionEmailSent, - onChangePassword, - onManagePasskeys, - onManageMfa -}: AccountSettingsPageContentProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - // Active tab from URL `?tab=`, falling back to the first visible section. - const rawTab = searchParams.get('tab') as AccountSettingsSection | null; - const firstSection = effectiveSections[0] ?? DEFAULT_TAB; - const activeTab: AccountSettingsSection = - rawTab && effectiveSections.includes(rawTab) ? rawTab : firstSection; - - const handleTabChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (value: any) => { - if (!value || typeof value !== 'string') return; - const params = new URLSearchParams(searchParams.toString()); - params.set('tab', value); - router.replace(`?${params.toString()}`, { scroll: false }); - }, - [router, searchParams] - ); - - const show = (section: AccountSettingsSection) => effectiveSections.includes(section); - - return ( - - - {show('profile') && ( - {merged.profileTabLabel} - )} - {show('emails') && ( - {merged.emailsTabLabel} - )} - {show('security') && ( - {merged.securityTabLabel} - )} - {show('sessions') && ( - {merged.sessionsTabLabel} - )} - {show('api-keys') && ( - {merged.apiKeysTabLabel} - )} - {show('connected-accounts') && ( - - {merged.connectedAccountsTabLabel} - - )} - {show('phones') && ( - {merged.phonesTabLabel} - )} - {show('danger') && ( - {merged.dangerTabLabel} - )} - - - {show('profile') && ( - - - - )} - - {show('emails') && ( - - - - )} - - {show('security') && ( - - - - )} - - {show('sessions') && ( - - - - )} - - {show('api-keys') && ( - - - - )} - - {show('connected-accounts') && ( - - - - )} - - {show('phones') && ( - - - - )} - - {show('danger') && ( - - - - )} - - ); -} - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- - -/** - * Default export — drop this file at `app/auth/account/page.tsx`. - * The `Suspense` boundary is required by Next.js 15 when `useSearchParams` is - * used anywhere in the component tree below a Client Component boundary. - * - * Calls `useCurrentUserQuery` once to read the current user. Uses the result - * to gate the API keys tab behind the `allowApiKeys` prop. - */ -export default function AccountSettingsPage({ - sections = ALL_SECTIONS, - messages: messageOverrides, - onDeletionEmailSent, - onChangePassword, - onManagePasskeys, - onManageMfa, - allowApiKeys = true, - className -}: AccountSettingsPageProps) { - const merged: AccountSettingsPageMessages = { - ...defaultAccountSettingsPageMessages, - ...messageOverrides - }; - - // Single top-level query — avoids N+1 loading on mount. - // Reads id + type; totpEnabled is not yet on UserSelect (backend pending). - const { isLoading: currentUserLoading } = useCurrentUserQuery({ - selection: { fields: { id: true, type: true } } - }); - - // Gate api-keys tab: omit when allowApiKeys flag is off. - const effectiveSections: AccountSettingsSection[] = allowApiKeys - ? sections - : sections.filter((s) => s !== 'api-keys'); - - return ( -
- {/* Skip-to-content for screen readers. - `sr-only` makes the anchor `position: absolute` without setting - `left`/`top`, so it would keep its static position and can extend the - page's horizontal scroll bounds at narrow viewports; `relative` on the - page wrapper + `left-0 top-0` pin it to the block's corner (which is - also where it should surface when focus reveals it). */} - - {merged.skipToContentLabel} - - -

{merged.pageTitle}

- - {currentUserLoading ? ( -
-
-
-
- ) : ( - - - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/account-settings-page/auth-account-settings-page.requires.json b/apps/blocks/src/blocks/auth/account-settings-page/auth-account-settings-page.requires.json deleted file mode 100644 index b61c8d3..0000000 --- a/apps/blocks/src/blocks/auth/account-settings-page/auth-account-settings-page.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": [], - "queries": ["currentUser"], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/account-settings-page/messages.ts b/apps/blocks/src/blocks/auth/account-settings-page/messages.ts deleted file mode 100644 index ca75c2c..0000000 --- a/apps/blocks/src/blocks/auth/account-settings-page/messages.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * account-settings-page — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy for the page-level chrome (tab labels, page title, - * skip-to-content). Section-level messages belong to each composed card. - */ - -export type AccountSettingsPageMessages = { - pageTitle: string; - skipToContentLabel: string; - profileTabLabel: string; - emailsTabLabel: string; - securityTabLabel: string; - sessionsTabLabel: string; - apiKeysTabLabel: string; - connectedAccountsTabLabel: string; - phonesTabLabel: string; - dangerTabLabel: string; -}; - -export const defaultAccountSettingsPageMessages: AccountSettingsPageMessages = { - pageTitle: 'Account settings', - skipToContentLabel: 'Skip to main content', - profileTabLabel: 'Profile', - emailsTabLabel: 'Emails', - securityTabLabel: 'Security', - sessionsTabLabel: 'Sessions', - apiKeysTabLabel: 'API keys', - connectedAccountsTabLabel: 'Connected accounts', - phonesTabLabel: 'Phones', - dangerTabLabel: 'Account' -}; diff --git a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/anonymous-sign-in-button.tsx b/apps/blocks/src/blocks/auth/anonymous-sign-in-button/anonymous-sign-in-button.tsx deleted file mode 100644 index 0ac3b92..0000000 --- a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/anonymous-sign-in-button.tsx +++ /dev/null @@ -1,177 +0,0 @@ -'use client'; - -/** - * anonymous-sign-in-button (registry: auth-anonymous-sign-in-button) - * - * Single-click guest session button. Creates an anonymous session - * (sessions.is_anonymous=true) without requiring any credentials. - * - * BACKEND-PENDING — CASE (b): - * The `anonymous_sign_in` procedure is not yet deployed in - * `constructive_auth_public`, so `useAnonymousSignInMutation` does NOT exist - * in the generated `@/generated/auth` SDK yet. To keep tsc clean, the - * @/generated/auth import is omitted until the proc ships and codegen is re-run. - * - * The `onSubmit` override seam is therefore the PRIMARY path (required for - * production use until backend ships). Once the backend procedure is deployed: - * 1. Re-run `cnc codegen --api-names auth --react-query --orm -o src/generated` - * 2. Uncomment the `useAnonymousSignInMutation` import below - * 3. Remove the compile-time guard block - * - * Data-binding doctrine: sdk-binding-contract.md §5, §10 (gap honesty). - * No fetch, no GraphQL document string, no configure()/getClient(), no - * QueryClientProvider — all wiring is done by @constructive/blocks-runtime. - * - * Anonymous session upgrade (convert to a real account) is a separate flow - * not provided by this block. Consumers are responsible for hiding this button - * when app_settings_auth.allow_anonymous_sessions is false. - */ - -import { useState } from 'react'; - -// BACKEND-PENDING: Uncomment this import once the anonymous_sign_in proc ships -// and cnc codegen has been re-run. The hook name (verified by contract) is: -// useAnonymousSignInMutation (from @/generated/auth) -// import { useAnonymousSignInMutation } from '@/generated/auth'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; - -import { - defaultAnonymousSignInButtonMessages, - type AnonymousSignInButtonMessageOverrides -} from './messages'; - -/** The result shape returned from the anonymous sign-in call. */ -export type AnonymousSignInResult = { - id: string; - userId: string; - accessToken: string; - accessTokenExpiresAt: string; - isAnonymous: true; -}; - -export type AnonymousSignInButtonProps = { - /** Button text override (uses messages.buttonText by default). */ - children?: React.ReactNode; - /** Credential kind sent to the API (default `'bearer'`). */ - credentialKind?: 'bearer' | 'cookie'; - /** Whether to create a persistent session (default false for guest). */ - rememberMe?: boolean; - messages?: AnonymousSignInButtonMessageOverrides; - /** Button visual variant (passed to the underlying Button). */ - variant?: 'default' | 'outline' | 'ghost' | 'link'; - /** - * Replace the default `useAnonymousSignInMutation` call. - * REQUIRED until `anonymous_sign_in` backend procedure ships. - * The host wires the generated binding after regenerating the SDK. - */ - onSubmit?: () => Promise; - /** Fires after a resolved anonymous sign-in. Always fires. */ - onSuccess?: (result: AnonymousSignInResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -export function AnonymousSignInButton({ - children, - credentialKind = 'bearer', - rememberMe = false, - messages: messageOverrides, - variant = 'outline', - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: AnonymousSignInButtonProps) { - // Deep merge: top-level copy + the errors map merged separately. - const merged = { - ...defaultAnonymousSignInButtonMessages, - ...messageOverrides, - errors: { ...defaultAnonymousSignInButtonMessages.errors, ...messageOverrides?.errors } - }; - - // BACKEND-PENDING (Case b): the generated hook is not yet in the SDK, so we - // cannot instantiate useAnonymousSignInMutation here. Once the proc ships, - // replace the stub below with: - // - // const defaultMutation = useAnonymousSignInMutation({ - // selection: { - // fields: { - // id: true, - // userId: true, - // accessToken: true, - // accessTokenExpiresAt: true, - // } - // } - // }); - // - // And update runAnonymousSignIn to: - // const data = await defaultMutation.mutateAsync({ input: { rememberMe, credentialKind } }); - // return data.anonymousSignIn as AnonymousSignInResult; - // - // Hybrid pending: onSubmitOverride ? overridePending : defaultMutation.isPending - - const [overridePending, setOverridePending] = useState(false); - const [error, setError] = useState(null); - - // While the backend is pending, isPending tracks only the override path. - // When the default mutation is restored, swap to the hybrid pattern. - const isPending = overridePending; - - async function handleClick() { - setError(null); - - if (!onSubmitOverride) { - // Backend pending — surface PROCEDURE_NOT_FOUND as a clear message. - const msg = merged.errors.PROCEDURE_NOT_FOUND; - const code = 'PROCEDURE_NOT_FOUND'; - setError(msg); - onMessage?.({ kind: 'error', key: code, message: msg }); - onError?.({ message: msg, code }); - return; - } - - setOverridePending(true); - try { - const result = await onSubmitOverride(); - onMessage?.({ kind: 'success', key: 'anonymousSignIn.success', message: merged.successMessage }); - onSuccess?.(result); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setOverridePending(false); - } - } - - return ( -
- - - {children ?? merged.buttonText} - -
- ); -} diff --git a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/auth-anonymous-sign-in-button.requires.json b/apps/blocks/src/blocks/auth/anonymous-sign-in-button/auth-anonymous-sign-in-button.requires.json deleted file mode 100644 index 3092db7..0000000 --- a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/auth-anonymous-sign-in-button.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["anonymousSignIn"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/messages.ts b/apps/blocks/src/blocks/auth/anonymous-sign-in-button/messages.ts deleted file mode 100644 index 553346d..0000000 --- a/apps/blocks/src/blocks/auth/anonymous-sign-in-button/messages.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * anonymous-sign-in-button — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` - * as `customMessages`. - * - * PROCEDURE_NOT_FOUND is required because `anonymous_sign_in` is a - * backend-pending procedure. It will surface as a GraphQL error until the - * proc ships and codegen is re-run. - */ - -export type AnonymousSignInButtonMessages = { - buttonText: string; - buttonPending: string; - successMessage: string; - /** Error messages keyed by UPPER_SNAKE_CASE backend error code */ - errors: { - PROCEDURE_NOT_FOUND: string; - ANONYMOUS_DISABLED: string; - RATE_LIMITED: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: hosts can localize a single error code without - * restating the full map (block-contract.md §10). - */ -export type AnonymousSignInButtonMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultAnonymousSignInButtonMessages: AnonymousSignInButtonMessages = { - buttonText: 'Continue as guest', - buttonPending: 'Starting session…', - successMessage: 'Guest session started.', - errors: { - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - ANONYMOUS_DISABLED: 'Guest access is not available.', - RATE_LIMITED: 'Too many requests. Please wait.', - UNKNOWN_ERROR: 'Failed to start guest session. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/api-key-create-dialog/api-key-create-dialog.tsx b/apps/blocks/src/blocks/auth/api-key-create-dialog/api-key-create-dialog.tsx deleted file mode 100644 index f5d13d6..0000000 --- a/apps/blocks/src/blocks/auth/api-key-create-dialog/api-key-create-dialog.tsx +++ /dev/null @@ -1,514 +0,0 @@ -'use client'; - -/** - * api-key-create-dialog (registry: auth-api-key-create-dialog) - * - * Modal dialog form for creating a new user-scoped API key. - * Enforces high-severity step-up (`tier: 'high'`) before calling - * `createApiKey`. On success, delivers the raw key + metadata to - * `onSuccess`; the parent (auth-account-api-keys-list) is responsible - * for opening auth-api-key-created-modal to show the one-time key value. - * - * Data path — GENERATED hook only: - * `useCreateApiKeyMutation` imported from `@/generated/auth`. - * No fetch, no GraphQL document string, no @constructive-io/data. - * No client bootstrap — blocks-runtime does all wiring. - * - * Hook signature (verified against reference SDK): - * variables: { input: { keyName?, accessLevel?, mfaLevel?, expiresIn?: IntervalInput } } - * payload wrapper: data.createApiKey → CreateApiKeyRecord { apiKey, keyId, expiresAt } - * i.e. result is nested under `result` field → data.createApiKey.result - * - * Step-up: `await stepUp({ tier: 'high' })` gates the mutation. If step-up is - * cancelled (`StepUpError.reason === 'cancelled'`) the dialog stays open without - * firing error callbacks (silent return per step-up-contract.md §3). - * - * (sdk-binding-contract.md §5–§7, block-contract.md §10) - */ - -import { useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter -} from '@constructive-io/ui/dialog'; -import { Button } from '@constructive-io/ui/button'; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem -} from '@constructive-io/ui/select'; - -import { cn } from '@/lib/utils'; -import { useCreateApiKeyMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; - -import { - defaultApiKeyCreateDialogMessages, - type ApiKeyCreateDialogMessages, - type ApiKeyCreateDialogMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/** Variables the create-api-key call receives. The override `onSubmit` gets these verbatim. */ -export type ApiKeyCreateInput = { - /** Key name. Non-empty, trimmed, max 100 chars. */ - name: string; - accessLevel: string; - mfaLevel: string; - /** - * Postgres interval string (e.g. "30 days") or null for no expiry. - * NOTE: The backend expects an `IntervalInput` object. This block converts - * the preset string values to the correct { days: N } shape internally. - * When onSubmit override is provided it receives the raw preset value. - */ - expiresIn: string | null; -}; - -export type ApiKeyCreatedResult = { - keyId: string; - rawKey: string; - name: string; - expiresAt: string | null; -}; - -export type AccessLevelOption = { value: string; label: string }; -export type MfaLevelOption = { value: string; label: string }; - -export type ApiKeyCreateDialogProps = { - /** Controlled open state. */ - open: boolean; - onOpenChange: (open: boolean) => void; - /** - * Available access levels. Default: ['read_only', 'full_access']. - * The deployed `create_api_key` proc ONLY accepts `read_only` | `full_access`. - */ - accessLevelOptions?: AccessLevelOption[]; - /** - * Available MFA levels. Default: ['none', 'verified']. - * The deployed `create_api_key` proc ONLY accepts `none` | `verified`. - */ - mfaLevelOptions?: MfaLevelOption[]; - messages?: ApiKeyCreateDialogMessageOverrides; - /** - * Replace the default `useCreateApiKeyMutation` call. - * Receives the raw form values after step-up succeeds. - */ - onSubmit?: (input: ApiKeyCreateInput) => Promise; - /** - * Fires on successful creation with the raw key and metadata. - * Parent should use this to open auth-api-key-created-modal. - * Always fires. - */ - onSuccess: (result: ApiKeyCreatedResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for step-up events and errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Interval conversion -// --------------------------------------------------------------------------- - -/** Convert preset expiry strings to IntervalInput for the backend. */ -function expiresInToInterval(value: string | null): { days: number } | undefined { - if (!value) return undefined; - const map: Record = { - '30 days': 30, - '90 days': 90, - '180 days': 180, - '365 days': 365 - }; - const days = map[value]; - if (days) return { days }; - return undefined; -} - -// --------------------------------------------------------------------------- -// Default option sets -// --------------------------------------------------------------------------- - -// The deployed `create_api_key` proc only accepts these enum values: -// accessLevel ∈ { read_only, full_access } mfaLevel ∈ { none, verified } -// Any other value (e.g. read/write/admin, required) -> INVALID_ACCESS_LEVEL. -const DEFAULT_ACCESS_LEVEL_OPTIONS: AccessLevelOption[] = [ - { value: 'read_only', label: 'Read only' }, - { value: 'full_access', label: 'Full access' } -]; - -const DEFAULT_MFA_LEVEL_OPTIONS: MfaLevelOption[] = [ - { value: 'none', label: 'None' }, - { value: 'verified', label: 'Verified' } -]; - -// --------------------------------------------------------------------------- -// Form data -// --------------------------------------------------------------------------- - -interface ApiKeyFormData { - name: string; - accessLevel: string; - mfaLevel: string; - expiresIn: string; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function ApiKeyCreateDialog({ - open, - onOpenChange, - accessLevelOptions = DEFAULT_ACCESS_LEVEL_OPTIONS, - mfaLevelOptions = DEFAULT_MFA_LEVEL_OPTIONS, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: ApiKeyCreateDialogProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: ApiKeyCreateDialogMessages = { - ...defaultApiKeyCreateDialogMessages, - ...messageOverrides, - expiresInOptions: { - ...defaultApiKeyCreateDialogMessages.expiresInOptions, - ...messageOverrides?.expiresInOptions - }, - errors: { - ...defaultApiKeyCreateDialogMessages.errors, - ...messageOverrides?.errors - } - }; - - // Generated hook from the host's `auth` SDK (sdk-binding-contract.md §5). - // Payload: data.createApiKey.result -> { apiKey, keyId, expiresAt } - const defaultMutation = useCreateApiKeyMutation({ - selection: { - fields: { - result: { - select: { - apiKey: true, - keyId: true, - expiresAt: true - } - } - } - } - }); - - const stepUp = useStepUp(); - - // Hybrid pending: generated hook tracks its own; override path uses local state. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - const [error, setError] = useState(null); - - async function runCreate(input: ApiKeyCreateInput): Promise { - if (onSubmitOverride) return onSubmitOverride(input); - // Build the interval input from the preset string. - const interval = expiresInToInterval(input.expiresIn); - const data = await defaultMutation.mutateAsync({ - input: { - keyName: input.name, - accessLevel: input.accessLevel, - mfaLevel: input.mfaLevel, - ...(interval ? { expiresIn: interval } : {}) - } - }); - const rec = data.createApiKey?.result; - if (!rec?.keyId || !rec?.apiKey) { - throw Object.assign(new Error('No key returned'), { extensions: { code: 'UNKNOWN_ERROR' } }); - } - return { - keyId: rec.keyId, - rawKey: rec.apiKey, - name: input.name, - expiresAt: rec.expiresAt ?? null - }; - } - - async function handleSubmit(values: ApiKeyFormData) { - setError(null); - - // Step 1: step-up BEFORE mutation (step-up-contract.md §5, tier: 'high'). - try { - await stepUp({ tier: 'high' }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - // Silent return — dialog stays open, no error fired (step-up-contract.md §3). - onMessage?.({ kind: 'info', key: 'stepUpCancelled', message: merged.stepUpCancelled }); - return; - } - // Step-up failed (non-cancel) — treat as error. - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - return; - } - - // Step 2: create the API key. - if (onSubmitOverride) setOverridePending(true); - try { - const input: ApiKeyCreateInput = { - name: values.name.trim(), - accessLevel: values.accessLevel, - mfaLevel: values.mfaLevel, - expiresIn: values.expiresIn === '__none__' ? null : values.expiresIn - }; - const result = await runCreate(input); - - // Do NOT auto-close here — parent closes and opens created-modal. - // Prevents a flash where both dialog and modal try to render simultaneously - // (spec §Notes). - onSuccess(result); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - const form = useForm({ - defaultValues: { - name: '', - accessLevel: accessLevelOptions[0]?.value ?? 'read_only', - mfaLevel: mfaLevelOptions[0]?.value ?? 'none', - expiresIn: '__none__' - } as ApiKeyFormData, - onSubmit: async ({ value }) => { - await handleSubmit(value); - } - }); - - function handleCancel() { - form.reset(); - setError(null); - onOpenChange(false); - } - - // Build expiry options from messages catalog. - const expiryOptions = [ - { value: '__none__', label: merged.expiresInOptions.noExpiry }, - { value: '30 days', label: merged.expiresInOptions.days30 }, - { value: '90 days', label: merged.expiresInOptions.days90 }, - { value: '180 days', label: merged.expiresInOptions.days180 }, - { value: '365 days', label: merged.expiresInOptions.days365 } - ]; - - return ( - - - - {merged.title} - {merged.description} - - - - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - {/* Key name */} - { - if (!value || !value.trim()) return 'Key name is required'; - if (value.trim().length > 100) return 'Key name must be 100 characters or fewer'; - return undefined; - } - }} - > - {(field) => ( - - )} - - - {/* Access level */} - (!value ? 'Access level is required' : undefined) - }} - > - {(field) => ( -
- - - {field.state.meta.errors.length > 0 && ( -

{field.state.meta.errors[0]}

- )} -
- )} -
- - {/* MFA level */} - (!value ? 'MFA level is required' : undefined) - }} - > - {(field) => ( -
- - - {field.state.meta.errors.length > 0 && ( -

{field.state.meta.errors[0]}

- )} -
- )} -
- - {/* Expiry */} - - {(field) => ( -
- - -
- )} -
- - - - - {merged.createButton} - - -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/api-key-create-dialog/auth-api-key-create-dialog.requires.json b/apps/blocks/src/blocks/auth/api-key-create-dialog/auth-api-key-create-dialog.requires.json deleted file mode 100644 index fb1e2af..0000000 --- a/apps/blocks/src/blocks/auth/api-key-create-dialog/auth-api-key-create-dialog.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["createApiKey"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/api-key-create-dialog/messages.ts b/apps/blocks/src/blocks/auth/api-key-create-dialog/messages.ts deleted file mode 100644 index c99290b..0000000 --- a/apps/blocks/src/blocks/auth/api-key-create-dialog/messages.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * api-key-create-dialog — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type ApiKeyCreateDialogMessages = { - title: string; - description: string; - nameLabel: string; - namePlaceholder: string; - accessLevelLabel: string; - mfaLevelLabel: string; - expiresInLabel: string; - expiresInOptions: { - noExpiry: string; - days30: string; - days90: string; - days180: string; - days365: string; - }; - createButton: string; - creatingButton: string; - cancelButton: string; - stepUpPrompt: string; - stepUpCancelled: string; - errors: { - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: top-level copy is shallow-partial; `errors` is - * itself partial so a host can localize a single error code without restating - * the full catalog. - */ -export type ApiKeyCreateDialogMessageOverrides = Partial> & { - errors?: Partial; - expiresInOptions?: Partial; -}; - -export const defaultApiKeyCreateDialogMessages: ApiKeyCreateDialogMessages = { - title: 'Create API key', - description: 'API keys provide programmatic access to your account.', - nameLabel: 'Key name', - namePlaceholder: 'e.g. CI deploy key', - accessLevelLabel: 'Access level', - mfaLevelLabel: 'MFA requirement', - expiresInLabel: 'Expiry', - expiresInOptions: { - noExpiry: 'No expiry', - days30: '30 days', - days90: '90 days', - days180: '180 days', - days365: '1 year' - }, - createButton: 'Create key', - creatingButton: 'Creating…', - cancelButton: 'Cancel', - stepUpPrompt: 'Confirm your identity before creating an API key.', - stepUpCancelled: 'Step-up verification cancelled.', - errors: { - UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/api-key-created-modal/api-key-created-modal.tsx b/apps/blocks/src/blocks/auth/api-key-created-modal/api-key-created-modal.tsx deleted file mode 100644 index 95bfbb5..0000000 --- a/apps/blocks/src/blocks/auth/api-key-created-modal/api-key-created-modal.tsx +++ /dev/null @@ -1,317 +0,0 @@ -'use client'; - -/** - * api-key-created-modal (registry: auth-api-key-created-modal) - * - * Presentational block — one-time display of a freshly-created raw API key - * (cnc_live_sk_...). The raw key is unrecoverable after this view; the DB - * stores only the SHA-256 hash. The modal enforces an explicit - * "I have saved this key" acknowledgement before allowing dismiss. - * - * NO data operation, NO generated hook, NO blocks-runtime dependency. - * The raw key is passed in as a prop by auth-account-api-keys-list after - * auth-api-key-create-dialog succeeds (spec §Pairing). - * - * Safety rails (Base UI Dialog API): - * • `disablePointerDismissal` on Dialog root: blocks overlay click when unacknowledged. - * • `onOpenChange` intercept: blocks Escape close (reason === 'escapeKey') when unacknowledged. - * • "Done" button uses aria-disabled (not native disabled) to remain in tab order. - * • Copy feedback reverts automatically after 2 s. - * - * (sdk-binding-contract.md §7 — presentational blocks ship no requires.json.) - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { TriangleAlertIcon, CopyIcon, CheckIcon } from 'lucide-react'; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; - -import { cn } from '@/lib/utils'; - -import { - defaultApiKeyCreatedModalMessages, - type ApiKeyCreatedModalMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export type ApiKeyCreatedModalMessageOverrides = Partial; - -export type ApiKeyCreatedModalProps = { - /** Whether the dialog is open. */ - open: boolean; - /** Called when the dialog requests a state change (open/close). */ - onOpenChange: (open: boolean) => void; - /** The raw API key (cnc_live_sk_...) — exists only in React state, never stored here. */ - apiKey: string; - /** Human-readable name of the key, shown in the modal title. */ - keyName: string; - /** Optional ISO-8601 expiry timestamp for display only. */ - expiresAt?: string | null; - /** BCP 47 locale used to format the expiry date. Default: en-US. */ - locale?: string; - /** IANA time-zone name used to format the expiry date. Default: UTC. */ - timeZone?: string; - /** Called when the user checks the acknowledgement and clicks "Done". */ - onDismissed?: () => void; - messages?: ApiKeyCreatedModalMessageOverrides; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function ApiKeyCreatedModal({ - open, - onOpenChange, - apiKey, - keyName, - expiresAt, - locale = 'en-US', - timeZone = 'UTC', - onDismissed, - messages: messageOverrides, - className -}: ApiKeyCreatedModalProps) { - // Deep merge (flat catalog — no nested errors map for this block). - const merged: ApiKeyCreatedModalMessages = { - ...defaultApiKeyCreatedModalMessages, - ...messageOverrides - }; - - const [hasCopied, setHasCopied] = useState(false); - const [copyError, setCopyError] = useState(null); - const [hasAcknowledged, setHasAcknowledged] = useState(false); - - // Use a ref so the onOpenChange interceptor always has the current value - // without a stale closure (avoids re-creating the handler on every state tick). - const acknowledgedRef = useRef(hasAcknowledged); - useEffect(() => { - acknowledgedRef.current = hasAcknowledged; - }, [hasAcknowledged]); - - // Reset local state when the modal opens. - useEffect(() => { - if (open) { - setHasCopied(false); - setCopyError(null); - setHasAcknowledged(false); - acknowledgedRef.current = false; - } - }, [open]); - - // Copy-button timeout ref for cleanup on unmount. - const copyTimeoutRef = useRef | null>(null); - useEffect(() => { - return () => { - if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); - }; - }, []); - - const copyErrorMessage = merged.copyErrorMessage; - const handleCopy = useCallback(async () => { - setCopyError(null); - try { - await navigator.clipboard.writeText(apiKey); - setHasCopied(true); - copyTimeoutRef.current = setTimeout(() => setHasCopied(false), 2000); - } catch { - setCopyError(copyErrorMessage); - } - }, [apiKey, copyErrorMessage]); - - // Base UI onOpenChange intercept — block Escape + any programmatic close - // when the user has not acknowledged they saved the key. - // The signature is (open: boolean, eventDetails: DialogRoot.ChangeEventDetails). - const handleOpenChange = useCallback( - (nextOpen: boolean, eventDetails?: { reason?: string }) => { - if (!nextOpen && !acknowledgedRef.current) { - // Block Escape key and outside-press dismissal; Done button is the only exit. - // Base UI REASONS constants use hyphenated strings: 'escape-key' and 'outside-press'. - if ( - eventDetails?.reason === 'escape-key' || - eventDetails?.reason === 'outside-press' - ) { - return; // Do not propagate — keep the dialog open. - } - } - onOpenChange(nextOpen); - }, - [onOpenChange] - ); - - function handleDone() { - if (!hasAcknowledged) return; - onDismissed?.(); - onOpenChange(false); - } - - // Format expiry for display. - const expiryDisplay = useMemo( - () => - expiresAt - ? new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - timeZone - }).format(new Date(expiresAt)) - : merged.noExpiry, - [expiresAt, locale, merged.noExpiry, timeZone] - ); - - return ( - void} - disablePointerDismissal={!hasAcknowledged} - > - - - {merged.title} - - One-time display of your new API key for {keyName}. Save it before closing. - - - -
- {/* Warning banner */} -
-
- - {/* Key display area */} -
-

{merged.keyLabel}

-
- - {apiKey} - -
- -
-
- {copyError && ( -

- {copyError} -

- )} -
- - {/* Expiry line */} -
- {merged.expiresLabel}: - {expiresAt ? ( - - {expiryDisplay} - - ) : ( - - {expiryDisplay} - - )} -
- - {/* Separator */} -
- - {/* Acknowledgement checkbox */} -
- setHasAcknowledged(e.target.checked)} - aria-required="true" - className="mt-0.5 size-4 accent-primary cursor-pointer" - data-testid="acknowledge-checkbox" - /> - -
- - {/* Dismiss button */} - -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/api-key-created-modal/messages.ts b/apps/blocks/src/blocks/auth/api-key-created-modal/messages.ts deleted file mode 100644 index 1b523b8..0000000 --- a/apps/blocks/src/blocks/auth/api-key-created-modal/messages.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * api-key-created-modal — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy. No `errors` map — this is a presentational block - * with no data operations and no backend error codes to surface. - */ - -export type ApiKeyCreatedModalMessages = { - title: string; - warningHeading: string; - warningBody: string; - keyLabel: string; - expiresLabel: string; - noExpiry: string; - copyButton: string; - copiedButton: string; - copyErrorMessage: string; - acknowledgementLabel: string; - dismissButton: string; -}; - -export const defaultApiKeyCreatedModalMessages: ApiKeyCreatedModalMessages = { - title: 'API key created', - warningHeading: 'Save this key now', - warningBody: - 'This is the only time you will see this key. It cannot be recovered once you close this window.', - keyLabel: 'Your new API key', - expiresLabel: 'Expires', - noExpiry: 'Never', - copyButton: 'Copy', - copiedButton: 'Copied!', - copyErrorMessage: 'Could not copy to clipboard. Please select and copy the key manually.', - acknowledgementLabel: 'I have copied and saved this API key in a secure location.', - dismissButton: 'Done' -}; diff --git a/apps/blocks/src/blocks/auth/change-password-form/auth-change-password-form.requires.json b/apps/blocks/src/blocks/auth/change-password-form/auth-change-password-form.requires.json deleted file mode 100644 index 9b71169..0000000 --- a/apps/blocks/src/blocks/auth/change-password-form/auth-change-password-form.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["setPassword"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/change-password-form/change-password-form.tsx b/apps/blocks/src/blocks/auth/change-password-form/change-password-form.tsx deleted file mode 100644 index 358c58a..0000000 --- a/apps/blocks/src/blocks/auth/change-password-form/change-password-form.tsx +++ /dev/null @@ -1,320 +0,0 @@ -'use client'; - -/** - * change-password-form (registry: auth-change-password-form) - * - * Inline form for authenticated users to update their password. Fields: current - * password + new password + confirm new password. Before submitting, gates behind - * `await stepUp({ tier: 'medium' })` (password re-verification). Provides inline - * strength feedback for the new password via the `password-strength` foundation lib. - * - * Binding doctrine (sdk-binding-contract.md §3, MASTER-PROMPT §5): - * • Data path = `useSetPasswordMutation` from `@/generated/auth` with a - * `selection` field-picker. No fetch, no GraphQL document, no hardcoded URL. - * • NO client bootstrap: never calls `configure()`/`getClient()`, never mounts - * ``. The host's `@constructive/blocks-runtime` does that. - * • Override seam: `onSubmit` fully replaces the generated-hook call. - * • Error mapping via `parseGraphQLError` from the `auth-errors` foundation lib. - */ - -import { useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { cn } from '@/lib/utils'; -import { useSetPasswordMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { estimatePasswordStrength } from '@/blocks/lib/password-strength'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; -import { Progress } from '@constructive-io/ui/progress'; - -import { defaultChangePasswordFormMessages, type ChangePasswordFormMessageOverrides, type ChangePasswordFormMessages } from './messages'; - -/** Input variables the change-password call receives. The override `onSubmit` gets these. */ -export type ChangePasswordInput = { - currentPassword: string; - newPassword: string; -}; - -export type ChangePasswordResult = { - success: boolean; -}; - -export type ChangePasswordFormProps = { - /** Show new password strength meter (default: true). */ - showPasswordStrength?: boolean; - /** - * Whether to check step-up before submission. - * Default: true. Set to false to skip when sign-in already verified recently. - */ - requireStepUp?: boolean; - messages?: ChangePasswordFormMessageOverrides; - /** Replace the default `useSetPasswordMutation` call. Receives the same vars. */ - onSubmit?: (input: ChangePasswordInput) => Promise; - /** Fires after a successful password update. Always fires. */ - onSuccess?: (result: ChangePasswordResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -type FormData = { - currentPassword: string; - newPassword: string; - confirmPassword: string; -}; - -function strengthLabel(merged: ChangePasswordFormMessages, label: 'weak' | 'fair' | 'good' | 'strong'): string { - const map = { - weak: merged.passwordStrengthWeak, - fair: merged.passwordStrengthFair, - good: merged.passwordStrengthGood, - strong: merged.passwordStrengthStrong - }; - return map[label]; -} - -function strengthColorClass(label: 'weak' | 'fair' | 'good' | 'strong'): string { - const map = { - weak: '[&_[data-slot=progress-indicator]]:bg-destructive', - fair: '[&_[data-slot=progress-indicator]]:bg-yellow-500', - good: '[&_[data-slot=progress-indicator]]:bg-blue-500', - strong: '[&_[data-slot=progress-indicator]]:bg-green-500' - }; - return map[label]; -} - -export function ChangePasswordForm({ - showPasswordStrength = true, - requireStepUp: requireStepUpProp = true, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: ChangePasswordFormProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: ChangePasswordFormMessages = { - ...defaultChangePasswordFormMessages, - ...messageOverrides, - errors: { ...defaultChangePasswordFormMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook from the host's `auth` SDK. The payload wraps the result - // under `setPassword.result` (boolean). Verified against generated types. - const defaultMutation = useSetPasswordMutation({ - selection: { - fields: { - result: true - } - } - }); - - // Step-up hook — imperative promise-based API (step-up-contract.md §3). - const stepUp = useStepUp(); - - // Hybrid pending: the generated hook tracks its own; the override path does not. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - const [error, setError] = useState(null); - const [newPasswordValue, setNewPasswordValue] = useState(''); - - async function handleChangePassword(values: FormData): Promise { - setError(null); - - // Confirm password mismatch guard. - if (values.newPassword !== values.confirmPassword) { - setError(merged.passwordMismatch); - return; - } - - // Step-up gate (tier: 'medium' → password re-verification per step-up-contract §6). - if (requireStepUpProp) { - try { - await stepUp({ tier: 'medium' }); - } catch (err) { - if (err instanceof StepUpError && err.reason === 'cancelled') { - // User cancelled the dialog — surface as STEP_UP_CANCELLED. - const message = merged.errors.STEP_UP_CANCELLED; - setError(message); - onMessage?.({ kind: 'error', key: 'STEP_UP_CANCELLED', message }); - onError?.({ message, code: 'STEP_UP_CANCELLED' }); - return; - } - // Other step-up failure. - const { code, message } = parseGraphQLError(err, { customMessages: merged.errors, defaultMessage: merged.errors.UNKNOWN_ERROR }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - return; - } - } - - if (onSubmitOverride) setOverridePending(true); - try { - const input: ChangePasswordInput = { - currentPassword: values.currentPassword, - newPassword: values.newPassword - }; - - let success: boolean; - if (onSubmitOverride) { - success = await onSubmitOverride(input); - } else { - const data = await defaultMutation.mutateAsync({ input: { currentPassword: values.currentPassword, newPassword: values.newPassword } }); - success = data.setPassword?.result ?? false; - } - - if (!success) { - // A resolved mutation returning false means current password was wrong. - const message = merged.errors.INVALID_CREDENTIALS; - setError(message); - onMessage?.({ kind: 'error', key: 'INVALID_CREDENTIALS', message }); - onError?.({ message, code: 'INVALID_CREDENTIALS' }); - return; - } - - onMessage?.({ kind: 'success', key: 'changePassword.success', message: merged.successMessage }); - onSuccess?.({ success: true }); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - const form = useForm({ - defaultValues: { - currentPassword: '', - newPassword: '', - confirmPassword: '' - } as FormData, - onSubmit: async ({ value }) => { - await handleChangePassword(value); - } - }); - - const strength = showPasswordStrength && newPasswordValue ? estimatePasswordStrength(newPasswordValue) : null; - const strengthPct = strength ? (strength.score / 4) * 100 : 0; - - return ( -
-

{merged.title}

- - - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - (!value ? 'Current password is required' : undefined) - }} - > - {(field) => ( - - )} - - - { - setNewPasswordValue(value ?? ''); - if (!value) return 'New password is required'; - if (value.length < 8) return 'Password must be at least 8 characters'; - if (value.length > 63) return 'Password must be at most 63 characters'; - return undefined; - } - }} - > - {(field) => ( -
- - {showPasswordStrength && newPasswordValue && strength && ( -
- -

{strengthLabel(merged, strength.label)}

-
- )} -
- )} -
- - (!value ? 'Please confirm your new password' : undefined) - }} - > - {(field) => ( - - )} - - -
- - {merged.submitButton} - -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/change-password-form/messages.ts b/apps/blocks/src/blocks/auth/change-password-form/messages.ts deleted file mode 100644 index 9b20fce..0000000 --- a/apps/blocks/src/blocks/auth/change-password-form/messages.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * change-password-form — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type ChangePasswordFormMessages = { - title: string; - currentPasswordLabel: string; - currentPasswordPlaceholder: string; - newPasswordLabel: string; - newPasswordPlaceholder: string; - confirmPasswordLabel: string; - confirmPasswordPlaceholder: string; - submitButton: string; - submitButtonPending: string; - passwordMismatch: string; - passwordStrengthWeak: string; - passwordStrengthFair: string; - passwordStrengthGood: string; - passwordStrengthStrong: string; - successMessage: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - INVALID_CREDENTIALS: string; - INCORRECT_PASSWORD: string; - WEAK_PASSWORD: string; - STEP_UP_REQUIRED: string; - STEP_UP_CANCELLED: string; - UNKNOWN_ERROR: string; - }; -}; - -export type ChangePasswordFormMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultChangePasswordFormMessages: ChangePasswordFormMessages = { - title: 'Change password', - currentPasswordLabel: 'Current password', - currentPasswordPlaceholder: '••••••••', - newPasswordLabel: 'New password', - newPasswordPlaceholder: '••••••••', - confirmPasswordLabel: 'Confirm new password', - confirmPasswordPlaceholder: '••••••••', - submitButton: 'Update password', - submitButtonPending: 'Updating…', - passwordMismatch: 'Passwords do not match.', - passwordStrengthWeak: 'Weak', - passwordStrengthFair: 'Fair', - passwordStrengthGood: 'Good', - passwordStrengthStrong: 'Strong', - successMessage: 'Password updated successfully.', - errors: { - INVALID_CREDENTIALS: 'Current password is incorrect.', - INCORRECT_PASSWORD: 'Current password is incorrect.', - WEAK_PASSWORD: 'New password does not meet minimum requirements.', - STEP_UP_REQUIRED: 'Please verify your identity to continue.', - STEP_UP_CANCELLED: 'Identity verification was cancelled.', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/cross-origin-link/auth-cross-origin-link.requires.json b/apps/blocks/src/blocks/auth/cross-origin-link/auth-cross-origin-link.requires.json deleted file mode 100644 index 76b4641..0000000 --- a/apps/blocks/src/blocks/auth/cross-origin-link/auth-cross-origin-link.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["requestCrossOriginToken"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/cross-origin-link/cross-origin-link.tsx b/apps/blocks/src/blocks/auth/cross-origin-link/cross-origin-link.tsx deleted file mode 100644 index 43636f3..0000000 --- a/apps/blocks/src/blocks/auth/cross-origin-link/cross-origin-link.tsx +++ /dev/null @@ -1,207 +0,0 @@ -'use client'; - -/** - * cross-origin-link (registry: auth-cross-origin-link) - * - * Generates a one-time cross-origin authentication token by calling - * `constructive_auth_public.request_cross_origin_token(...)`, then navigates - * to `${destinationOrigin}${destinationPath}?token=` via - * `window.location.href` (intentional cross-origin redirect). - * - * Binding doctrine (sdk-binding-contract.md §5): - * • Data path = `useRequestCrossOriginTokenMutation` from `@/generated/auth`. - * No fetch, no GraphQL document string, no `@constructive-io/data` import. - * • No client bootstrap: never calls `configure()`/`getClient()`, never mounts - * a `QueryClientProvider`. The host mounts `blocks-runtime` once at app root. - * • Override seam: `onSubmit` fully replaces the generated-hook call. - * • Error mapping via `auth-errors` foundation lib; inline alert via - * `auth-error-alert` primitive. - * - * The block does NOT ship a form with email/password inputs — it receives them - * as props from a parent form context where the user already typed credentials. - */ - -import { useState } from 'react'; - -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { useRequestCrossOriginTokenMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; - -import { defaultCrossOriginLinkMessages, type CrossOriginLinkMessages } from './messages'; - -/** Variables sent to the cross-origin token mutation. */ -export type CrossOriginLinkInput = { - email: string; - password: string; - origin: string; - rememberMe: boolean; -}; - -/** - * Deep-partial message overrides: top-level keys are shallow-partial; - * `errors` is itself partial so a host can override a single error code. - */ -export type CrossOriginLinkMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type CrossOriginLinkProps = { - /** Email for credential verification (passed from the parent form). */ - email: string; - /** Password for credential verification (passed from the parent form). */ - password: string; - /** Target origin, e.g. 'https://app.example.com'. Must be allowlisted server-side. */ - destinationOrigin: string; - /** - * Path on the destination to redirect to after token exchange. - * The token is appended as ?token=. Default: '/auth/cross-origin'. - */ - destinationPath?: string; - rememberMe?: boolean; - /** Render as a button (default) or an anchor link. */ - renderAs?: 'button' | 'link'; - /** Content rendered inside the button/link. Falls back to messages.defaultButtonText. */ - children?: React.ReactNode; - /** Visual variant passed through to the underlying Button. Default: 'default'. */ - variant?: 'default' | 'outline' | 'ghost' | 'link'; - messages?: CrossOriginLinkMessageOverrides; - /** Replace the default `useRequestCrossOriginTokenMutation` call. Must return the token string. */ - onSubmit?: (input: CrossOriginLinkInput) => Promise; - /** Fires after token generation, before redirect. Always fires on success. */ - onSuccess?: (token: string, url: string) => void; - /** Fires after a mapped error. Always fires on error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and all errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -export function CrossOriginLink({ - email, - password, - destinationOrigin, - destinationPath = '/auth/cross-origin', - rememberMe = false, - renderAs = 'button', - children, - variant = 'default', - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: CrossOriginLinkProps) { - // Deep merge: top-level keys + the errors map merged separately. - const merged: CrossOriginLinkMessages = { - ...defaultCrossOriginLinkMessages, - ...messageOverrides, - errors: { ...defaultCrossOriginLinkMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hook from the host's `auth` SDK (sdk-binding-contract.md §5). - // `RequestCrossOriginTokenPayload.result` is a plain string (the token), - // so the selection uses `{ result: true }` — a scalar boolean selector. - const defaultMutation = useRequestCrossOriginTokenMutation({ - selection: { - fields: { - result: true - } - } - }); - - // Hybrid pending: the generated hook tracks its own; the override path does not. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - const [error, setError] = useState(null); - - async function handleClick() { - setError(null); - - const vars: CrossOriginLinkInput = { - email, - password, - origin: destinationOrigin, - rememberMe - }; - - if (onSubmitOverride) setOverridePending(true); - try { - let token: string; - - if (onSubmitOverride) { - token = await onSubmitOverride(vars); - } else { - // The generated hook takes `{ input }` and returns - // `{ requestCrossOriginToken: { result: string | null } | null }`. - const data = await defaultMutation.mutateAsync({ input: vars }); - const result = data.requestCrossOriginToken?.result ?? null; - if (!result) { - // A resolved mutation with no token is treated as a credential failure. - throw Object.assign(new Error('Invalid email or password.'), { - extensions: { code: 'INVALID_CREDENTIALS' } - }); - } - token = result; - } - - const url = `${destinationOrigin}${destinationPath}?token=${encodeURIComponent(token)}`; - - onMessage?.({ kind: 'success', key: 'crossOriginLink.success', message: merged.successMessage }); - onSuccess?.(token, url); - - // Cross-origin navigation — cannot use router.push (block-contract.md §6). - window.location.href = url; - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - const label = children ?? merged.defaultButtonText; - const loadingLabel = merged.pendingText; - - return ( -
- - - {renderAs === 'link' ? ( - - ) : ( - - {label} - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/cross-origin-link/messages.ts b/apps/blocks/src/blocks/auth/cross-origin-link/messages.ts deleted file mode 100644 index 8dab115..0000000 --- a/apps/blocks/src/blocks/auth/cross-origin-link/messages.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * cross-origin-link — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type CrossOriginLinkMessages = { - defaultButtonText: string; - pendingText: string; - /** Shown when token is generated (before navigation) */ - successMessage: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - INVALID_CREDENTIALS: string; - CROSS_ORIGIN_DISABLED: string; - RATE_LIMITED: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultCrossOriginLinkMessages: CrossOriginLinkMessages = { - defaultButtonText: 'Continue to app', - pendingText: 'Connecting…', - successMessage: 'Redirecting to app…', - errors: { - INVALID_CREDENTIALS: 'Invalid email or password.', - CROSS_ORIGIN_DISABLED: 'Cross-origin authentication is not enabled.', - RATE_LIMITED: 'Too many attempts. Please wait.', - UNKNOWN_ERROR: 'Failed to generate link. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/domain-verification-step/domain-verification-step.tsx b/apps/blocks/src/blocks/auth/domain-verification-step/domain-verification-step.tsx deleted file mode 100644 index 5599ebe..0000000 --- a/apps/blocks/src/blocks/auth/domain-verification-step/domain-verification-step.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client'; - -/** - * domain-verification-step (registry: auth-domain-verification-step) - * - * v2 STUB — Phase 3 (deferred SSO backend). - * - * Displays the DNS TXT record an admin must add to prove domain ownership for - * an SSO provider configuration. In production it will poll the server until - * the record is detected or a timeout is reached. The stub renders the static - * UI skeleton and surfaces a clear "deferred" notice so operators understand - * what must be deployed before the block becomes functional. - * - * No generated hook, no @/generated import, no requires.json, no blocks-runtime - * dependency — this block is purely presentational at this stage. - * - * When the SSO backend ships (`constructive_auth_public.get_domain_verification_record` - * + `check_domain_verification`), replace the stub states with live hook calls - * imported from `@/generated/auth` and add `auth-domain-verification-step.requires.json`. - * - * Spec: planning/blocks/auth/auth-domain-verification-step.md - * SDK prerequisite (future): constructive_auth_public.get_domain_verification_record + - * check_domain_verification (backend-spec/v2-sso-scim.md) - */ - -import { useState } from 'react'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Alert, AlertDescription } from '@constructive-io/ui/alert'; - -import { cn } from '@/lib/utils'; - -import { - defaultAuthDomainVerificationStepMessages, - type AuthDomainVerificationStepMessages -} from './messages'; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** Verification state machine — mirrors what the live poll implementation will use. */ -export type DomainVerificationStatus = 'waiting' | 'verified' | 'timeout' | 'error'; - -export type AuthDomainVerificationStepMessageOverrides = Partial; - -export type AuthDomainVerificationStepProps = { - /** The SSO provider UUID this domain is being claimed for (required). */ - ssoProviderId: string; - /** The domain being verified (e.g. "acme.com"). */ - domain: string; - /** Polling interval in ms (default 5000 — unused in stub). */ - pollIntervalMs?: number; - /** Max poll duration in ms (default 300_000 — unused in stub). */ - pollTimeoutMs?: number; - messages?: AuthDomainVerificationStepMessageOverrides; - /** Fires when the domain is verified. */ - onVerified?: (ssoProviderId: string) => void; - /** Fires when polling exceeds `pollTimeoutMs`. */ - onTimeout?: () => void; - /** Fires after an error during the verification check. */ - onError?: (err: { message: string; code: string }) => void; - /** Fires to surface a notification to the host application. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// ─── Component ─────────────────────────────────────────────────────────────── - -/** - * AuthDomainVerificationStep - * - * v2 stub — renders the DNS TXT-record verification UI skeleton with a - * "backend deferred" notice. Drop the notice and wire the two generated hooks - * once the SSO procedures ship. - */ -export function AuthDomainVerificationStep({ - ssoProviderId, - domain, - messages: messageOverrides, - onVerified: _onVerified, - onTimeout: _onTimeout, - onError: _onError, - onMessage: _onMessage, - className -}: AuthDomainVerificationStepProps) { - // Deep-merge messages (same pattern as sign-in-card). - const merged: AuthDomainVerificationStepMessages = { - ...defaultAuthDomainVerificationStepMessages, - ...messageOverrides - }; - - // In the live implementation these will be driven by the poll hook. - // The stub always starts in 'waiting' so the skeleton is visible. - const [status] = useState('waiting'); - const [copiedField, setCopiedField] = useState<'name' | 'value' | null>(null); - - // Stable placeholder TXT values — replaced by the real hook response in v2. - const txtRecordName = `_constructive-verify.${domain}`; - const txtRecordValue = `constructive-domain-verification=${ssoProviderId}`; - - function handleCopy(field: 'name' | 'value', value: string) { - navigator.clipboard.writeText(value).then(() => { - setCopiedField(field); - setTimeout(() => setCopiedField(null), 2000); - }); - } - - // The "Check now" button will invoke the check_domain_verification hook in v2. - // In the stub it does nothing (the button is present for layout fidelity). - function handleCheckNow() { - // TODO (v2): call `useCheckDomainVerificationMutation` from `@/generated/auth` - } - - return ( - - -
- {merged.title} - -
- {merged.description} -
- - - {/* Deferred-backend notice — remove once procedures ship */} - - - {merged.deferredNotice} - - - - {/* TXT record name */} - handleCopy('name', txtRecordName)} - /> - - {/* TXT record value */} - handleCopy('value', txtRecordValue)} - /> - - {/* Propagation note */} -

{merged.propagationNote}

- - {/* Manual check trigger */} - -
-
- ); -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function StatusBadge({ - status, - merged -}: { - status: DomainVerificationStatus; - merged: AuthDomainVerificationStepMessages; -}) { - if (status === 'verified') return {merged.statusVerified}; - if (status === 'timeout') return {merged.statusTimeout}; - if (status === 'error') return {merged.statusError}; - return ( - - {merged.statusWaiting} - - ); -} - -function TxtRecordField({ - label, - value, - copyLabel, - onCopy -}: { - label: string; - value: string; - copyLabel: string; - onCopy: () => void; -}) { - return ( -
-

{label}

-
- {value} - -
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/domain-verification-step/messages.ts b/apps/blocks/src/blocks/auth/domain-verification-step/messages.ts deleted file mode 100644 index d9edbe0..0000000 --- a/apps/blocks/src/blocks/auth/domain-verification-step/messages.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * domain-verification-step — message catalog - * - * v2 STUB — no data binding yet (sdk-binding-contract.md: deferred SSO backend). - * Top-level camelCase keys are UI copy. There are no backend error codes in v1 - * because the block performs no network operations. - */ - -export type AuthDomainVerificationStepMessages = { - title: string; - description: string; - txtRecordNameLabel: string; - txtRecordValueLabel: string; - copyLabel: string; - copiedLabel: string; - checkNowLabel: string; - statusWaiting: string; - statusVerified: string; - statusTimeout: string; - statusError: string; - deferredNotice: string; - propagationNote: string; -}; - -export const defaultAuthDomainVerificationStepMessages: AuthDomainVerificationStepMessages = { - title: 'Verify domain ownership', - description: - 'Add the DNS TXT record below to your domain to prove ownership. DNS changes can take up to 48 hours to propagate.', - txtRecordNameLabel: 'TXT record name', - txtRecordValueLabel: 'TXT record value', - copyLabel: 'Copy', - copiedLabel: 'Copied!', - checkNowLabel: 'Check now', - statusWaiting: 'Waiting for DNS propagation…', - statusVerified: 'Domain verified.', - statusTimeout: 'Verification timed out. Check that the TXT record was added correctly.', - statusError: 'Verification error. Please try again.', - deferredNotice: 'Domain verification requires a server-side DNS backend that has not been deployed yet.', - propagationNote: 'DNS propagation can take up to 48 hours. The check button lets you verify manually at any time.' -}; diff --git a/apps/blocks/src/blocks/auth/email-otp-input/auth-email-otp-input.requires.json b/apps/blocks/src/blocks/auth/email-otp-input/auth-email-otp-input.requires.json deleted file mode 100644 index f488b86..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-input/auth-email-otp-input.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["signInEmailOtp", "sendEmailOtp"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/email-otp-input/email-otp-input.tsx b/apps/blocks/src/blocks/auth/email-otp-input/email-otp-input.tsx deleted file mode 100644 index 7ffb977..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-input/email-otp-input.tsx +++ /dev/null @@ -1,466 +0,0 @@ -'use client'; - -/** - * email-otp-input (registry: auth-email-otp-input) - * - * Reusable 6-segment OTP code input with countdown timer, resend CTA, and - * attempt feedback. Designed to be rendered inline by [[auth-email-otp-request-card]] - * or as a standalone block. - * - * BACKEND-PENDING — CASE (b): - * `sign_in_email_otp` and `send_email_otp` are not yet deployed in - * `constructive_auth_public`. `useSignInEmailOtpMutation` and - * `useSendEmailOtpMutation` do NOT exist in the reference SDK (confirmed in - * apps/admin/src/graphql/auth-sdk/api/hooks/mutations/). This block therefore: - * - * • Does NOT import from `@/generated/auth` (no hooks to import — tsc would fail). - * • Makes `onVerify` the primary/recommended network path: the host wires the - * generated binding after running `cnc codegen --api-names auth ...`. - * • `onResend` is similarly the primary resend path until `send_email_otp` ships. - * • Stub default paths throw typed PROCEDURE_NOT_FOUND errors so the block - * behaves gracefully (shows the error message) if mounted without an override. - * • `requires.json` names the pending ops so `check-sdk-fixtures.ts` fails clearly. - * • `PROCEDURE_NOT_FOUND` is in `messages.errors`. - * - * When the backend ships and the host regenerates the SDK, replace the stubs with: - * import { useSignInEmailOtpMutation, useSendEmailOtpMutation } from '@/generated/auth'; - * const defaultMutation = useSignInEmailOtpMutation({ - * selection: { - * fields: { - * id: true, userId: true, accessToken: true, accessTokenExpiresAt: true, - * isVerified: true, mfaRequired: true, mfaChallengeToken: true, - * }, - * }, - * }); - * const sendMutation = useSendEmailOtpMutation({ selection: {} }); - * // Submit: const result = await defaultMutation.mutateAsync({ email, code }).then((d) => d.signInEmailOtp); - * // Resend: await sendMutation.mutateAsync({ email, type: 'sign_in' }); - * and add the hybrid-isPending pattern (sdk-binding-contract.md §5). - * - * Pairing: No page block — used as an inline code-entry step rendered by - * [[auth-email-otp-request-card]] or embedded in a custom page. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; - -import { defaultEmailOtpInputMessages, type EmailOtpInputMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const DEFAULT_LENGTH = 6; -const DEFAULT_RESEND_COOLDOWN_SECONDS = 60; - -// --------------------------------------------------------------------------- -// Simple {{key}} mustache interpolation (no dep needed) -// --------------------------------------------------------------------------- - -function interpolate(template: string, vars: Record): string { - return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] ?? '')); -} - -// --------------------------------------------------------------------------- -// Backend-pending stubs -// Throw PROCEDURE_NOT_FOUND so the error message surfaces in the UI. -// Replace these with the generated hooks once the procedures ship. -// --------------------------------------------------------------------------- - -class ProcedureNotFoundError extends Error { - public readonly extensions = { code: 'PROCEDURE_NOT_FOUND' }; - constructor() { - super('PROCEDURE_NOT_FOUND'); - this.name = 'ProcedureNotFoundError'; - } -} - -async function stubVerify(_email: string, _code: string): Promise { - throw new ProcedureNotFoundError(); -} - -async function stubResend(_email: string): Promise { - throw new ProcedureNotFoundError(); -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * Result of an OTP verification. Mirrors the `signInEmailOtp` payload shape - * (the fields this block selects); declared here so the public surface and - * the `onVerify` override do not depend on a generated type name. - */ -export type EmailOtpVerifyResult = { - id?: string | null; - userId?: string | null; - accessToken?: string | null; - accessTokenExpiresAt?: string | null; - isVerified?: boolean | null; - mfaRequired?: boolean | null; - mfaChallengeToken?: string | null; - /** For non-sign-in flows: simple success boolean. */ - success?: boolean; -}; - -/** - * Message overrides. Top-level copy is shallow-partial; `errors` is itself - * partial so a host can localize a single error code without restating the map. - */ -export type EmailOtpInputMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type EmailOtpInputProps = { - /** Email the OTP was sent to (required for the default sign-in hook). */ - email: string; - /** Number of OTP segments. Default: 6 */ - length?: number; - /** Countdown timer duration in seconds before resend is enabled. Default: 60 */ - resendCooldownSeconds?: number; - messages?: EmailOtpInputMessageOverrides; - /** - * Custom verify function. Required until `sign_in_email_otp` ships. - * After codegen, the host wires in `useSignInEmailOtpMutation`. - * Use for non-sign-in OTP types (verify, reset, change-email). - */ - onVerify?: (email: string, code: string) => Promise; - /** - * Custom resend function. Required until `send_email_otp` ships. - * After codegen, the host wires in `useSendEmailOtpMutation`. - */ - onResend?: (email: string) => Promise; - /** Fires after a successful verification. Always fires. */ - onSuccess?: (result: EmailOtpVerifyResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for all events. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function EmailOtpInput({ - email, - length = DEFAULT_LENGTH, - resendCooldownSeconds = DEFAULT_RESEND_COOLDOWN_SECONDS, - messages: messageOverrides, - onVerify: onVerifyOverride, - onResend: onResendOverride, - onSuccess, - onError, - onMessage, - className -}: EmailOtpInputProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: EmailOtpInputMessages = { - ...defaultEmailOtpInputMessages, - ...messageOverrides, - errors: { ...defaultEmailOtpInputMessages.errors, ...messageOverrides?.errors } - }; - - // OTP digits state — array of `length` single-character strings. - const [digits, setDigits] = useState(() => Array(length).fill('')); - const inputRefs = useRef>(Array(length).fill(null)); - - // Pending / error state. - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(null); - - // Resend state. - const [isResendPending, setIsResendPending] = useState(false); - const [resendSuccess, setResendSuccess] = useState(false); - - // Countdown timer. - const [cooldownRemaining, setCooldownRemaining] = useState(0); - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - const verifyFn = onVerifyOverride ?? stubVerify; - const resendFn = onResendOverride ?? stubResend; - - const startCooldown = useCallback(() => { - setCooldownRemaining(resendCooldownSeconds); - }, [resendCooldownSeconds]); - - useEffect(() => { - if (cooldownRemaining <= 0) return; - const timeout = setTimeout(() => { - setCooldownRemaining((seconds) => Math.max(0, seconds - 1)); - }, 1000); - return () => clearTimeout(timeout); - }, [cooldownRemaining]); - - // Auto-focus the first input on mount. - useEffect(() => { - inputRefs.current[0]?.focus(); - }, []); - - // --------------------------------------------------------------------------- - // Submit handler - // --------------------------------------------------------------------------- - - const handleVerify = useCallback( - async (code: string) => { - if (isPending) return; - setError(null); - setIsPending(true); - try { - const result = await verifyFn(email, code); - - if (result.mfaRequired) { - onMessage?.({ kind: 'warning', key: 'mfaRequired' }); - } else { - onMessage?.({ kind: 'success', key: 'signInEmailOtp.success' }); - } - onSuccess?.(result); - } catch (err) { - const { code: errCode, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = errCode ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setIsPending(false); - } - }, - [isPending, verifyFn, email, merged.errors, onMessage, onSuccess, onError] - ); - - // --------------------------------------------------------------------------- - // Digit input handling - // --------------------------------------------------------------------------- - - function updateDigit(index: number, value: string) { - const next = [...digits]; - next[index] = value; - setDigits(next); - return next; - } - - function focusNext(currentIndex: number) { - if (currentIndex < length - 1) { - inputRefs.current[currentIndex + 1]?.focus(); - } - } - - function focusPrev(currentIndex: number) { - if (currentIndex > 0) { - inputRefs.current[currentIndex - 1]?.focus(); - } - } - - function handleDigitChange(index: number, value: string) { - // Strip non-digits. - const digit = value.replace(/\D/g, '').slice(-1); - const nextDigits = updateDigit(index, digit); - - if (digit) { - focusNext(index); - // Auto-submit when all segments are filled. - if (nextDigits.every((d) => d !== '')) { - const code = nextDigits.join(''); - handleVerify(code); - } - } - } - - function handleKeyDown(index: number, e: React.KeyboardEvent) { - if (e.key === 'Backspace') { - if (digits[index]) { - updateDigit(index, ''); - } else { - focusPrev(index); - } - } else if (e.key === 'ArrowLeft') { - e.preventDefault(); - focusPrev(index); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - focusNext(index); - } - } - - function handlePaste(e: React.ClipboardEvent) { - e.preventDefault(); - const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length); - if (!pasted) return; - - const nextDigits = Array(length) - .fill('') - .map((_, i) => pasted[i] ?? ''); - setDigits(nextDigits); - - // Focus the next empty slot or the last filled one. - const filledCount = pasted.length; - const focusIndex = Math.min(filledCount, length - 1); - inputRefs.current[focusIndex]?.focus(); - - // Auto-submit if we pasted a full code. - if (filledCount >= length) { - handleVerify(pasted.slice(0, length)); - } - } - - // --------------------------------------------------------------------------- - // Form submit (manual — fallback for accessibility) - // --------------------------------------------------------------------------- - - function handleFormSubmit(e: React.FormEvent) { - e.preventDefault(); - const code = digits.join(''); - if (code.length < length) return; - handleVerify(code); - } - - // --------------------------------------------------------------------------- - // Resend handler - // --------------------------------------------------------------------------- - - async function handleResend() { - if (isResendPending || cooldownRemaining > 0) return; - setError(null); - setResendSuccess(false); - setIsResendPending(true); - try { - await resendFn(email); - setResendSuccess(true); - setDigits(Array(length).fill('')); - inputRefs.current[0]?.focus(); - startCooldown(); - onMessage?.({ kind: 'info', key: 'sendEmailOtp.success', message: merged.resendSuccess }); - } catch (err) { - const { code: errCode, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = errCode ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setIsResendPending(false); - } - } - - // --------------------------------------------------------------------------- - // Resend button label - // --------------------------------------------------------------------------- - - function resendButtonLabel(): string { - if (isResendPending) return merged.resendPending; - if (cooldownRemaining > 0) return interpolate(merged.resendCooldown, { seconds: cooldownRemaining }); - return merged.resendButton; - } - - const isResendDisabled = isResendPending || cooldownRemaining > 0; - const isSubmitDisabled = isPending || digits.join('').length < length; - - // --------------------------------------------------------------------------- - // JSX - // --------------------------------------------------------------------------- - - return ( - - - {merged.title} - - {interpolate(merged.description, { email })} - - - - - - - {resendSuccess && ( -

- {merged.resendSuccess} -

- )} - -
-
- {merged.inputLabel} - - {/* OTP segment inputs */} -
- {digits.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - pattern="[0-9]*" - maxLength={1} - value={digit} - aria-label={`Digit ${index + 1} of ${length}`} - data-testid={`otp-digit-${index}`} - autoComplete={index === 0 ? 'one-time-code' : 'off'} - className={cn( - 'h-12 w-10 rounded-md border text-center text-lg font-semibold', - 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1', - 'transition-colors', - digit ? 'border-ring' : 'border-input', - 'bg-background text-foreground' - )} - onChange={(e) => handleDigitChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={handlePaste} - /> - ))} -
-
- - - {merged.submitButton} - -
- - {/* Resend CTA */} -
- -
-
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/email-otp-input/messages.ts b/apps/blocks/src/blocks/auth/email-otp-input/messages.ts deleted file mode 100644 index 25240bf..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-input/messages.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * email-otp-input — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and is passed straight to `parseGraphQLError` - * as `customMessages`. - * - * Runtime interpolation tokens: - * description → {{email}} - * resendCooldown → {{seconds}} - * - * PROCEDURE_NOT_FOUND is included because `sign_in_email_otp` and - * `send_email_otp` are backend-pending (sdk-binding-contract.md §10). - */ - -export type EmailOtpInputMessages = { - title: string; - /** Runtime interpolation: {{email}} */ - description: string; - inputLabel: string; - submitButton: string; - submitButtonPending: string; - resendButton: string; - resendPending: string; - /** Runtime interpolation: {{seconds}} */ - resendCooldown: string; - resendSuccess: string; - errors: { - INVALID_OTP: string; - EXPIRED_TOKEN: string; - RATE_LIMITED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultEmailOtpInputMessages: EmailOtpInputMessages = { - title: 'Enter your code', - description: 'We sent a 6-digit code to {{email}}.', - inputLabel: 'One-time code', - submitButton: 'Verify', - submitButtonPending: 'Verifying…', - resendButton: 'Resend code', - resendPending: 'Resending…', - resendCooldown: 'Resend in {{seconds}}s', - resendSuccess: 'Code resent. Check your inbox.', - errors: { - INVALID_OTP: 'Invalid code. Please check and try again.', - EXPIRED_TOKEN: 'This code has expired. Please request a new one.', - RATE_LIMITED: 'Too many attempts. Please wait before trying again.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/email-otp-request-card/auth-email-otp-request-card.requires.json b/apps/blocks/src/blocks/auth/email-otp-request-card/auth-email-otp-request-card.requires.json deleted file mode 100644 index b68e34d..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-request-card/auth-email-otp-request-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["sendEmailOtp"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/email-otp-request-card/email-otp-request-card.tsx b/apps/blocks/src/blocks/auth/email-otp-request-card/email-otp-request-card.tsx deleted file mode 100644 index d445b46..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-request-card/email-otp-request-card.tsx +++ /dev/null @@ -1,374 +0,0 @@ -'use client'; - -/** - * email-otp-request-card (registry: auth-email-otp-request-card) - * - * Email-only form that sends a one-time passcode to the user's email. On success - * it transitions to a "code sent" confirmation panel within the same card - * — NO navigation/redirect. The confirmation copy interpolates the submitted - * email address. A "Resend code" button in the confirmed state calls the same path. - * - * BACKEND-PENDING — CASE (b): `send_email_otp` is not yet deployed in - * `constructive_auth_public` and the generated `useSendEmailOtpMutation` hook - * does NOT exist in the reference SDK. This block therefore: - * • Does NOT import from `@/generated/auth` (no hook to import — tsc would fail). - * • Makes `onSubmit` the primary/required network path: the host wires the - * generated binding after running `cnc codegen --api-names auth ...`. - * • The stub default path throws a typed PROCEDURE_NOT_FOUND error so the - * block behaves gracefully (shows the error message) if accidentally mounted - * without the override. - * • `requires.json` names the pending op so `check-sdk-fixtures.ts` fails clearly. - * • `PROCEDURE_NOT_FOUND` is in `messages.errors`. - * - * When the backend ships and the host regenerates the SDK, replace the stub - * `defaultRunSend` with: - * import { useSendEmailOtpMutation } from '@/generated/auth'; - * const defaultMutation = useSendEmailOtpMutation({ selection: { fields: { clientMutationId: true } } }); - * const [overridePending, setOverridePending] = useState(false); - * const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - * async function defaultRunSend(vars: EmailOtpRequestVars): Promise { - * await defaultMutation.mutateAsync({ input: vars }).then((d) => d.sendEmailOtp); - * } - * and gate setOverridePending on onSubmitOverride (see sign-in-card.tsx). - * - * (`send_email_otp` returns void — no payload fields to select.) - * - * NO QueryClientProvider, NO configure(), NO fetch, NO GraphQL document strings. - * The host mounts `blocks-runtime` once at app root; that is the single wiring - * point. This block joins it as soon as the generated hook ships. - */ - -import { useRef, useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { forgotPasswordSchema, type ForgotPasswordFormData } from '@/blocks/lib/schemas'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; -import { EmailOtpInput } from '../email-otp-input/email-otp-input'; - -import { defaultEmailOtpRequestCardMessages, type EmailOtpRequestCardMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/** - * OTP type discriminator. Passed as the `type` param to send_email_otp. - * Server-defined semantics; the block surfaces them via `otpType`. - */ -export type OtpType = 'sign_in' | 'verify' | 'reset' | 'change_email'; - -/** Variables the send-OTP call receives. The `onSubmit` override gets these verbatim. */ -export type EmailOtpRequestVars = { - email: string; - type: OtpType; -}; - -/** - * Message overrides. Top-level copy is shallow-partial; `errors` is itself - * partial so a host can localize a single error code without restating the map. - */ -export type EmailOtpRequestCardMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type EmailOtpRequestCardProps = { - /** - * OTP type discriminator. Passed as 'type' param to send_email_otp. - * Default: 'sign_in' - */ - otpType?: OtpType; - /** Pre-fill the email field (e.g. from a query param). */ - defaultEmail?: string; - /** - * When true (default), renders [[auth-email-otp-input]] inline in the - * code-sent state, passing `email` down for code entry. - * When false, only shows confirmation message + resend button; the host - * handles navigation to code entry via `onSuccess`. - * Default: true - */ - showOtpInputInline?: boolean; - messages?: EmailOtpRequestCardMessageOverrides; - /** Href for the back-to-sign-in link. Rendered as plain `` when provided. */ - signInHref?: string; - /** - * Replace the default mutation call. Receives the same vars. - * - * BACKEND-PENDING: Until `send_email_otp` is deployed and the host has - * regenerated its auth SDK, this prop is the ONLY way to wire a real network - * call. After codegen, the host may drop this prop and let the generated hook - * (`useSendEmailOtpMutation`) take over via `blocks-runtime`. - */ - onSubmit?: (vars: EmailOtpRequestVars) => Promise; - /** Fires after a resolved send-OTP call. Always fires on success. */ - onSuccess?: (vars: { email: string }) => void; - /** Fires after a mapped error. Always fires on error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and resend. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -type CardState = 'form' | 'code-sent'; - -/** Replaces all `{{email}}` tokens in a template string. */ -function interpolateEmail(template: string, email: string): string { - return template.replace(/\{\{email\}\}/g, email); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function EmailOtpRequestCard({ - otpType = 'sign_in', - defaultEmail, - showOtpInputInline = true, - messages: messageOverrides, - signInHref, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: EmailOtpRequestCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: EmailOtpRequestCardMessages = { - ...defaultEmailOtpRequestCardMessages, - ...messageOverrides, - errors: { ...defaultEmailOtpRequestCardMessages.errors, ...messageOverrides?.errors } - }; - - // --------------------------------------------------------------------------- - // BACKEND-PENDING stub (CASE b) - // - // The generated `useSendEmailOtpMutation` does not exist yet. We provide a - // stub that throws PROCEDURE_NOT_FOUND so the block is self-describing when - // mounted without an `onSubmit` override. Replace this section with the real - // generated hook once the proc ships and the host regenerates its auth SDK. - // --------------------------------------------------------------------------- - const [overridePending, setOverridePending] = useState(false); - - // Hybrid pending: when override is provided, track it; otherwise the stub always - // returns synchronously (PROCEDURE_NOT_FOUND), so pending is always false. - const isPending = onSubmitOverride ? overridePending : false; - - // Resend has its own pending state; it reuses the same run path. - const [resendPending, setResendPending] = useState(false); - - const [error, setError] = useState(null); - const [cardState, setCardState] = useState('form'); - const submittedEmailRef = useRef(''); - const confirmationFocusRef = useRef(null); - - async function defaultRunSend(_vars: EmailOtpRequestVars): Promise { - // Throw a typed PROCEDURE_NOT_FOUND error so parseGraphQLError maps it to - // the human-readable message in merged.errors. - const err = Object.assign(new Error(merged.errors.PROCEDURE_NOT_FOUND), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - throw err; - } - - async function runSend(vars: EmailOtpRequestVars): Promise { - if (onSubmitOverride) return onSubmitOverride(vars); - return defaultRunSend(vars); - } - - async function handleSubmit(values: ForgotPasswordFormData) { - setError(null); - if (onSubmitOverride) setOverridePending(true); - try { - forgotPasswordSchema.parse(values); - await runSend({ email: values.email, type: otpType }); - submittedEmailRef.current = values.email; - setCardState('code-sent'); - onMessage?.({ kind: 'success', key: 'emailOtpRequest.success' }); - onSuccess?.({ email: values.email }); - // Move focus to the confirmation panel for accessibility. - setTimeout(() => confirmationFocusRef.current?.focus(), 0); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - async function handleResend() { - setResendPending(true); - try { - await runSend({ email: submittedEmailRef.current, type: otpType }); - onMessage?.({ kind: 'info', key: 'emailOtpRequest.resend', message: merged.resendSuccess }); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setResendPending(false); - } - } - - const form = useForm({ - defaultValues: { - email: defaultEmail ?? '' - } as ForgotPasswordFormData, - onSubmit: async ({ value }) => { - await handleSubmit(value); - } - }); - - // --------------------------------------------------------------------------- - // Code-sent state (confirmed) - // --------------------------------------------------------------------------- - - if (cardState === 'code-sent') { - // When showOtpInputInline is true (default), render the OTP input inline. - // The EmailOtpInput block is a full card itself — render it standalone, not - // nested inside another Card, to avoid double-card appearance. - if (showOtpInputInline) { - return ( - onSuccess?.({ email: submittedEmailRef.current })} - onError={onError} - onMessage={onMessage} - className={className} - /> - ); - } - - // showOtpInputInline=false: show confirmation + resend only; host navigates. - return ( - - - {/* Block-owned focusable anchor — CardTitle does not forward its ref. */} -
- {merged.title} -
- - {interpolateEmail(merged.codeSentMessage, submittedEmailRef.current)} - -
- - - - {merged.resendButton} - - - - {signInHref && ( - -
- - )} - - ); - } - - // --------------------------------------------------------------------------- - // Form state - // --------------------------------------------------------------------------- - - return ( - - - {merged.title} - {merged.description} - - - - - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - { - if (!value) return 'Email is required'; - if (!/\S+@\S+\.\S+/.test(value)) return 'Please enter a valid email'; - return undefined; - } - }} - > - {(field) => ( - - )} - - -
- - {merged.submitButton} - -
-
-
- - {signInHref && ( - - - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/email-otp-request-card/messages.ts b/apps/blocks/src/blocks/auth/email-otp-request-card/messages.ts deleted file mode 100644 index 995e779..0000000 --- a/apps/blocks/src/blocks/auth/email-otp-request-card/messages.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * email-otp-request-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * BACKEND-PENDING (CASE b): `send_email_otp` is not yet deployed in - * `constructive_auth_public`. The `PROCEDURE_NOT_FOUND` key is required because - * the block's stub path throws this code when no `onSubmit` override is provided - * (sdk-binding-contract.md §10 — backend-pending block: requires.json names - * the absent op, messages.errors.PROCEDURE_NOT_FOUND surfaces the gap). - * - * `{{email}}` in `codeSentMessage` is substituted by `interpolateEmail()` in - * the component (no external interpolation lib needed — the block co-locates it). - */ - -export type EmailOtpRequestCardMessages = { - /** Card title for the initial form state. */ - title: string; - /** Card description for the initial form state. */ - description: string; - emailLabel: string; - emailPlaceholder: string; - submitButton: string; - submitButtonPending: string; - /** - * Shown in the confirmed state. Runtime interpolation: {{email}} - * Replace `{{email}}` with the submitted address before rendering. - */ - codeSentMessage: string; - resendButton: string; - resendPending: string; - resendSuccess: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - RATE_LIMITED: string; - CAPTCHA_FAILED: string; - EMAIL_OTP_DISABLED: string; - /** Required: surfaced when the backend procedure is not yet deployed (CASE b). */ - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultEmailOtpRequestCardMessages: EmailOtpRequestCardMessages = { - title: 'Sign in with a code', - description: "Enter your email and we'll send you a one-time code.", - emailLabel: 'Email', - emailPlaceholder: 'you@example.com', - submitButton: 'Send code', - submitButtonPending: 'Sending…', - codeSentMessage: 'We sent a 6-digit code to {{email}}. Enter it below.', - resendButton: 'Resend code', - resendPending: 'Resending…', - resendSuccess: 'Code resent.', - errors: { - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - CAPTCHA_FAILED: 'Captcha verification failed. Please try again.', - EMAIL_OTP_DISABLED: 'Email OTP sign-in is not enabled.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.', - }, -}; diff --git a/apps/blocks/src/blocks/auth/forgot-password-card/auth-forgot-password-card.requires.json b/apps/blocks/src/blocks/auth/forgot-password-card/auth-forgot-password-card.requires.json deleted file mode 100644 index 12e252b..0000000 --- a/apps/blocks/src/blocks/auth/forgot-password-card/auth-forgot-password-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["forgotPassword"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/forgot-password-card/forgot-password-card.tsx b/apps/blocks/src/blocks/auth/forgot-password-card/forgot-password-card.tsx deleted file mode 100644 index af705bb..0000000 --- a/apps/blocks/src/blocks/auth/forgot-password-card/forgot-password-card.tsx +++ /dev/null @@ -1,282 +0,0 @@ -'use client'; - -/** - * forgot-password-card (registry: auth-forgot-password-card) - * - * Email-only form that initiates the password reset flow. On success it - * transitions to a "check your email" confirmation panel within the same card - * — NO navigation/redirect. The confirmation copy interpolates the submitted - * email address. - * - * Data path: the generated `useForgotPasswordMutation` hook imported from - * `@/generated/auth`. `forgot_password` returns no domain object, so the hook - * selects the payload's `clientMutationId`. The mutation variables wrap the email - * in `{ input: { email } }` (confirmed from generated ForgotPasswordVariables). - * - * Override seam: `onSubmit` fully replaces the generated-hook call. - * Resend: the "Resend email" button in the confirmed panel calls the same hook. - */ - -import { useRef, useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { useForgotPasswordMutation } from '@/generated/auth'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { forgotPasswordSchema, type ForgotPasswordFormData } from '@/blocks/lib/schemas'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; - -import { defaultForgotPasswordCardMessages, type ForgotPasswordCardMessages } from './messages'; - -/** The input shape for the forgot-password call. The override `onSubmit` gets this verbatim. */ -export type ForgotPasswordVars = { - email: string; -}; - -type CardState = 'form' | 'confirmed'; - -/** - * Message overrides. Top-level copy is shallow-partial; `errors` is itself - * partial so a host can localize a single error code without restating the map. - */ -export type ForgotPasswordCardMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type ForgotPasswordCardProps = { - /** Pre-fill the email field (e.g. from a query param). */ - defaultEmail?: string; - /** Show a "Back to sign in" link. Default: true. */ - showBackLink?: boolean; - /** Href for the back-to-sign-in link. Rendered as plain `` when provided. */ - signInHref?: string; - messages?: ForgotPasswordCardMessageOverrides; - /** Replace the default `useForgotPasswordMutation` call. Receives the same vars. */ - onSubmit?: (vars: ForgotPasswordVars) => Promise; - /** Fires after a resolved forgot-password request. Always fires on success. */ - onSuccess?: (vars: ForgotPasswordVars) => void; - /** Fires after a mapped error. Always fires on error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and resend. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -/** Replaces all `{{email}}` tokens in a template string. */ -function interpolateEmail(template: string, email: string): string { - return template.replace(/\{\{email\}\}/g, email); -} - -export function ForgotPasswordCard({ - defaultEmail, - showBackLink = true, - signInHref, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: ForgotPasswordCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: ForgotPasswordCardMessages = { - ...defaultForgotPasswordCardMessages, - ...messageOverrides, - errors: { ...defaultForgotPasswordCardMessages.errors, ...messageOverrides?.errors } - }; - - // PostGraphile mutation payloads are composite objects and require at least - // one selected field even when the procedure itself returns void. - const defaultMutation = useForgotPasswordMutation({ - selection: { fields: { clientMutationId: true } } - }); - - // Hybrid pending: generated hook tracks its own; override path uses local state. - const [overridePending, setOverridePending] = useState(false); - const isPending = onSubmitOverride ? overridePending : defaultMutation.isPending; - - // Resend has its own pending state; it reuses the same mutation. - const [resendPending, setResendPending] = useState(false); - - const [error, setError] = useState(null); - const [cardState, setCardState] = useState('form'); - // Track the submitted email so the confirmation panel can show it. - const submittedEmailRef = useRef(''); - - // Block-owned ref on a plain div so focus reliably lands regardless of - // whether CardTitle forwards its ref (it does not in the current UI package). - const confirmationFocusRef = useRef(null); - - async function runForgotPassword(vars: ForgotPasswordVars): Promise { - if (onSubmitOverride) return onSubmitOverride(vars); - await defaultMutation.mutateAsync({ input: { email: vars.email } }).then((d) => d.forgotPassword); - } - - async function handleSubmit(values: ForgotPasswordFormData) { - setError(null); - if (onSubmitOverride) setOverridePending(true); - try { - forgotPasswordSchema.parse(values); - await runForgotPassword({ email: values.email }); - submittedEmailRef.current = values.email; - setCardState('confirmed'); - onMessage?.({ kind: 'success', key: 'forgotPassword.success' }); - onSuccess?.({ email: values.email }); - // Move focus to the confirmation panel for accessibility. - setTimeout(() => confirmationFocusRef.current?.focus(), 0); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - async function handleResend() { - setResendPending(true); - try { - await runForgotPassword({ email: submittedEmailRef.current }); - onMessage?.({ kind: 'info', key: 'forgotPassword.resend', message: merged.resendSuccessMessage }); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setResendPending(false); - } - } - - const form = useForm({ - defaultValues: { - email: defaultEmail ?? '' - } as ForgotPasswordFormData, - onSubmit: async ({ value }) => { - await handleSubmit(value); - } - }); - - if (cardState === 'confirmed') { - return ( - - - {/* Block-owned focusable anchor — CardTitle does not forward its ref. */} -
- {merged.confirmationTitle} -
- - {interpolateEmail(merged.confirmationDescription, submittedEmailRef.current)} - -
- - - - {merged.resendLabel} - - - - {showBackLink && signInHref && ( - -
- - )} - - ); - } - - return ( - - - {merged.title} - {merged.description} - - - - - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - { - if (!value) return 'Email is required'; - if (!/\S+@\S+\.\S+/.test(value)) return 'Please enter a valid email'; - return undefined; - } - }} - > - {(field) => ( - - )} - - -
- - {merged.submitLabel} - -
-
-
- - {showBackLink && signInHref && ( - - - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/forgot-password-card/messages.ts b/apps/blocks/src/blocks/auth/forgot-password-card/messages.ts deleted file mode 100644 index e493422..0000000 --- a/apps/blocks/src/blocks/auth/forgot-password-card/messages.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * forgot-password-card — message catalog - * - * Canonical block-messages pattern: top-level camelCase keys are UI copy; the - * nested `errors` map is keyed by backend error CODE (UPPER_SNAKE_CASE) and is - * handed straight to `parseGraphQLError` as `customMessages`, so a host - * localizes any code by overriding a single key. - * - * `{{email}}` in `confirmationDescription` is a runtime interpolation token - * replaced by the block at render time. - */ - -export type ForgotPasswordCardMessages = { - title: string; - description: string; - emailLabel: string; - emailPlaceholder: string; - submitLabel: string; - loadingLabel: string; - backToSignInLabel: string; - /** Confirmation panel copy */ - confirmationTitle: string; - /** May contain {{email}} token — replaced at render. */ - confirmationDescription: string; - resendLabel: string; - resendLoadingLabel: string; - resendSuccessMessage: string; - errors: { - RATE_LIMITED: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultForgotPasswordCardMessages: ForgotPasswordCardMessages = { - title: 'Forgot your password?', - description: "Enter your email address and we'll send you a reset link.", - emailLabel: 'Email', - emailPlaceholder: 'you@example.com', - submitLabel: 'Send reset link', - loadingLabel: 'Sending…', - backToSignInLabel: '← Back to sign in', - confirmationTitle: 'Check your email', - confirmationDescription: - "If an account exists for {{email}}, you'll receive a password reset link shortly.", - resendLabel: 'Resend email', - resendLoadingLabel: 'Resending…', - resendSuccessMessage: 'Email resent.', - errors: { - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/forgot-password-page/forgot-password-page.tsx b/apps/blocks/src/blocks/auth/forgot-password-page/forgot-password-page.tsx deleted file mode 100644 index 8e97418..0000000 --- a/apps/blocks/src/blocks/auth/forgot-password-page/forgot-password-page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -/** - * forgot-password-page (registry: auth-forgot-password-page) - * - * Thin Next.js 15 page that composes [[auth-forgot-password-card]] inside a - * centered layout. This is the page-glue layer (block-contract.md §2): - * - * • Reads `?email=` from searchParams and passes it to the card as - * `defaultEmail` (reduces friction when navigated from a sign-in form - * that already knows the typed email). - * • Provides a centered `
` layout (fulfilling the layout-kit - * accessibility requirement — landmark
). - * • No navigation on success — the card transitions to its own confirmed - * state internally; this page does not redirect. - * • Imports `next/navigation` — this is CORRECT for a page block. - * Card blocks NEVER import it; page blocks always do (block-contract §2). - * - * The block calls NO data hooks directly. All data logic lives in the card. - * This block ships NO requires.json (presentational page glue, no generated hook). - * - * Configurable constants at the top of the installed file: - * SIGN_IN_PATH — href rendered inside the card's "Back to sign in" link. - */ - -import { useSearchParams } from 'next/navigation'; - -import { ForgotPasswordCard } from '@/blocks/auth/forgot-password-card/forgot-password-card'; - -// Editable constants in the installed page: -const SIGN_IN_PATH = '/auth/sign-in'; - -export default function ForgotPasswordPage() { - const searchParams = useSearchParams(); - const email = searchParams.get('email') ?? undefined; - - return ( -
- -
- ); -} diff --git a/apps/blocks/src/blocks/auth/invitation-acceptance-card/auth-invitation-acceptance-card.requires.json b/apps/blocks/src/blocks/auth/invitation-acceptance-card/auth-invitation-acceptance-card.requires.json deleted file mode 100644 index c3e242f..0000000 --- a/apps/blocks/src/blocks/auth/invitation-acceptance-card/auth-invitation-acceptance-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "admin", - "mutations": ["submitAppInviteCode", "submitOrgInviteCode"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/invitation-acceptance-card/invitation-acceptance-card.tsx b/apps/blocks/src/blocks/auth/invitation-acceptance-card/invitation-acceptance-card.tsx deleted file mode 100644 index a934b64..0000000 --- a/apps/blocks/src/blocks/auth/invitation-acceptance-card/invitation-acceptance-card.tsx +++ /dev/null @@ -1,352 +0,0 @@ -'use client'; - -/** - * invitation-acceptance-card (registry: auth-invitation-acceptance-card) - * - * Card for accepting or declining an invite (app-level or org-level). Calls - * the host's generated `useSubmitAppInviteCodeMutation` or - * `useSubmitOrgInviteCodeMutation` from `@/generated/admin` — both ops live - * in `invites_public` → namespace `admin`. - * - * Binding doctrine (sdk-binding-contract.md §2, §5–§7): - * • Data path = generated React-Query hooks, no fetch / GraphQL document. - * • NO client bootstrap — blocks-runtime wires the QueryClient + configure(). - * • Override seam: `onSubmit` fully replaces the mutation calls. - * • Error mapping via auth-errors; inline alert only (no toast in v1). - * - * Both mutations take `{ input: { token?: string } }` and return a payload - * with `{ result?: boolean | null }`. A boolean `true` means accepted. - */ - -import { useState } from 'react'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Badge } from '@constructive-io/ui/badge'; -import { Separator } from '@constructive-io/ui/separator'; - -import { cn } from '@/lib/utils'; -import { useSubmitAppInviteCodeMutation, useSubmitOrgInviteCodeMutation } from '@/generated/admin'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { UserAvatar, type UserAvatarUser } from '@/blocks/user/user-avatar/user-avatar'; - -import { - defaultInvitationAcceptanceMessages, - type InvitationAcceptanceMessageOverrides, - type InvitationAcceptanceMessages -} from './messages'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -/** Minimal invite metadata surfaced as props (caller parses URL/token). */ -export type InviteMetadata = { - /** The invitation token from the URL. */ - token: string; - /** App-level or org-level invite. */ - kind: 'app' | 'org'; - /** Inviter's user record. Optional — shown in org invites. */ - inviter?: UserAvatarUser | null; - /** The org being joined. Required when kind === 'org'. */ - org?: UserAvatarUser | null; - /** Human-readable role label (e.g. "Member", "Admin"). */ - role?: string | null; - /** ISO-8601 expiry timestamp — for display only. */ - expiresAt?: string | null; -}; - -/** Result returned from the accept action and passed to `onSuccess`. */ -export type InviteAcceptResult = { - kind: 'app' | 'org'; - /** Populated for org invites if an org was passed as a prop. */ - org?: { - id: string; - displayName: string; - }; - /** Caller routes to this path after acceptance, if provided. */ - redirectTo?: string; -}; - -export type InvitationAcceptanceCardProps = { - /** The invite token from the URL (required). */ - token: string; - /** App-level or org-level invite (default 'app'). */ - kind?: 'app' | 'org'; - /** Optional: inviter user data for display. */ - inviter?: UserAvatarUser | null; - /** Optional: org user data for display (kind='org'). */ - org?: UserAvatarUser | null; - /** Optional: role label for display (kind='org'). */ - role?: string | null; - messages?: InvitationAcceptanceMessageOverrides; - /** - * Replace the default mutation calls. - * Receives `{ token, kind }` and must resolve to `InviteAcceptResult`. - */ - onSubmit?: (input: { token: string; kind: 'app' | 'org' }) => Promise; - /** Fires after acceptance. Always fires. */ - onSuccess?: (result: InviteAcceptResult) => void; - /** Fires when Decline is clicked. Caller navigates away. */ - onDecline?: () => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and non-fatal branches. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// ── Simple mustache interpolation for {{key}} tokens ───────────────────────── -function interpolate(template: string, vars: Record): string { - return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? ''); -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function InvitationAcceptanceCard({ - token, - kind = 'app', - inviter, - org, - role, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onDecline, - onError, - onMessage, - className -}: InvitationAcceptanceCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: InvitationAcceptanceMessages = { - ...defaultInvitationAcceptanceMessages, - ...messageOverrides, - errors: { ...defaultInvitationAcceptanceMessages.errors, ...messageOverrides?.errors } - }; - - // Generated hooks from the host's `admin` SDK (invites_public → namespace admin). - // Both are always instantiated — only one is called based on `kind`. - const submitApp = useSubmitAppInviteCodeMutation({ - selection: { fields: { result: true } } - }); - - const submitOrg = useSubmitOrgInviteCodeMutation({ - selection: { fields: { result: true } } - }); - - // Hybrid pending: generated hooks track their own; override path does not. - const [overridePending, setOverridePending] = useState(false); - const defaultIsPending = kind === 'org' ? submitOrg.isPending : submitApp.isPending; - const isPending = onSubmitOverride ? overridePending : defaultIsPending; - - const [error, setError] = useState(null); - const [accepted, setAccepted] = useState(false); - const [pendingApproval, setPendingApproval] = useState(false); - - async function handleAccept() { - if (!token) { - const msg = merged.missingTokenDescription; - setError(msg); - onMessage?.({ kind: 'error', key: 'MISSING_TOKEN', message: msg }); - onError?.({ message: msg, code: 'MISSING_TOKEN' }); - return; - } - - setError(null); - if (onSubmitOverride) setOverridePending(true); - - try { - let result: InviteAcceptResult; - - let serverAccepted: boolean; - - if (onSubmitOverride) { - result = await onSubmitOverride({ token, kind }); - // B2 compliance: InviteAcceptResult has no accepted field. - // Derive acceptance from result shape: app invites are always accepted; - // org invites are accepted when the caller populates result.org. - serverAccepted = result.kind === 'app' || result.org !== undefined; - } else { - if (kind === 'org') { - const data = await submitOrg.mutateAsync({ input: { token } }); - serverAccepted = data.submitOrgInviteCode?.result ?? false; - } else { - const data = await submitApp.mutateAsync({ input: { token } }); - serverAccepted = data.submitAppInviteCode?.result ?? false; - } - - result = { - kind, - org: - kind === 'org' && org - ? { id: org.id, displayName: org.displayName } - : undefined - }; - } - - if (serverAccepted) { - setAccepted(true); - - const successKey = kind === 'org' ? 'inviteAccepted.org' : 'inviteAccepted.app'; - const successMsg = - kind === 'org' - ? interpolate(merged.orgSuccessTitle, { orgName: org?.displayName ?? '' }) - : merged.appSuccessTitle; - - onMessage?.({ kind: 'success', key: successKey, message: successMsg }); - onSuccess?.(result); - } else { - // Server returned result:false — org approval is pending (is_approved=false). - setPendingApproval(true); - const orgName = org?.displayName ?? ''; - const pendingMsg = interpolate(merged.pendingApprovalDescription, { orgName }); - onMessage?.({ kind: 'info', key: 'INVITE_PENDING_APPROVAL', message: pendingMsg }); - onSuccess?.(result); - } - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - function handleDecline() { - onDecline?.(); - } - - // ── Success screen ────────────────────────────────────────────────────────── - if (accepted) { - const orgName = org?.displayName ?? ''; - - return ( - - - - {kind === 'org' - ? interpolate(merged.orgSuccessTitle, { orgName }) - : merged.appSuccessTitle} - - - {kind === 'org' - ? interpolate(merged.orgSuccessDescription, { orgName }) - : merged.appSuccessDescription} - - - {kind === 'org' && ( - -

{merged.orgSuccessSwitchHint}

-
- )} -
- ); - } - - // ── Pending-approval screen (org invite, is_approved=false) ───────────────── - if (pendingApproval) { - const orgName = org?.displayName ?? ''; - - return ( - - - {merged.pendingApprovalTitle} - - {interpolate(merged.pendingApprovalDescription, { orgName })} - - - - ); - } - - // ── Invite display ────────────────────────────────────────────────────────── - const orgName = org?.displayName ?? ''; - const inviterName = inviter?.displayName ?? ''; - - const title = - kind === 'org' - ? interpolate(merged.orgInviteTitle, { orgName }) - : merged.appInviteTitle; - - const description = - kind === 'org' - ? interpolate(merged.orgInviteDescription, { orgName, inviterName }) - : merged.appInviteDescription; - - return ( - - - {title} - {description} - - - - - - {/* Org avatar + inviter */} - {kind === 'org' && (org || inviter) && ( -
- {org && ( -
- -
-

{org.displayName}

- {org.username && ( -

@{org.username}

- )} -
-
- )} - - {org && inviter && } - - {inviter && ( -
- -

- {merged.orgInviteFrom}:{' '} - {inviter.displayName} -

-
- )} - - {role && ( -
-

{merged.orgInviteRole}:

- {role} -
- )} -
- )} -
- - - - {merged.acceptButton} - - - - -
- ); -} diff --git a/apps/blocks/src/blocks/auth/invitation-acceptance-card/messages.ts b/apps/blocks/src/blocks/auth/invitation-acceptance-card/messages.ts deleted file mode 100644 index 6a120d3..0000000 --- a/apps/blocks/src/blocks/auth/invitation-acceptance-card/messages.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * invitation-acceptance-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - */ - -export type InvitationAcceptanceMessages = { - loadingTitle: string; - appInviteTitle: string; - appInviteDescription: string; - /** Runtime interpolation: {{orgName}} */ - orgInviteTitle: string; - /** Runtime interpolation: {{inviterName}}, {{orgName}} */ - orgInviteDescription: string; - orgInviteRole: string; - orgInviteFrom: string; - acceptButton: string; - acceptButtonPending: string; - declineButton: string; - appSuccessTitle: string; - appSuccessDescription: string; - /** Runtime interpolation: {{orgName}} */ - orgSuccessTitle: string; - /** Runtime interpolation: {{orgName}} */ - orgSuccessDescription: string; - orgSuccessSwitchHint: string; - /** Shown when server returns result:false — org approval is pending. Runtime interpolation: {{orgName}} */ - pendingApprovalTitle: string; - /** Runtime interpolation: {{orgName}} */ - pendingApprovalDescription: string; - expiredTitle: string; - expiredDescription: string; - alreadyUsedTitle: string; - alreadyUsedDescription: string; - emailMismatchTitle: string; - emailMismatchDescription: string; - emailNotVerifiedError: string; - limitReachedTitle: string; - limitReachedDescription: string; - notFoundTitle: string; - notFoundDescription: string; - missingTokenTitle: string; - missingTokenDescription: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - INVITE_NOT_FOUND: string; - INVITE_LIMIT: string; - INVITE_EMAIL_NOT_FOUND: string; - EMAIL_NOT_VERIFIED: string; - UNKNOWN_ERROR: string; - }; -}; - -/** Deep-partial override type: top-level keys optional; errors nested-partial. */ -export type InvitationAcceptanceMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultInvitationAcceptanceMessages: InvitationAcceptanceMessages = { - loadingTitle: 'Loading invitation…', - appInviteTitle: "You’ve been invited", - appInviteDescription: "You’ve received an invitation to join the app.", - orgInviteTitle: "You’ve been invited to {{orgName}}", - orgInviteDescription: '{{inviterName}} has invited you to join {{orgName}}.', - orgInviteRole: 'Role', - orgInviteFrom: 'Invited by', - acceptButton: 'Accept invitation', - acceptButtonPending: 'Accepting…', - declineButton: 'Decline', - appSuccessTitle: 'Welcome aboard!', - appSuccessDescription: "You’ve successfully joined the app.", - orgSuccessTitle: "You’ve joined {{orgName}}", - orgSuccessDescription: 'You are now a member of {{orgName}}.', - orgSuccessSwitchHint: 'You can switch to this organization using the context switcher.', - pendingApprovalTitle: 'Request submitted', - pendingApprovalDescription: 'Your request to join {{orgName}} is pending approval by an administrator.', - expiredTitle: 'Invitation expired', - expiredDescription: 'This invitation link has expired. Ask the sender for a new one.', - alreadyUsedTitle: 'Already used', - alreadyUsedDescription: 'This invitation has already been claimed.', - emailMismatchTitle: 'Wrong account', - emailMismatchDescription: - 'This invitation was sent to a different email address. Sign in with the correct account.', - emailNotVerifiedError: 'Please verify your email address before accepting this invitation.', - limitReachedTitle: 'Invitation limit reached', - limitReachedDescription: 'This invitation link has reached its maximum number of uses.', - notFoundTitle: 'Invitation not found', - notFoundDescription: 'This invitation link is invalid or has been cancelled.', - missingTokenTitle: 'Invalid link', - missingTokenDescription: 'This invitation link is missing required parameters.', - errors: { - INVITE_NOT_FOUND: 'This invitation was not found.', - INVITE_LIMIT: 'This invitation has reached its usage limit.', - INVITE_EMAIL_NOT_FOUND: 'This invitation was sent to a different email address.', - EMAIL_NOT_VERIFIED: 'Please verify your email before accepting this invitation.', - UNKNOWN_ERROR: 'Something went wrong. Please try again.', - }, -}; diff --git a/apps/blocks/src/blocks/auth/invitation-acceptance-page/auth-invitation-acceptance-page.requires.json b/apps/blocks/src/blocks/auth/invitation-acceptance-page/auth-invitation-acceptance-page.requires.json deleted file mode 100644 index b61c8d3..0000000 --- a/apps/blocks/src/blocks/auth/invitation-acceptance-page/auth-invitation-acceptance-page.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": [], - "queries": ["currentUser"], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/invitation-acceptance-page/invitation-acceptance-page.tsx b/apps/blocks/src/blocks/auth/invitation-acceptance-page/invitation-acceptance-page.tsx deleted file mode 100644 index dcd0dee..0000000 --- a/apps/blocks/src/blocks/auth/invitation-acceptance-page/invitation-acceptance-page.tsx +++ /dev/null @@ -1,184 +0,0 @@ -'use client'; - -/** - * invitation-acceptance-page (registry: auth-invitation-acceptance-page) - * - * Thin Next.js page wrapper that composes [[auth-invitation-acceptance-card]] - * inside a centered full-viewport layout and adds router glue: - * - * • Auth gate: calls `useCurrentUserQuery` from `@/generated/auth` to check - * authentication before rendering the card. Unauthenticated users are - * redirected to SIGN_IN_PATH with a `?redirect=` return URL. Users with - * `is_verified=false` see a warning before the card renders. - * • Reads `?token=` and `?kind=` from `useSearchParams()` and passes them - * to `InvitationAcceptanceCard`. - * • Routes to `result.redirectTo` (or `DEFAULT_REDIRECT`) after a successful - * acceptance via `onSuccess`. - * • Routes to `DECLINE_REDIRECT` when the user clicks Decline. - * - * Pages MAY use `next/navigation`; Cards MUST NOT (block-contract.md §6). - * This block imports `useCurrentUserQuery` from `@/generated/auth` and ships - * `auth-invitation-acceptance-page.requires.json` (sdk-binding-contract.md §7). - * - * Editable constants after install: - * const DEFAULT_REDIRECT = '/dashboard'; - * const DECLINE_REDIRECT = '/'; - * const SIGN_IN_PATH = '/auth/sign-in'; - * const BRAND_LOGO_SRC = ''; // optional logo URL - */ - -import { useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { useCurrentUserQuery } from '@/generated/auth'; -import { - InvitationAcceptanceCard, - type InviteAcceptResult -} from '@/blocks/auth/invitation-acceptance-card/invitation-acceptance-card'; -import { - defaultInvitationAcceptanceMessages, - type InvitationAcceptanceMessageOverrides -} from '@/blocks/auth/invitation-acceptance-card/messages'; - -// --------------------------------------------------------------------------- -// Editable constants (installed page — consumer modifies these in place) -// --------------------------------------------------------------------------- -const DEFAULT_REDIRECT = '/dashboard'; -const DECLINE_REDIRECT = '/'; -const SIGN_IN_PATH = '/auth/sign-in'; -/** Optional brand logo URL. Renders above the card when non-empty. */ -const BRAND_LOGO_SRC = ''; - -// --------------------------------------------------------------------------- -// Open-redirect guard (same guard used in sign-in-page) -// --------------------------------------------------------------------------- - -/** - * Returns `redirect` only when it resolves to the same origin. External URLs, - * protocol-relative URLs, and path-encoded bypasses fall back to `fallback`. - */ -function safeRedirect(redirect: string | null | undefined, fallback: string): string { - if (!redirect) return fallback; - try { - const url = new URL(redirect, window.location.origin); - return url.origin === window.location.origin ? redirect : fallback; - } catch { - return fallback; - } -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export type InvitationAcceptancePageProps = { - messages?: InvitationAcceptanceMessageOverrides; - className?: string; -}; - -export default function InvitationAcceptancePage({ messages: messageOverrides, className }: InvitationAcceptancePageProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - const token = searchParams.get('token') ?? ''; - const rawKind = searchParams.get('kind'); - const kind: 'app' | 'org' = rawKind === 'org' ? 'org' : 'app'; - const rawRedirect = searchParams.get('redirect'); - const redirectTo = safeRedirect(rawRedirect ? decodeURIComponent(rawRedirect) : null, DEFAULT_REDIRECT); - - // Merge messages for page-level copy (missing-token state, auth gate) - const merged = { - ...defaultInvitationAcceptanceMessages, - ...messageOverrides, - errors: { ...defaultInvitationAcceptanceMessages.errors, ...messageOverrides?.errors } - }; - - // --------------------------------------------------------------------------- - // Auth gate — check current user before rendering the acceptance card - // --------------------------------------------------------------------------- - const { data: currentUserData, isLoading: authLoading } = useCurrentUserQuery({ - selection: { fields: { id: true } } - }); - - const currentUser = currentUserData?.currentUser; - - // Redirect unauthenticated users to sign-in with a return URL. - useEffect(() => { - if (!authLoading && !currentUser) { - const returnUrl = encodeURIComponent(`/invite?token=${token}&kind=${kind}`); - router.replace(`${SIGN_IN_PATH}?redirect=${returnUrl}`); - } - }, [authLoading, currentUser, token, kind, router]); - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - - function handleSuccess(result: InviteAcceptResult) { - const target = result.redirectTo ? safeRedirect(result.redirectTo, redirectTo) : redirectTo; - router.push(target); - } - - function handleDecline() { - router.push(DECLINE_REDIRECT); - } - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- - - return ( -
- {BRAND_LOGO_SRC && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - Brand logo -
- )} - - {/* Auth loading skeleton */} - {authLoading && ( -
-
-
-
-
-
- )} - - {/* Signed-in state: show page content */} - {!authLoading && currentUser && ( - <> - {!token ? ( -
-

{merged.missingTokenTitle}

-

{merged.missingTokenDescription}

- - Go to sign in - -
- ) : ( - - )} - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/magic-link-callback-page/auth-magic-link-callback-page.requires.json b/apps/blocks/src/blocks/auth/magic-link-callback-page/auth-magic-link-callback-page.requires.json deleted file mode 100644 index f674770..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-callback-page/auth-magic-link-callback-page.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["signInMagicLink"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/magic-link-callback-page/magic-link-callback-page.tsx b/apps/blocks/src/blocks/auth/magic-link-callback-page/magic-link-callback-page.tsx deleted file mode 100644 index aea031c..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-callback-page/magic-link-callback-page.tsx +++ /dev/null @@ -1,404 +0,0 @@ -'use client'; - -/** - * magic-link-callback-page (registry: auth-magic-link-callback-page) - * - * Handles the /auth/magic-link?token=... URL that users land on from their - * email client. On mount, calls `constructive_auth_public.sign_in_magic_link` - * via the generated `useSignInMagicLinkMutation` hook and transitions through: - * loading → success (redirect) | expired | invalid | missing-token. - * - * BACKEND-PENDING — CASE (b): - * `sign_in_magic_link` is not yet deployed in `constructive_auth_public`, so - * `useSignInMagicLinkMutation` is not present in the generated auth SDK at the - * time this block was authored. The import is therefore OMITTED so that - * `tsc --noEmit` passes. The `onSubmit` override is the primary/required path; - * the host wires the generated binding after regenerating the SDK once the proc - * ships. `requires.json` names `signInMagicLink` so `check-sdk-fixtures.ts` will fail - * with a precise message until the host SDK exports it. - * - * DATA PATH (after proc ships): - * import { useSignInMagicLinkMutation } from '@/generated/auth'; - * const defaultMutation = useSignInMagicLinkMutation({ - * selection: { - * fields: { - * result: { - * select: { - * id: true, userId: true, accessToken: true, accessTokenExpiresAt: true, - * isVerified: true, mfaRequired: true, mfaChallengeToken: true, - * } - * } - * } - * } - * }); - * const result = await defaultMutation.mutateAsync({ input: { token, credentialKind } }) - * .then((d) => d.signInMagicLink?.result ?? null); - * - * No fetch, no GraphQL document, no client bootstrap in this file. - * Pages may use next/navigation (block-contract.md §2, §6). - */ - -import { Suspense, useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; - -import { - defaultMagicLinkCallbackPageMessages, - type MagicLinkCallbackPageMessageOverrides, - type MagicLinkCallbackPageMessages -} from './messages'; - -// ─── Configurable constants (edit after installing) ───────────────────────── -const DEFAULT_REDIRECT = '/dashboard'; -const SIGN_IN_PATH = '/auth/sign-in'; -const MAGIC_LINK_REQUEST_PATH = '/auth/magic-link-request'; -const MFA_PATH = '/auth/mfa/totp'; -const CREDENTIAL_KIND = 'bearer'; -// ──────────────────────────────────────────────────────────────────────────── - -/** Internal page state machine */ -type PageState = 'loading' | 'success' | 'expired' | 'invalid' | 'missing-token'; - -/** - * The sign-in result shape this page consumes. Mirrors the auth SDK's - * `SignInMagicLinkPayload` (the fields this page selects); declared here so - * the `onSubmit` override contract does not depend on a generated type name. - */ -export type MagicLinkSignInResult = { - id: string | null; - userId: string | null; - accessToken: string | null; - accessTokenExpiresAt: string | null; - isVerified: boolean | null; - mfaRequired: boolean | null; - mfaChallengeToken: string | null; -}; - -// ─── Inner implementation (needs useSearchParams + useRouter) ──────────────── - -interface MagicLinkCallbackInnerProps { - messages?: MagicLinkCallbackPageMessageOverrides; - /** - * Replace the default `useSignInMagicLinkMutation` call. Receives the same - * vars ({ token, credentialKind }) the default hook would send. - * - * REQUIRED while the backend procedure is pending (CASE b). Once the proc is - * deployed and the host SDK regenerated, this becomes optional — the host can - * remove the override and the generated hook takes over. - */ - onSubmit?: (vars: { token: string; credentialKind: string }) => Promise; - /** Fires after a successful sign-in. Always fires. */ - onSuccess?: (result: MagicLinkSignInResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, warnings, and errors. Always fires. */ - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - className?: string; -} - -function MagicLinkCallbackInner({ - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MagicLinkCallbackInnerProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: MagicLinkCallbackPageMessages = { - ...defaultMagicLinkCallbackPageMessages, - ...messageOverrides, - errors: { - ...defaultMagicLinkCallbackPageMessages.errors, - ...messageOverrides?.errors - } - }; - - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams.get('token'); - const redirectParam = searchParams.get('redirect'); - - // Validate redirect is same-origin to prevent open-redirect attacks. - function safeRedirect(raw: string | null): string { - if (!raw) return DEFAULT_REDIRECT; - try { - const url = new URL(raw, window.location.origin); - return url.origin === window.location.origin ? url.pathname + url.search : DEFAULT_REDIRECT; - } catch { - return DEFAULT_REDIRECT; - } - } - - const [pageState, setPageState] = useState(() => - !token ? 'missing-token' : 'loading' - ); - - // Hybrid pending: the generated hook tracks its own; the override path does not. - const [overridePending, setOverridePending] = useState(false); - - // ── BACKEND-PENDING (CASE b): defaultMutation is unavailable until the proc - // ships and the host regenerates the SDK. The block uses the onSubmit - // override seam as its sole execution path right now. - // - // After the proc ships, restore the generated hook binding: - // - // import { useSignInMagicLinkMutation } from '@/generated/auth'; - // const defaultMutation = useSignInMagicLinkMutation({ - // selection: { - // fields: { - // result: { - // select: { - // id: true, userId: true, accessToken: true, - // accessTokenExpiresAt: true, isVerified: true, - // mfaRequired: true, mfaChallengeToken: true, - // } - // } - // } - // } - // }); - // - // And replace `runSignIn` with the hybrid pattern (see sign-in-card.tsx). - // ──────────────────────────────────────────────────────────────────────────── - - async function runSignIn(vars: { - token: string; - credentialKind: string; - }): Promise { - if (onSubmitOverride) { - return onSubmitOverride(vars); - } - // PROCEDURE_NOT_FOUND guard: if no override and no generated hook yet, - // surface the backend-pending error message rather than crashing. - throw Object.assign( - new Error(merged.errors.PROCEDURE_NOT_FOUND), - { extensions: { code: 'PROCEDURE_NOT_FOUND' } } - ); - } - - // Fire on mount when token param is present. - useEffect(() => { - if (!token) return; - - async function runCallback() { - if (onSubmitOverride) setOverridePending(true); - try { - const result = await runSignIn({ token: token!, credentialKind: CREDENTIAL_KIND }); - - if (!result) { - // Null result without an exception → treat as invalid token. - const message = merged.errors.INVALID_TOKEN; - setPageState('invalid'); - onError?.({ message, code: 'INVALID_TOKEN' }); - onMessage?.({ kind: 'error', key: 'INVALID_TOKEN', message }); - return; - } - - if (result.mfaRequired && result.mfaChallengeToken) { - // MFA required: route to TOTP challenge page. - const redirect = safeRedirect(redirectParam); - onMessage?.({ kind: 'warning', key: 'mfaRequired' }); - onSuccess?.(result); - router.push( - `${MFA_PATH}?token=${encodeURIComponent(result.mfaChallengeToken)}&redirect=${encodeURIComponent(redirect)}` - ); - return; - } - - // Full success: transition to success state then redirect. - setPageState('success'); - onMessage?.({ kind: 'success', key: 'signInMagicLink.success', message: merged.successDescription }); - onSuccess?.(result); - router.push(safeRedirect(redirectParam)); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - - if (code === 'EXPIRED_TOKEN') { - setPageState('expired'); - onError?.({ message, code: key }); - onMessage?.({ kind: 'error', key, message }); - } else { - setPageState('invalid'); - onError?.({ message, code: key }); - onMessage?.({ kind: 'error', key, message }); - } - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - runCallback(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const isLoading = pageState === 'loading' && overridePending; - - return ( -
-
- {pageState === 'loading' && ( - - - {merged.loadingTitle} - {merged.loadingDescription} - - - )} - - {pageState === 'success' && ( - - - {merged.successTitle} - {merged.successDescription} - - - )} - - {pageState === 'expired' && ( - - - {merged.expiredTitle} - {merged.expiredDescription} - - - - - - )} - - {pageState === 'invalid' && ( - - - {merged.invalidTitle} - {merged.invalidDescription} - - - - - - )} - - {pageState === 'missing-token' && ( - - - {merged.missingTokenTitle} - {merged.missingTokenDescription} - - - - - - )} -
-
- ); -} - -// ─── Public page export ────────────────────────────────────────────────────── - -/** - * Props for the magic-link callback page. Because this is a `registry:page`, - * the host typically wires these at the page file level rather than via the - * registry entry. The `onSubmit` prop is the primary seam while the backend - * procedure `sign_in_magic_link` is pending (sdk-binding-contract.md §10, - * CASE b). - */ -export interface MagicLinkCallbackPageProps { - messages?: MagicLinkCallbackPageMessageOverrides; - onSubmit?: (vars: { token: string; credentialKind: string }) => Promise; - onSuccess?: (result: MagicLinkSignInResult) => void; - onError?: (err: { message: string; code: string }) => void; - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - className?: string; -} - -/** - * Next.js page component for the magic-link sign-in callback. - * Mount at `/auth/magic-link` (the URL embedded in magic-link emails). - * Wrap with `` at the page level per Next.js 15 requirements. - * - * @example - * ```tsx - * // app/auth/magic-link/page.tsx - * import { Suspense } from 'react'; - * import MagicLinkCallbackPage from '@/blocks/auth/magic-link-callback-page/magic-link-callback-page'; - * - * export default function Page() { - * return ( - * - * { - * // Host wires the generated hook here until the proc ships. - * return null; - * }} - * /> - * - * ); - * } - * ``` - */ -export default function MagicLinkCallbackPage({ - messages, - onSubmit, - onSuccess, - onError, - onMessage, - className -}: MagicLinkCallbackPageProps) { - return ( - -
- - - {defaultMagicLinkCallbackPageMessages.loadingTitle} - - {defaultMagicLinkCallbackPageMessages.loadingDescription} - - - -
-
- } - > - - - ); -} diff --git a/apps/blocks/src/blocks/auth/magic-link-callback-page/messages.ts b/apps/blocks/src/blocks/auth/magic-link-callback-page/messages.ts deleted file mode 100644 index 17e080c..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-callback-page/messages.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * magic-link-callback-page — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend - * error CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` - * as `customMessages`. The `PROCEDURE_NOT_FOUND` key is required because - * `sign_in_magic_link` is a backend-pending procedure; it surfaces a clear - * message if the host SDK is installed before the DB proc is deployed. - * - * (sdk-binding-contract.md §10 — backend-pending block: requires.json names - * the absent op, messages.errors.PROCEDURE_NOT_FOUND surfaces the gap.) - */ - -export type MagicLinkCallbackPageMessages = { - loadingTitle: string; - loadingDescription: string; - successTitle: string; - successDescription: string; - expiredTitle: string; - expiredDescription: string; - expiredRequestNewLink: string; - invalidTitle: string; - invalidDescription: string; - invalidSignInLink: string; - missingTokenTitle: string; - missingTokenDescription: string; - missingTokenSignInLink: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - EXPIRED_TOKEN: string; - INVALID_TOKEN: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: top-level is shallow-partial; `errors` is itself - * partial so a host can override a single error code without restating the map. - */ -export type MagicLinkCallbackPageMessageOverrides = Partial< - Omit -> & { - errors?: Partial; -}; - -export const defaultMagicLinkCallbackPageMessages: MagicLinkCallbackPageMessages = { - loadingTitle: 'Signing you in…', - loadingDescription: 'Please wait while we verify your link.', - successTitle: 'Signed in', - successDescription: 'You have been signed in successfully. Redirecting…', - expiredTitle: 'Link expired', - expiredDescription: - 'This sign-in link has expired or has already been used. Request a new one.', - expiredRequestNewLink: 'Request a new link', - invalidTitle: 'Invalid link', - invalidDescription: 'This sign-in link is invalid.', - invalidSignInLink: 'Back to sign in', - missingTokenTitle: 'Invalid link', - missingTokenDescription: - 'This sign-in link is missing required parameters. Try clicking the link in your email again.', - missingTokenSignInLink: 'Back to sign in', - errors: { - EXPIRED_TOKEN: 'This sign-in link has expired.', - INVALID_TOKEN: 'This sign-in link is invalid.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Sign-in failed. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/magic-link-request-card/auth-magic-link-request-card.requires.json b/apps/blocks/src/blocks/auth/magic-link-request-card/auth-magic-link-request-card.requires.json deleted file mode 100644 index 8e69cdf..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-request-card/auth-magic-link-request-card.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["requestMagicLink"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/magic-link-request-card/magic-link-request-card.tsx b/apps/blocks/src/blocks/auth/magic-link-request-card/magic-link-request-card.tsx deleted file mode 100644 index a32dec1..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-request-card/magic-link-request-card.tsx +++ /dev/null @@ -1,335 +0,0 @@ -'use client'; - -/** - * magic-link-request-card (registry: auth-magic-link-request-card) - * - * Email-only form that initiates the magic-link sign-in flow. On success it - * transitions to a "Check your email" confirmation panel within the same card - * — NO navigation/redirect. The confirmation copy interpolates the submitted - * email address. Mirrors the `forgot-password-card` pattern exactly. - * - * BACKEND-PENDING — CASE (b): `request_magic_link` is not yet deployed in - * `constructive_auth_public` and the generated `useRequestMagicLinkMutation` - * hook does NOT exist in the reference SDK. This block therefore: - * • Does NOT import from `@/generated/auth` (no hook to import — tsc would fail). - * • Makes `onSubmit` the primary/required network path: the host wires the - * generated binding after running `cnc codegen --api-names auth ...`. - * • The stub default path throws a typed PROCEDURE_NOT_FOUND error so the - * block behaves gracefully (shows the error message) if accidentally mounted - * without the override. - * • `requires.json` names the pending op so `check-sdk-fixtures.ts` fails clearly. - * • `PROCEDURE_NOT_FOUND` is in `messages.errors`. - * - * When the backend ships and the host regenerates the SDK, replace the stub - * `defaultRunRequest` with: - * const defaultMutation = useRequestMagicLinkMutation({ selection: { fields: { clientMutationId: true } } }); - * async function defaultRunRequest(vars: MagicLinkRequestVars) { - * await defaultMutation.mutateAsync({ input: { email: vars.email } }).then((d) => d.requestMagicLink); - * } - * and add the hybrid-isPending pattern (see sdk-binding-contract.md §5). - * - * Override seam: `onSubmit` fully replaces the default network call. - * Resend: the "Resend email" button in the confirmed panel calls the same path. - * - * NO QueryClientProvider, NO configure(), NO fetch, NO GraphQL document strings. - * The host mounts `blocks-runtime` once at app root; that is the single wiring - * point. This block joins it as soon as the generated hook ships. - */ - -import { useRef, useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { forgotPasswordSchema, type ForgotPasswordFormData } from '@/blocks/lib/schemas'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { FormField } from '@/blocks/primitives/form-field'; - -import { defaultMagicLinkRequestCardMessages, type MagicLinkRequestCardMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/** Variables the magic-link request call receives. The `onSubmit` override gets these verbatim. */ -export type MagicLinkRequestVars = { - email: string; -}; - -/** - * Message overrides. Top-level copy is shallow-partial; `errors` is itself - * partial so a host can localize a single error code without restating the map. - */ -export type MagicLinkRequestCardMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type MagicLinkRequestCardProps = { - /** Pre-fill the email field (e.g. from a query param). */ - defaultEmail?: string; - /** Show a "Back to sign in" link. Default: true. */ - showBackLink?: boolean; - messages?: MagicLinkRequestCardMessageOverrides; - /** Href for the back-to-sign-in link. Rendered as plain `` when provided. */ - signInHref?: string; - /** - * Replace the default mutation call. Receives the same vars. - * - * BACKEND-PENDING: Until `request_magic_link` is deployed and the host has - * regenerated its auth SDK, this prop is the ONLY way to wire a real network - * call. After codegen, the host may drop this prop and let the generated hook - * (`useRequestMagicLinkMutation`) take over via `blocks-runtime`. - */ - onSubmit?: (vars: MagicLinkRequestVars) => Promise; - /** Fires after a resolved magic-link request. Always fires on success. */ - onSuccess?: (vars: MagicLinkRequestVars) => void; - /** Fires after a mapped error. Always fires on error. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success, mapped errors, and resend. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -type CardState = 'form' | 'confirmed'; - -/** Replaces all `{{email}}` tokens in a template string. */ -function interpolateEmail(template: string, email: string): string { - return template.replace(/\{\{email\}\}/g, email); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function MagicLinkRequestCard({ - defaultEmail, - showBackLink = true, - messages: messageOverrides, - signInHref, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MagicLinkRequestCardProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: MagicLinkRequestCardMessages = { - ...defaultMagicLinkRequestCardMessages, - ...messageOverrides, - errors: { ...defaultMagicLinkRequestCardMessages.errors, ...messageOverrides?.errors } - }; - - // --------------------------------------------------------------------------- - // BACKEND-PENDING stub (CASE b) - // - // The generated `useRequestMagicLinkMutation` does not exist yet. We provide - // a stub that throws PROCEDURE_NOT_FOUND so the block is self-describing when - // mounted without an `onSubmit` override. Replace this section with the real - // generated hook once the proc ships and the host regenerates its auth SDK. - // --------------------------------------------------------------------------- - const [stubPending, setStubPending] = useState(false); - - async function defaultRunRequest(vars: MagicLinkRequestVars): Promise { - // Throw a typed PROCEDURE_NOT_FOUND error so parseGraphQLError maps it to - // the human-readable message in merged.errors. - const err = Object.assign(new Error(merged.errors.PROCEDURE_NOT_FOUND), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - throw err; - } - - // Hybrid pending state: override path tracks its own; stub uses local state. - const isPending = onSubmitOverride ? stubPending : stubPending; - - // Resend has its own pending state; it reuses the same run path. - const [resendPending, setResendPending] = useState(false); - - const [error, setError] = useState(null); - const [cardState, setCardState] = useState('form'); - const submittedEmailRef = useRef(''); - const confirmationFocusRef = useRef(null); - - async function runRequest(vars: MagicLinkRequestVars): Promise { - if (onSubmitOverride) return onSubmitOverride(vars); - return defaultRunRequest(vars); - } - - async function handleSubmit(values: ForgotPasswordFormData) { - setError(null); - if (onSubmitOverride) setStubPending(true); - try { - forgotPasswordSchema.parse(values); - await runRequest({ email: values.email }); - submittedEmailRef.current = values.email; - setCardState('confirmed'); - onMessage?.({ kind: 'success', key: 'magicLinkRequest.success' }); - onSuccess?.({ email: values.email }); - // Move focus to the confirmation panel for accessibility. - setTimeout(() => confirmationFocusRef.current?.focus(), 0); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setStubPending(false); - } - } - - async function handleResend() { - setResendPending(true); - try { - await runRequest({ email: submittedEmailRef.current }); - onMessage?.({ kind: 'info', key: 'magicLinkRequest.resend', message: merged.resendSuccess }); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setResendPending(false); - } - } - - const form = useForm({ - defaultValues: { - email: defaultEmail ?? '' - } as ForgotPasswordFormData, - onSubmit: async ({ value }) => { - await handleSubmit(value); - } - }); - - // --------------------------------------------------------------------------- - // Confirmed state - // --------------------------------------------------------------------------- - - if (cardState === 'confirmed') { - return ( - - - {/* Block-owned focusable anchor — CardTitle does not forward its ref. */} -
- {merged.confirmationTitle} -
- - {interpolateEmail(merged.confirmationDescription, submittedEmailRef.current)} - -
- - - - {merged.resendButton} - - - - {showBackLink && signInHref && ( - -
- - )} - - ); - } - - // --------------------------------------------------------------------------- - // Form state - // --------------------------------------------------------------------------- - - return ( - - - {merged.title} - {merged.description} - - - - - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - { - if (!value) return 'Email is required'; - if (!/\S+@\S+\.\S+/.test(value)) return 'Please enter a valid email'; - return undefined; - } - }} - > - {(field) => ( - - )} - - -
- - {merged.submitButton} - -
-
-
- - {showBackLink && signInHref && ( - - - - )} -
- ); -} diff --git a/apps/blocks/src/blocks/auth/magic-link-request-card/messages.ts b/apps/blocks/src/blocks/auth/magic-link-request-card/messages.ts deleted file mode 100644 index 902bf65..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-request-card/messages.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * magic-link-request-card — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * PROCEDURE_NOT_FOUND is included because `request_magic_link` is backend-pending - * (not yet deployed in `constructive_auth_public`). Until the proc ships and the - * host regenerates its auth SDK, any attempt to call the mutation will surface this - * code. The block uses the override seam (`onSubmit`) as the primary path until - * the generated hook becomes available. - */ - -export type MagicLinkRequestCardMessages = { - title: string; - description: string; - emailLabel: string; - emailPlaceholder: string; - submitButton: string; - submitButtonPending: string; - backToSignIn: string; - /** Confirmation state strings */ - confirmationTitle: string; - /** Runtime interpolation: {{email}} */ - confirmationDescription: string; - resendButton: string; - resendPending: string; - resendSuccess: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - RATE_LIMITED: string; - CAPTCHA_FAILED: string; - MAGIC_LINK_DISABLED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultMagicLinkRequestCardMessages: MagicLinkRequestCardMessages = { - title: 'Sign in with email link', - description: "Enter your email and we'll send you a sign-in link.", - emailLabel: 'Email', - emailPlaceholder: 'you@example.com', - submitButton: 'Send sign-in link', - submitButtonPending: 'Sending…', - backToSignIn: '← Back to sign in', - confirmationTitle: 'Check your email', - confirmationDescription: 'We sent a sign-in link to {{email}}. Check your inbox.', - resendButton: 'Resend email', - resendPending: 'Resending…', - resendSuccess: 'Email resent.', - errors: { - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - CAPTCHA_FAILED: 'Captcha verification failed. Please try again.', - MAGIC_LINK_DISABLED: 'Magic link sign-in is not enabled.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/magic-link-sent-page/auth-magic-link-sent-page.requires.json b/apps/blocks/src/blocks/auth/magic-link-sent-page/auth-magic-link-sent-page.requires.json deleted file mode 100644 index 8e69cdf..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-sent-page/auth-magic-link-sent-page.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["requestMagicLink"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/magic-link-sent-page/magic-link-sent-page.tsx b/apps/blocks/src/blocks/auth/magic-link-sent-page/magic-link-sent-page.tsx deleted file mode 100644 index e0e9158..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-sent-page/magic-link-sent-page.tsx +++ /dev/null @@ -1,287 +0,0 @@ -'use client'; - -/** - * magic-link-sent-page (registry: auth-magic-link-sent-page) - * - * Static confirmation page shown after [[auth-magic-link-request-card]] submits. - * Provides a "Check your email" affordance with: - * • Resend CTA — calls `useRequestMagicLinkMutation` from the host's generated - * `auth` SDK (sdk-binding-contract.md §3). - * • Resend cooldown — 60-second countdown tracked in component state. - * • "Use a different email" / "Back to sign in" navigation links. - * - * BACKEND-PENDING — CASE (b): `request_magic_link` is not yet deployed in - * `constructive_auth_public` and `useRequestMagicLinkMutation` does NOT exist - * in the reference SDK. This block therefore: - * • Does NOT import from `@/generated/auth` (no hook to import — tsc would fail). - * • Makes `onSubmit` the primary/recommended network path: the host wires the - * generated binding after running `cnc codegen --api-names auth ...`. - * • The stub default path throws a typed PROCEDURE_NOT_FOUND error so the - * block behaves gracefully (shows the error message) if mounted without the override. - * • `requires.json` names the pending op so `check-sdk-fixtures.ts` fails clearly. - * • `PROCEDURE_NOT_FOUND` is in `messages.errors`. - * - * When the backend ships and the host regenerates the SDK, replace the stub - * `defaultResend` with: - * const defaultMutation = useRequestMagicLinkMutation({ selection: {} }); - * async function defaultResend(vars: RequestMagicLinkVars) { - * await defaultMutation.mutateAsync({ email: vars.email }); - * } - * and add the hybrid-isPending pattern (see sdk-binding-contract.md §5). - * - * Email is read from `?email=` searchParam (via useSearchParams) or - * sessionStorage (fallback for direct navigation / bookmark). - * - * Pages MAY import `next/navigation`; Cards MUST NOT (block-contract.md §6). - * - * Editable constants after install: - * const MAGIC_LINK_REQUEST_PATH = '/auth/magic-link'; - * const SIGN_IN_PATH = '/auth/sign-in'; - * const RESEND_COOLDOWN_SECONDS = 60; - */ - -import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; - -import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; - -import { defaultMagicLinkSentPageMessages, type MagicLinkSentPageMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Editable constants (installed page — consumer modifies these in place) -// --------------------------------------------------------------------------- -const MAGIC_LINK_REQUEST_PATH = '/auth/magic-link'; -const SIGN_IN_PATH = '/auth/sign-in'; -const RESEND_COOLDOWN_SECONDS = 60; - -// --------------------------------------------------------------------------- -// Simple {{key}} mustache interpolation (no dep needed) -// --------------------------------------------------------------------------- - -function interpolate(template: string, vars: Record): string { - return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] ?? '')); -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Variables sent to the resend request. The override `onSubmit` gets these. */ -export type RequestMagicLinkVars = { - email: string; -}; - -/** Result of the resend operation. Void on the wire; null here for the override seam. */ -export type RequestMagicLinkResult = null; - -export type MagicLinkSentPageMessageOverrides = Partial> & { - errors?: Partial; -}; - -export type MagicLinkSentPageProps = { - messages?: MagicLinkSentPageMessageOverrides; - /** - * Replace the default resend call. - * Required until `request_magic_link` ships in `constructive_auth_public`. - * After codegen, the host wires in `useRequestMagicLinkMutation`. - */ - onSubmit?: (vars: RequestMagicLinkVars) => Promise; - /** Fires after a successful resend. Always fires. */ - onSuccess?: (result: RequestMagicLinkResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Stub default path (backend-pending) -// Throws PROCEDURE_NOT_FOUND so the error message surfaces in the UI. -// Replace this with the generated hook once `request_magic_link` ships. -// --------------------------------------------------------------------------- - -class ProcedureNotFoundError extends Error { - public readonly extensions = { code: 'PROCEDURE_NOT_FOUND' }; - constructor() { - super('PROCEDURE_NOT_FOUND'); - this.name = 'ProcedureNotFoundError'; - } -} - -async function stubResend(_vars: RequestMagicLinkVars): Promise { - throw new ProcedureNotFoundError(); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export default function MagicLinkSentPage({ - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MagicLinkSentPageProps) { - // Deep merge: top-level copy + errors map merged separately. - const merged: MagicLinkSentPageMessages = { - ...defaultMagicLinkSentPageMessages, - ...messageOverrides, - errors: { ...defaultMagicLinkSentPageMessages.errors, ...messageOverrides?.errors } - }; - - const searchParams = useSearchParams(); - - // Read email from ?email= searchParam; fall back to sessionStorage for direct nav. - const [email] = useState(() => { - const param = searchParams.get('email'); - if (param) return decodeURIComponent(param); - if (typeof sessionStorage !== 'undefined') { - return sessionStorage.getItem('magic-link-email'); - } - return null; - }); - - // The default resend is backend-pending (stub throws PROCEDURE_NOT_FOUND). - // When the host provides `onSubmit`, that path is used instead. - const resendFn = onSubmitOverride ?? stubResend; - - // Track pending state manually (stub is sync-throw; override may be async). - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(null); - const [resendSuccess, setResendSuccess] = useState(false); - - // --------------------------------------------------------------------------- - // Countdown timer - // --------------------------------------------------------------------------- - - const [cooldownRemaining, setCooldownRemaining] = useState(0); - - const startCooldown = useCallback(() => { - setCooldownRemaining(RESEND_COOLDOWN_SECONDS); - }, []); - - useEffect(() => { - if (cooldownRemaining <= 0) return; - const timeout = setTimeout(() => { - setCooldownRemaining((seconds) => Math.max(0, seconds - 1)); - }, 1000); - return () => clearTimeout(timeout); - }, [cooldownRemaining]); - - // --------------------------------------------------------------------------- - // Resend handler - // --------------------------------------------------------------------------- - - async function handleResend() { - if (!email || isPending || cooldownRemaining > 0) return; - setError(null); - setResendSuccess(false); - setIsPending(true); - try { - await resendFn({ email }); - setResendSuccess(true); - startCooldown(); - onMessage?.({ kind: 'success', key: 'requestMagicLink.success', message: merged.resendSuccess }); - onSuccess?.(null); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setIsPending(false); - } - } - - // --------------------------------------------------------------------------- - // Resend button label - // --------------------------------------------------------------------------- - - function resendButtonLabel(): string { - if (isPending) return merged.resendPending; - if (cooldownRemaining > 0) return interpolate(merged.resendCooldown, { seconds: cooldownRemaining }); - return merged.resendButton; - } - - const isResendDisabled = !email || isPending || cooldownRemaining > 0; - - // --------------------------------------------------------------------------- - // JSX - // --------------------------------------------------------------------------- - - return ( -
- - - {merged.title} - {email ? ( - {interpolate(merged.description, { email })} - ) : ( - {merged.description.replace('{{email}}', 'your email address')} - )} - - - - - - {resendSuccess && ( -

- {merged.resendSuccess} -

- )} - - - {resendButtonLabel()} - - - -
- - - - -
-
- ); -} diff --git a/apps/blocks/src/blocks/auth/magic-link-sent-page/messages.ts b/apps/blocks/src/blocks/auth/magic-link-sent-page/messages.ts deleted file mode 100644 index c098a92..0000000 --- a/apps/blocks/src/blocks/auth/magic-link-sent-page/messages.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * magic-link-sent-page — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * Runtime interpolation: {{email}} in `description`, {{seconds}} in `resendCooldown`. - */ - -export type MagicLinkSentPageMessages = { - title: string; - /** Runtime interpolation: {{email}} */ - description: string; - resendButton: string; - resendPending: string; - /** Runtime interpolation: {{seconds}} */ - resendCooldown: string; - resendSuccess: string; - differentEmailLink: string; - signInLink: string; - /** Error messages — UPPER_SNAKE_CASE keys match err.extensions.code from PostGraphile */ - errors: { - RATE_LIMITED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultMagicLinkSentPageMessages: MagicLinkSentPageMessages = { - title: 'Check your email', - description: 'We sent a sign-in link to {{email}}. The link expires in a few minutes.', - resendButton: 'Resend email', - resendPending: 'Resending…', - resendCooldown: 'Resend in {{seconds}}s', - resendSuccess: 'Email resent. Check your inbox.', - differentEmailLink: 'Use a different email', - signInLink: 'Back to sign in', - errors: { - RATE_LIMITED: 'Too many requests. Please wait before trying again.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Failed to resend email. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-backup-codes-display/messages.ts b/apps/blocks/src/blocks/auth/mfa-backup-codes-display/messages.ts deleted file mode 100644 index d2f5908..0000000 --- a/apps/blocks/src/blocks/auth/mfa-backup-codes-display/messages.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * mfa-backup-codes-display — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy. This block has no error-code map because it is - * purely display-only — errors from clipboard/download APIs are handled inline. - * The `errors` object is minimal but present per block-contract pattern; it - * includes PROCEDURE_NOT_FOUND as a sentinel for future wiring (the backend - * `generate_backup_codes` proc is not yet deployed; this block's callers will - * surface that error when it ships, and a host can override this key). - */ - -export type MfaBackupCodesDisplayMessages = { - title: string; - description: string; - warningText: string; - copyAllButton: string; - copiedButton: string; - downloadButton: string; - confirmCheckboxLabel: string; - continueButton: string; - errors: { - /** - * Sentinel: the `generate_backup_codes` backend procedure is not yet - * deployed. Callers that wrap this block and call the future hook should - * surface this message when the mutation fails with PROCEDURE_NOT_FOUND. - */ - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export type MfaBackupCodesDisplayMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultMfaBackupCodesDisplayMessages: MfaBackupCodesDisplayMessages = { - title: 'Save your backup codes', - description: - 'If you lose access to your authenticator app, you can use one of these codes to sign in. Each code can only be used once.', - warningText: 'Store these codes somewhere safe. They will not be shown again.', - copyAllButton: 'Copy all', - copiedButton: 'Copied!', - downloadButton: 'Download as .txt', - confirmCheckboxLabel: 'I have saved my backup codes in a safe place.', - continueButton: 'Continue', - errors: { - PROCEDURE_NOT_FOUND: - 'Backup code generation is not available yet. Contact your administrator.', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display.tsx b/apps/blocks/src/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display.tsx deleted file mode 100644 index 89e27f8..0000000 --- a/apps/blocks/src/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display.tsx +++ /dev/null @@ -1,203 +0,0 @@ -'use client'; - -/** - * mfa-backup-codes-display (registry: auth-mfa-backup-codes-display) - * - * Display-only card for one-time presentation of MFA backup codes. Codes are - * passed in via the `codes` prop by the caller (auth-mfa-totp-enroll or - * auth-mfa-backup-codes-regenerate); this block has NO generated-hook import, - * NO fetch, NO network call, and NO requires.json — it is purely presentational - * (sdk-binding-contract.md §7: presentational blocks ship no manifest). - * - * Affordances: - * • 2-column grid of monospace cells (selectable, 1-col on mobile). - * • "Copy all" — writes all codes as newline-separated text to the clipboard. - * • "Download as .txt" — client-side download, one code per line + header. - * • "I have saved…" checkbox gate + "Continue" button (when requireConfirmation=true). - * - * Accessibility: - * • Codes rendered as
  • for screen readers. - * • Copy/download buttons have aria-label. - * • Checkbox associated to label via htmlFor. - * • Continue button has aria-disabled="true" until checkbox is checked. - * - * NOTE: Backend generate_backup_codes procedure is future/undeployed. Codes are - * generated by the CALLER and passed in here — see the `codes` prop. - */ - -import { useState } from 'react'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; -import { Checkbox } from '@constructive-io/ui/checkbox'; -import { Badge } from '@constructive-io/ui/badge'; - -import { cn } from '@/lib/utils'; - -import { - defaultMfaBackupCodesDisplayMessages, - type MfaBackupCodesDisplayMessageOverrides -} from './messages'; - -export type MfaBackupCodesDisplayProps = { - /** The backup codes to display. Returned from generate_backup_codes() by the caller. */ - codes: string[]; - /** - * When true (default), renders an "I have saved these codes" checkbox gate. - * The onConfirm callback fires only after the user checks the box and clicks Continue. - */ - requireConfirmation?: boolean; - /** Fires when user confirms they have saved the codes (clicks Continue). */ - onConfirm?: () => void; - /** Notification seam — fires for the confirm event. Always fires when onConfirm is called. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - messages?: MfaBackupCodesDisplayMessageOverrides; - className?: string; -}; - -export function MfaBackupCodesDisplay({ - codes, - requireConfirmation = true, - onConfirm, - onMessage, - messages: messageOverrides, - className -}: MfaBackupCodesDisplayProps) { - // Deep merge: top-level copy + the errors map merged separately. - const merged = { - ...defaultMfaBackupCodesDisplayMessages, - ...messageOverrides, - errors: { - ...defaultMfaBackupCodesDisplayMessages.errors, - ...messageOverrides?.errors - } - }; - - const [copied, setCopied] = useState(false); - const [confirmed, setConfirmed] = useState(false); - - function handleCopyAll() { - const text = codes.join('\n'); - navigator.clipboard.writeText(text).then(() => { - setCopied(true); - // Reset the "Copied!" label after 2 seconds - setTimeout(() => setCopied(false), 2000); - }); - } - - function handleDownload() { - const header = '# Backup codes for your account — keep these safe\n\n'; - const body = codes.join('\n'); - const blob = new Blob([header + body], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = 'backup-codes.txt'; - anchor.click(); - URL.revokeObjectURL(url); - } - - function handleContinue() { - onMessage?.({ kind: 'success', key: 'backupCodes.confirmed' }); - onConfirm?.(); - } - - const continueDisabled = requireConfirmation && !confirmed; - - return ( - - - {merged.title} - {merged.description} - - - - {/* Warning badge — codes are shown once */} -
    - - Warning - -

    {merged.warningText}

    -
    - - {/* Code grid — 2 columns, 1 on mobile */} -
      - {codes.map((code, i) => ( -
    • - - {code} - -
    • - ))} -
    - - {/* Copy all + Download actions */} -
    - - -
    - - {/* Confirmation checkbox */} - {requireConfirmation && ( -
    - setConfirmed(checked === true)} - data-testid="confirm-checkbox" - /> - -
    - )} -
    - - {/* Continue button in footer */} - - - -
    - ); -} diff --git a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/auth-mfa-backup-codes-regenerate.requires.json b/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/auth-mfa-backup-codes-regenerate.requires.json deleted file mode 100644 index 82bbb14..0000000 --- a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/auth-mfa-backup-codes-regenerate.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["generateBackupCodes"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/messages.ts b/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/messages.ts deleted file mode 100644 index b597183..0000000 --- a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/messages.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * mfa-backup-codes-regenerate — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * PROCEDURE_NOT_FOUND is included because `generate_backup_codes` is - * backend-pending (sdk-binding-contract.md §10, CASE b). When the proc is - * deployed and codegen produces `useGenerateBackupCodesMutation`, the host wires - * the generated hook via the `onSubmit` seam; this error code will surface if the - * deployed proc somehow fails with PROCEDURE_NOT_FOUND. - */ - -export type MfaBackupCodesRegenerateMessages = { - title: string; - description: string; - warningText: string; - regenerateButton: string; - cancelButton: string; - generatingButton: string; - successMessage: string; - errors: { - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: consumers can override any top-level key and/or - * any individual error code without restating the full catalog. - */ -export type MfaBackupCodesRegenerateMessageOverrides = Partial< - Omit -> & { - errors?: Partial; -}; - -export const defaultMfaBackupCodesRegenerateMessages: MfaBackupCodesRegenerateMessages = { - title: 'Regenerate backup codes', - description: - 'Generate a new set of backup codes. Your old backup codes will stop working immediately.', - warningText: 'Make sure to save the new codes. Old codes cannot be recovered.', - regenerateButton: 'Regenerate backup codes', - cancelButton: 'Cancel', - generatingButton: 'Generating…', - successMessage: 'Backup codes regenerated successfully.', - errors: { - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/mfa-backup-codes-regenerate.tsx b/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/mfa-backup-codes-regenerate.tsx deleted file mode 100644 index 40545bd..0000000 --- a/apps/blocks/src/blocks/auth/mfa-backup-codes-regenerate/mfa-backup-codes-regenerate.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client'; - -/** - * mfa-backup-codes-regenerate (registry: auth-mfa-backup-codes-regenerate) - * - * Confirmation dialog → step-up → generate_backup_codes() → display new codes - * via [[auth-mfa-backup-codes-display]]. Used in account security settings when - * the user wants to rotate their backup codes. - * - * BACKEND-PENDING (CASE b): `generate_backup_codes` is NOT yet deployed to - * `constructive_auth_public`. The generated hook `useGenerateBackupCodesMutation` - * does not exist in the current SDK — importing it would fail tsc. Consequently: - * • The `@/generated/auth` import for that hook is OMITTED. - * • `onSubmit` is REQUIRED (no default mutation path until the proc ships). - * • When the backend deploys and codegen regenerates, the host replaces the - * `onSubmit` prop with the generated hook binding. - * • requires.json names `generateBackupCodes` so `check-sdk-fixtures.ts` fails clearly. - * • messages.errors.PROCEDURE_NOT_FOUND is present for when the proc first lands. - * - * Flow: - * 1. Confirmation state — dialog open; warns that old codes are immediately invalidated. - * 2. Step-up — `await stepUp({ tier: 'high' })`. Cancel returns silently. - * 3. Generating — calls onSubmit() (the mutation adapter). Shows loading state. - * 4. Display — renders [[auth-mfa-backup-codes-display]] with new codes. - * onSuccess fires when user confirms codes are saved. - * - * Binding doctrine: sdk-binding-contract.md §5–§7, MASTER-PROMPT §5. - * Step-up: step-up-contract.md §3 — `tier: 'high'` gates the regenerate action. - * STEP_UP_CANCELLED → silent return (no error callbacks, no toast). - */ - -import { useState } from 'react'; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; -import { MfaBackupCodesDisplay } from '@/blocks/auth/mfa-backup-codes-display/mfa-backup-codes-display'; - -import { - defaultMfaBackupCodesRegenerateMessages, - type MfaBackupCodesRegenerateMessages, - type MfaBackupCodesRegenerateMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** The result type returned by the onSubmit adapter and passed to onSuccess. */ -export type MfaBackupCodesRegenerateResult = { - codes: string[]; -}; - -export type MfaBackupCodesRegenerateProps = { - /** Controlled open state. */ - open: boolean; - /** Called when the dialog requests open-state change (close on cancel/X). */ - onOpenChange: (open: boolean) => void; - messages?: MfaBackupCodesRegenerateMessageOverrides; - /** - * Adapter override — REQUIRED until `generate_backup_codes` is deployed to - * the backend and codegen regenerates `useGenerateBackupCodesMutation`. - * - * Expected signature: - * `onSubmit={async () => { const d = await generateBackupCodes.mutateAsync({}); return d.generateBackupCodes; }}` - * - * Returns: `{ codes: string[] }` — the newly generated backup codes. - */ - onSubmit: () => Promise; - /** Fires after the user confirms codes are saved (clicks Continue in the display step). Always fires. */ - onSuccess?: (result: MfaBackupCodesRegenerateResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. Always fires. */ - onMessage?: (event: { - kind: 'success' | 'error' | 'info' | 'warning'; - key: string; - message?: string; - }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function MfaBackupCodesRegenerate({ - open, - onOpenChange, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MfaBackupCodesRegenerateProps) { - // Deep merge: top-level copy + the errors map merged separately. - const merged: MfaBackupCodesRegenerateMessages = { - ...defaultMfaBackupCodesRegenerateMessages, - ...messageOverrides, - errors: { - ...defaultMfaBackupCodesRegenerateMessages.errors, - ...messageOverrides?.errors - } - }; - - const stepUp = useStepUp(); - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(null); - /** Codes returned after a successful regeneration — drives the display step. */ - const [codes, setCodes] = useState(null); - - async function handleRegenerate() { - setError(null); - try { - // Step-up must complete before the mutation fires. - // tier:'high' → MFA if enrolled (TOTP is active at this point), else password. - await stepUp({ tier: 'high' }); - - // Proceed with the mutation via the override seam (BACKEND-PENDING). - setIsPending(true); - const result = await onSubmitOverride(); - - // Transition to display step — show codes via [[auth-mfa-backup-codes-display]]. - setCodes(result.codes); - } catch (err) { - // STEP_UP_CANCELLED: user dismissed the step-up dialog — silent return. - if (err instanceof StepUpError && err.reason === 'cancelled') return; - - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setIsPending(false); - } - } - - function handleCancel() { - if (isPending) return; - setError(null); - onOpenChange(false); - } - - function handleCodesConfirmed() { - // User confirmed codes are saved — fire success and close. - const result: MfaBackupCodesRegenerateResult = { codes: codes! }; - onMessage?.({ - kind: 'success', - key: 'generateBackupCodes.success', - message: merged.successMessage - }); - onSuccess?.(result); - // Reset dialog state and close. - setCodes(null); - onOpenChange(false); - } - - return ( - { - if (!isOpen) handleCancel(); - }} - > - - {codes !== null ? ( - // Display step — show new codes via [[auth-mfa-backup-codes-display]]. - // The display component handles "I have saved these" confirmation gate. - - ) : ( - // Confirmation step — warn about invalidation before regenerating. - <> - - - {merged.title} - - - {merged.description} - - - -
    - {/* Prominent warning — old codes invalidated immediately */} -
    -

    {merged.warningText}

    -
    - - {/* Async error alert (aria-live="polite" is inside AuthErrorAlert) */} - -
    - - - {/* Cancel button receives initial focus (safer default for destructive action) */} - - - - {merged.regenerateButton} - - - - )} -
    -
    - ); -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/messages.ts b/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/messages.ts deleted file mode 100644 index 35b45e1..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/messages.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * mfa-totp-challenge-page — message catalog - * - * Canonical block-messages pattern (block-contract.md §4): top-level camelCase - * keys are UI copy for the page's own error states (missing token, expired token). - * The card's own messages are handled by auth-mfa-totp-challenge — these are - * only for the page wrapper states. - */ - -export type MfaTotpChallengePageMessages = { - /** Shown when ?token= is absent from the URL */ - missingTokenTitle: string; - missingTokenDescription: string; - missingTokenCta: string; - /** Shown when the challenge token has expired (EXPIRED_TOKEN from the card's onError) */ - expiredTokenTitle: string; - expiredTokenDescription: string; - expiredTokenCta: string; -}; - -export const defaultMfaTotpChallengePageMessages: MfaTotpChallengePageMessages = { - missingTokenTitle: 'Invalid link', - missingTokenDescription: 'This sign-in link is missing required parameters. Please sign in again.', - missingTokenCta: 'Back to sign in', - expiredTokenTitle: 'Session expired', - expiredTokenDescription: - 'Your sign-in session has expired. Please sign in again to get a new verification link.', - expiredTokenCta: 'Sign in again' -}; diff --git a/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/mfa-totp-challenge-page.tsx b/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/mfa-totp-challenge-page.tsx deleted file mode 100644 index b993bf4..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-challenge-page/mfa-totp-challenge-page.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client'; - -/** - * mfa-totp-challenge-page (registry: auth-mfa-totp-challenge-page) - * - * Thin Next.js page mounted at `/auth/mfa/totp`. Reads `?token=` and - * `?redirect=` from `useSearchParams()`, validates both, and mounts - * ``. Routes to `redirectTo` on success. - * - * Page states: - * ready — ?token= present; shows the MfaTotpChallenge card - * missing-token — ?token= absent; shows error card - * expired — card fires onError with code EXPIRED_TOKEN; shows error card - * - * This block calls NO generated hook directly. All mutation logic is delegated - * to auth-mfa-totp-challenge. No requires.json is shipped (sdk-binding-contract §7). - * - * Pages MAY use `next/navigation`; Cards MUST NOT (block-contract.md §6). - * - * Editable constants after install: - * const DEFAULT_REDIRECT = '/dashboard'; - * const SIGN_IN_PATH = '/auth/sign-in'; - */ - -import { useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { Card, CardContent, CardHeader, CardDescription } from '@constructive-io/ui/card'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { MfaTotpChallenge } from '@/blocks/auth/mfa-totp-challenge/mfa-totp-challenge'; -import type { MfaChallengeResult, MfaTotpChallengeVars } from '@/blocks/auth/mfa-totp-challenge/mfa-totp-challenge'; - -import { - defaultMfaTotpChallengePageMessages, - type MfaTotpChallengePageMessages -} from './messages'; - -// --------------------------------------------------------------------------- -// Editable constants (installed page — consumer modifies these in place) -// --------------------------------------------------------------------------- -const DEFAULT_REDIRECT = '/dashboard'; -const SIGN_IN_PATH = '/auth/sign-in'; - -// --------------------------------------------------------------------------- -// Open-redirect guard (same pattern as auth-sign-in-page) -// --------------------------------------------------------------------------- - -/** - * Returns `redirect` only when it resolves to the same origin as the current - * page. External URLs — including absolute URLs, protocol-relative, and path- - * encoded bypasses — are silently replaced with `fallback`. - */ -function safeRedirect(redirect: string | null | undefined, fallback: string): string { - if (!redirect) return fallback; - try { - const url = new URL(redirect, window.location.origin); - return url.origin === window.location.origin ? redirect : fallback; - } catch { - return fallback; - } -} - -// --------------------------------------------------------------------------- -// Internal page state -// --------------------------------------------------------------------------- - -type PageState = 'ready' | 'missing-token' | 'expired'; - -// --------------------------------------------------------------------------- -// Message override type (deep-partial) -// --------------------------------------------------------------------------- - -export type MfaTotpChallengePageMessageOverrides = Partial; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export default function MfaTotpChallengePage({ - messages: messageOverrides, - onSubmit: onSubmitOverride, - className -}: { - messages?: MfaTotpChallengePageMessageOverrides; - /** - * Override seam — passed through to so tests (and consumers - * who want an early integration before the generated hook ships) can inject a - * custom submit handler. Mirrors the card's own onSubmit prop. - */ - onSubmit?: (vars: MfaTotpChallengeVars) => Promise; - className?: string; -}) { - const router = useRouter(); - const searchParams = useSearchParams(); - - // Merge messages - const merged: MfaTotpChallengePageMessages = { - ...defaultMfaTotpChallengePageMessages, - ...messageOverrides - }; - - // Read ?token= and ?redirect= from URL - const rawToken = searchParams.get('token'); - const rawRedirect = searchParams.get('redirect'); - const redirectTo = safeRedirect(rawRedirect ? decodeURIComponent(rawRedirect) : null, DEFAULT_REDIRECT); - - // Page state — starts in 'missing-token' if no token, otherwise 'ready' - const [pageState, setPageState] = useState(rawToken ? 'ready' : 'missing-token'); - - function handleSuccess(_result: MfaChallengeResult) { - // _result is unused today; when the backend ships, extract result.redirectTo - // as a server-provided redirect hint (takes precedence over ?redirect= param). - router.push(redirectTo); - } - - function handleError(err: { message: string; code: string }) { - if (err.code === 'EXPIRED_TOKEN') { - setPageState('expired'); - } - } - - // --------------------------------------------------------------------------- - // Error state rendering (missing-token or expired) - // --------------------------------------------------------------------------- - - if (pageState === 'missing-token') { - return ( -
    - - -

    {merged.missingTokenTitle}

    - {merged.missingTokenDescription} -
    - - - -
    -
    - ); - } - - if (pageState === 'expired') { - return ( -
    - - -

    {merged.expiredTokenTitle}

    - {merged.expiredTokenDescription} -
    - - - -
    -
    - ); - } - - // --------------------------------------------------------------------------- - // Ready state — mount the MFA challenge card - // --------------------------------------------------------------------------- - - return ( -
    - -
    - ); -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-challenge/auth-mfa-totp-challenge.requires.json b/apps/blocks/src/blocks/auth/mfa-totp-challenge/auth-mfa-totp-challenge.requires.json deleted file mode 100644 index a9a0f03..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-challenge/auth-mfa-totp-challenge.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["completeMfaChallenge"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-challenge/messages.ts b/apps/blocks/src/blocks/auth/mfa-totp-challenge/messages.ts deleted file mode 100644 index c89c685..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-challenge/messages.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * mfa-totp-challenge — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * PROCEDURE_NOT_FOUND is included because `complete_mfa_challenge` is backend- - * pending (sdk-binding-contract.md §10). It will surface until the procedure - * is deployed and codegen regenerated. - */ - -export type MfaTotpChallengeMessages = { - title: string; - description: string; - codeLabel: string; - codePlaceholder: string; - trustDeviceLabel: string; - trustDeviceHint: string; - submitButton: string; - loadingLabel: string; - backupCodeLink: string; - successToast: string; - errors: { - INVALID_TOTP: string; - EXPIRED_TOKEN: string; - RATE_LIMITED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -export const defaultMfaTotpChallengeMessages: MfaTotpChallengeMessages = { - title: 'Two-factor authentication', - description: 'Enter the 6-digit code from your authenticator app.', - codeLabel: 'Authentication code', - codePlaceholder: '000000', - trustDeviceLabel: 'Trust this device for 30 days', - trustDeviceHint: 'Skip two-factor on this device for 30 days.', - submitButton: 'Verify', - loadingLabel: 'Verifying...', - backupCodeLink: 'Use a backup code instead', - successToast: 'Verified successfully.', - errors: { - INVALID_TOTP: 'Invalid code. Check your authenticator app and try again.', - EXPIRED_TOKEN: 'Your session expired. Please sign in again.', - RATE_LIMITED: 'Too many attempts. Please wait before trying again.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-totp-challenge/mfa-totp-challenge.tsx b/apps/blocks/src/blocks/auth/mfa-totp-challenge/mfa-totp-challenge.tsx deleted file mode 100644 index 3a5bc0d..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-challenge/mfa-totp-challenge.tsx +++ /dev/null @@ -1,315 +0,0 @@ -'use client'; - -/** - * mfa-totp-challenge (registry: auth-mfa-totp-challenge) - * - * Presents a 6-digit TOTP code input when `sign_in` returns - * `mfa_required=true` with a non-null `mfa_challenge_token`. - * - * BACKEND-PENDING CASE (b): `complete_mfa_challenge` is not yet deployed in - * `constructive_auth_public`, so `useCompleteMfaChallengeMutation` does NOT - * exist in the generated `auth` SDK. To keep tsc clean the import from - * `@/generated/auth` is OMITTED here. The `onSubmit` override seam is the - * primary call path (the host wires the generated binding after they - * regenerate the SDK). Until then PROCEDURE_NOT_FOUND surfaces at runtime. - * See: sdk-binding-contract.md §10, planning/blocks/auth/auth-mfa-totp-challenge.md - * - * Data-binding contract (sdk-binding-contract.md §5): - * Hook: useCompleteMfaChallengeMutation (pending — not in SDK yet) - * Namespace: auth - * Import (when deployed): import { useCompleteMfaChallengeMutation } from '@/generated/auth' - * Op: completeMfaChallenge - * Payload key: d.completeMfaChallenge - */ - -import { useState } from 'react'; -import { useForm } from '@tanstack/react-form'; - -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@constructive-io/ui/card'; -import { Checkbox } from '@constructive-io/ui/checkbox'; - -import { Input } from '@constructive-io/ui/input'; -import { FormControl } from '@constructive-io/ui/form-control'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; - -import { defaultMfaTotpChallengeMessages, type MfaTotpChallengeMessages } from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** The session/user payload returned after a successful MFA challenge. */ -export type MfaChallengeResult = { - session: { id: string; accessToken: string; expiresAt: string }; - user: { id: string; [key: string]: unknown }; - redirectTo?: string; -}; - -/** Vars the submit call receives. The override `onSubmit` gets these verbatim. */ -export type MfaTotpChallengeVars = { - totpValue: string; - trustDevice: boolean; - challengeToken: string; - mfaMethod: string; - credentialKind: string; - deviceToken?: string; - rememberMe?: boolean; -}; - -/** - * Deep-partial message override type: top-level keys are shallow-partial; - * `errors` is itself partial so a host can localize a single error code without - * restating the whole map. - */ -export type MfaTotpChallengeMessageOverrides = Partial> & { - errors?: Partial; -}; - -// --------------------------------------------------------------------------- -// Internal form values shape -// --------------------------------------------------------------------------- - -type TotpFormData = { - totpCode: string; - trustDevice: boolean; -}; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -export type MfaTotpChallengeProps = { - /** The mfa_challenge_token from the sign_in result. Required. */ - challengeToken: string; - /** The mfa_method to pass to complete_mfa_challenge. Default: 'totp'. */ - mfaMethod?: string; - /** The credential_kind to use for session creation. Default: 'bearer'. */ - credentialKind?: string; - /** Whether the "Trust this device for 30 days" checkbox is shown. Default: true. */ - showTrustDevice?: boolean; - /** - * Backup-code path affordance — locked false in v1; enable when - * verify_backup_code lands. See: backend-spec/future-procedures.md - */ - allowBackupCode?: false; - messages?: MfaTotpChallengeMessageOverrides; - /** - * Replace the default `useCompleteMfaChallengeMutation` call (backend-pending). - * In v1 this is the PRIMARY path — the host provides a custom implementation - * until `complete_mfa_challenge` is deployed. - */ - onSubmit?: (vars: MfaTotpChallengeVars) => Promise; - /** Fires after MFA challenge completed and session is active. Always fires. */ - onSuccess?: (result: MfaChallengeResult) => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function MfaTotpChallenge({ - challengeToken, - mfaMethod = 'totp', - credentialKind = 'bearer', - showTrustDevice = true, - // allowBackupCode is locked false in v1 (backup-code feature is backend-pending) - allowBackupCode: _allowBackupCode = false, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MfaTotpChallengeProps) { - // Deep merge: top-level copy + the errors map merged separately. - const merged: MfaTotpChallengeMessages = { - ...defaultMfaTotpChallengeMessages, - ...messageOverrides, - errors: { ...defaultMfaTotpChallengeMessages.errors, ...messageOverrides?.errors } - }; - - /** - * BACKEND-PENDING CASE (b): the generated hook does not exist yet. - * When `complete_mfa_challenge` is deployed and codegen regenerated: - * 1. Add: import { useCompleteMfaChallengeMutation } from '@/generated/auth'; - * 2. Instantiate: const defaultMutation = useCompleteMfaChallengeMutation({ selection: ... }); - * 3. Wire hybrid isPending: onSubmitOverride ? overridePending : defaultMutation.isPending - * 4. The `if (onSubmitOverride) setOverridePending(true/false)` guards in handleVerify - * MUST stay — they prevent double-pending when both the override and the default - * hook are present (matches gold-standard sign-in-card pattern). - */ - const [overridePending, setOverridePending] = useState(false); - // When the generated hook lands, this becomes: - // onSubmitOverride ? overridePending : defaultMutation.isPending - const isPending = overridePending; - - const [error, setError] = useState(null); - - async function runChallenge(vars: MfaTotpChallengeVars): Promise { - if (onSubmitOverride) { - return onSubmitOverride(vars); - } - // PROCEDURE_NOT_FOUND: complete_mfa_challenge is not yet deployed. - // The generated hook will be wired here once the procedure ships. - const procedureErr = Object.assign(new Error('complete_mfa_challenge is not yet deployed'), { - extensions: { code: 'PROCEDURE_NOT_FOUND' } - }); - throw procedureErr; - } - - async function handleVerify(values: TotpFormData) { - setError(null); - if (onSubmitOverride) setOverridePending(true); - try { - const vars: MfaTotpChallengeVars = { - totpValue: values.totpCode.replace(/[\s-]/g, ''), - trustDevice: values.trustDevice, - challengeToken, - mfaMethod, - credentialKind, - rememberMe: values.trustDevice - }; - const result = await runChallenge(vars); - onMessage?.({ kind: 'success', key: 'completeMfaChallenge.success', message: merged.successToast }); - onSuccess?.(result); - } catch (err) { - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - if (onSubmitOverride) setOverridePending(false); - } - } - - const form = useForm({ - defaultValues: { - totpCode: '', - trustDevice: false - } as TotpFormData, - onSubmit: async ({ value }) => { - await handleVerify(value); - } - }); - - return ( - - - {merged.title} - {merged.description} - - - - - -
    { - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - { - if (!value) return 'Authentication code is required'; - const digits = value.replace(/[\s-]/g, ''); - if (!/^\d{6}$/.test(digits)) return 'Enter a 6-digit code'; - return undefined; - } - }} - > - {(field) => { - const errors = field.state.meta.errors?.filter(Boolean) ?? []; - const hasError = errors.length > 0; - const errorMessage = errors[0] as string | undefined; - return ( - - field.handleChange(e.target.value.replace(/[\s-]/g, '').slice(0, 6))} - onBlur={field.handleBlur} - /> - - ); - }} - - - {showTrustDevice && ( - - {(field) => ( -
    -
    - field.handleChange(checked === true)} - /> - -
    -

    {merged.trustDeviceHint}

    -
    - )} -
    - )} - - - {merged.submitButton} - -
    -
    - - {/* allowBackupCode is locked false in v1 — the backup-code affordance is a - v1.1 feature gated on verify_backup_code procedure. The backupCodeLink - message key is in the catalog now to avoid a breaking change later. */} - {false && ( - - - - )} -
    - ); -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/auth-mfa-totp-disable-confirm.requires.json b/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/auth-mfa-totp-disable-confirm.requires.json deleted file mode 100644 index 900ff9c..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/auth-mfa-totp-disable-confirm.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["disableTotp"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/messages.ts b/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/messages.ts deleted file mode 100644 index 90b29b2..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/messages.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * mfa-totp-disable-confirm — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * PROCEDURE_NOT_FOUND is included because `disable_totp` is backend-pending - * (sdk-binding-contract.md §10, CASE b). When the proc is deployed, this code - * will be resolved at runtime by the generated hook; until then, hosts using the - * onSubmit override seam will not see it. - */ - -export type MfaTotpDisableConfirmMessages = { - title: string; - description: string; - warningText: string; - backupCodesWarning: string; - confirmButton: string; - cancelButton: string; - loadingLabel: string; - successMessage: string; - errors: { - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Deep-partial override type: consumers can override any top-level key and/or - * any individual error code without restating the full catalog. - */ -export type MfaTotpDisableConfirmMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultMfaTotpDisableConfirmMessages: MfaTotpDisableConfirmMessages = { - title: 'Disable two-factor authentication', - description: 'This will remove the extra layer of security from your account.', - warningText: 'Your account will be less secure without two-factor authentication.', - backupCodesWarning: 'All backup codes will also be invalidated.', - confirmButton: 'Disable two-factor authentication', - cancelButton: 'Keep enabled', - loadingLabel: 'Disabling...', - successMessage: 'Two-factor authentication disabled.', - errors: { - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/mfa-totp-disable-confirm.tsx b/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/mfa-totp-disable-confirm.tsx deleted file mode 100644 index 3feebfa..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-disable-confirm/mfa-totp-disable-confirm.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client'; - -/** - * mfa-totp-disable-confirm (registry: auth-mfa-totp-disable-confirm) - * - * Confirmation dialog for disabling TOTP. Requires high-severity step-up - * (`tier: 'high'`) before calling `disable_totp`. Shows prominent warnings about - * the security implications of removing two-factor authentication. - * - * BACKEND-PENDING (CASE b): `disable_totp` is NOT yet deployed to - * `constructive_auth_public`. The generated hook `useDisableTotpMutation` does - * not exist in the current SDK — importing it would fail tsc. Consequently: - * • The `@/generated/auth` import for that hook is OMITTED. - * • `onSubmit` is REQUIRED (no default mutation path). - * • When the backend deploys and codegen regenerates, the host replaces the - * `onSubmit` prop with the generated hook. - * • requires.json names `disableTotp` so `check-sdk-fixtures.ts` fails clearly. - * • messages.errors.PROCEDURE_NOT_FOUND is present for when the proc first lands. - * - * Binding doctrine: sdk-binding-contract.md §5–§7, MASTER-PROMPT §5. - * Step-up: step-up-contract.md §3 — `tier: 'high'` gates the confirm action. - * STEP_UP_CANCELLED → silent return (no error callbacks, no toast). - */ - -import { useState } from 'react'; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription -} from '@constructive-io/ui/dialog'; -import { Button } from '@constructive-io/ui/button'; - -import { cn } from '@/lib/utils'; -import { parseGraphQLError } from '@/blocks/lib/auth-errors'; -import { AuthErrorAlert } from '@/blocks/primitives/auth-error-alert'; -import { AuthLoadingButton } from '@/blocks/primitives/auth-loading-button'; -import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up'; - -import { - defaultMfaTotpDisableConfirmMessages, - type MfaTotpDisableConfirmMessages, - type MfaTotpDisableConfirmMessageOverrides -} from './messages'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type MfaTotpDisableConfirmProps = { - /** Controlled open state. */ - open: boolean; - /** Called when the dialog requests open-state change (close on cancel/X). */ - onOpenChange: (open: boolean) => void; - messages?: MfaTotpDisableConfirmMessageOverrides; - /** - * Adapter override — REQUIRED until `disable_totp` is deployed to the backend - * and codegen regenerates `useDisableTotpMutation`. After the proc ships, the - * host can wire the generated hook here: - * `onSubmit={async () => { await disableTotp.mutateAsync({}); }}` - * Until then, pass a mock or real implementation. - */ - onSubmit: () => Promise; - /** Fires after a successful disable. Always fires. */ - onSuccess?: () => void; - /** Fires after a mapped error. Always fires. */ - onError?: (err: { message: string; code: string }) => void; - /** Notification seam — fires for success and mapped errors. Always fires. */ - onMessage?: (event: { kind: 'success' | 'error' | 'info' | 'warning'; key: string; message?: string }) => void; - className?: string; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function MfaTotpDisableConfirm({ - open, - onOpenChange, - messages: messageOverrides, - onSubmit: onSubmitOverride, - onSuccess, - onError, - onMessage, - className -}: MfaTotpDisableConfirmProps) { - // Deep merge: top-level copy + the errors map merged separately. - const merged: MfaTotpDisableConfirmMessages = { - ...defaultMfaTotpDisableConfirmMessages, - ...messageOverrides, - errors: { ...defaultMfaTotpDisableConfirmMessages.errors, ...messageOverrides?.errors } - }; - - const stepUp = useStepUp(); - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(null); - - async function handleConfirm() { - setError(null); - try { - // Step-up must complete before the disable mutation fires. - // tier:'high' → MFA if enrolled (user has TOTP, so this is correct), else password. - await stepUp({ tier: 'high' }); - - // Proceed with the disable mutation via the override seam (BACKEND-PENDING). - setIsPending(true); - await onSubmitOverride(); - - onMessage?.({ kind: 'success', key: 'disableTotp.success', message: merged.successMessage }); - onSuccess?.(); - onOpenChange(false); - } catch (err) { - // STEP_UP_CANCELLED: user dismissed the step-up dialog — silent return. - if (err instanceof StepUpError && err.reason === 'cancelled') return; - - const { code, message } = parseGraphQLError(err, { - customMessages: merged.errors, - defaultMessage: merged.errors.UNKNOWN_ERROR - }); - const key = code ?? 'UNKNOWN_ERROR'; - setError(message); - onMessage?.({ kind: 'error', key, message }); - onError?.({ message, code: key }); - } finally { - setIsPending(false); - } - } - - function handleCancel() { - if (isPending) return; - setError(null); - onOpenChange(false); - } - - return ( - { if (!isOpen) handleCancel(); }}> - - - {merged.title} - {merged.description} - - -
    - {/* Prominent security warning — both texts visible simultaneously per spec */} -
    -

    {merged.warningText}

    -

    {merged.backupCodesWarning}

    -
    - - {/* Async error alert (aria-live="polite" is inside AuthErrorAlert) */} - -
    - - - {/* Cancel button receives initial focus (safer default for destructive action) */} - - - - {merged.confirmButton} - - -
    -
    - ); -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-enroll/auth-mfa-totp-enroll.requires.json b/apps/blocks/src/blocks/auth/mfa-totp-enroll/auth-mfa-totp-enroll.requires.json deleted file mode 100644 index ad5df1a..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-enroll/auth-mfa-totp-enroll.requires.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "auth", - "mutations": ["enableTotp", "confirmTotpSetup", "generateBackupCodes"], - "queries": [], - "models": [] -} diff --git a/apps/blocks/src/blocks/auth/mfa-totp-enroll/messages.ts b/apps/blocks/src/blocks/auth/mfa-totp-enroll/messages.ts deleted file mode 100644 index 9224e48..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-enroll/messages.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * mfa-totp-enroll — message catalog - * - * Canonical block-messages pattern (block-contract.md §4, §10): top-level - * camelCase keys are UI copy; the nested `errors` map is keyed by backend error - * CODE (UPPER_SNAKE_CASE) and is handed straight to `parseGraphQLError` as - * `customMessages`, so a host localizes any code by overriding a single key. - * - * PROCEDURE_NOT_FOUND is included because all three ops (enableTotp, - * confirmTotpSetup, generateBackupCodes) are backend-pending — they will surface - * this code at runtime until the procedures are deployed. - */ - -export type MfaTotpEnrollMessages = { - // Step 1: Setup - setupTitle: string; - setupDescription: string; - qrInstructions: string; - manualEntryLabel: string; - nextButton: string; - // Step 2: Verify - verifyTitle: string; - verifyDescription: string; - codeLabel: string; - codePlaceholder: string; - verifyButton: string; - verifyingButton: string; - backButton: string; - // Step 3: Backup codes (delegated to auth-mfa-backup-codes-display) - // Shared - errors: { - INVALID_TOTP: string; - RATE_LIMITED: string; - PROCEDURE_NOT_FOUND: string; - UNKNOWN_ERROR: string; - }; -}; - -/** - * Override type: top-level is shallow-partial; `errors` is itself partial so a - * host can localize a single error code without restating the map. - */ -export type MfaTotpEnrollMessageOverrides = Partial> & { - errors?: Partial; -}; - -export const defaultMfaTotpEnrollMessages: MfaTotpEnrollMessages = { - setupTitle: 'Set up two-factor authentication', - setupDescription: 'Scan the QR code with your authenticator app, then enter the code it shows.', - qrInstructions: 'Or enter this key manually into your authenticator app:', - manualEntryLabel: 'Manual entry key', - nextButton: 'Next', - verifyTitle: 'Verify your authenticator', - verifyDescription: 'Enter the 6-digit code from your authenticator app to confirm setup.', - codeLabel: 'Verification code', - codePlaceholder: '000000', - verifyButton: 'Verify and enable', - verifyingButton: 'Verifying…', - backButton: 'Back', - errors: { - INVALID_TOTP: 'Invalid code. Check your authenticator app and try again.', - RATE_LIMITED: 'Too many attempts. Please wait before trying again.', - PROCEDURE_NOT_FOUND: - 'This feature requires a backend update. See: https://constructive.io/docs/backend-spec/future-procedures', - UNKNOWN_ERROR: 'Something went wrong. Please try again.' - } -}; diff --git a/apps/blocks/src/blocks/auth/mfa-totp-enroll/mfa-totp-enroll.test.tsx b/apps/blocks/src/blocks/auth/mfa-totp-enroll/mfa-totp-enroll.test.tsx deleted file mode 100644 index 4bf7a0d..0000000 --- a/apps/blocks/src/blocks/auth/mfa-totp-enroll/mfa-totp-enroll.test.tsx +++ /dev/null @@ -1,454 +0,0 @@ -/** - * mfa-totp-enroll tests - * - * BACKEND-PENDING CASE (b): the three required hooks (useEnableTotpMutation, - * useConfirmTotpSetupMutation, useGenerateBackupCodesMutation) do NOT exist in - * the generated SDK yet — the procedures are undeployed. Therefore: - * • This block does NOT import from @/generated/auth (no hooks to mock). - * • Tests exercise the onSubmit/onConfirm/onGenerateCodes override path. - * • The "no adapters" path (graceful degradation) asserts PROCEDURE_NOT_FOUND. - * - * NOTE: vi.mock('@/generated/auth') is present as a no-op guard so that if a - * future developer accidentally adds a generated import, tests break clearly - * rather than silently hitting the real client. - */ - -import { Suspense } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -// No-op guard — this block currently imports nothing from @/generated/auth -// (CASE b), but the mock prevents any accidental real-client hit if that changes. -vi.mock('@/generated/auth', () => ({})); - -import { MfaTotpEnroll } from './mfa-totp-enroll'; -import { defaultMfaTotpEnrollMessages } from './messages'; -import { defaultMfaBackupCodesDisplayMessages } from '@/blocks/auth/mfa-backup-codes-display/messages'; - -// --------------------------------------------------------------------------- -// Shared adapter factories -// --------------------------------------------------------------------------- - -function makeSetupAdapter(overrides?: { qrUrl?: string; manualKey?: string }) { - return vi.fn().mockResolvedValue({ - qrUrl: overrides?.qrUrl ?? 'https://example.com/qr.png', - manualKey: overrides?.manualKey ?? 'ABCDEFGHIJKLMNOP' - }); -} - -function makeConfirmAdapter(result = true) { - return vi.fn().mockResolvedValue(result); -} - -function makeCodesAdapter(codes?: string[]) { - return vi.fn().mockResolvedValue(codes ?? ['abc-def-ghi', 'jkl-mno-pqr', 'stu-vwx-yz0']); -} - -function makeAdapters() { - return { - onSubmit: makeSetupAdapter(), - onConfirm: makeConfirmAdapter(), - onGenerateCodes: makeCodesAdapter() - }; -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('MfaTotpEnroll', () => { - it('renders setup step title on mount', async () => { - await act(async () => { - render(); - }); - expect(screen.getByText(defaultMfaTotpEnrollMessages.setupTitle)).toBeInTheDocument(); - }); - - it('calls onSubmit adapter on mount and shows QR image', async () => { - const onSubmit = makeSetupAdapter({ qrUrl: 'https://example.com/qr.png' }); - render(); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument()); - expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute('src', 'https://example.com/qr.png'); - }); - - it('finishes one-time setup with the latest committed success callback', async () => { - const setup = deferred<{ qrUrl: string; manualKey: string }>(); - const initialSubmit = vi.fn(() => setup.promise); - const replacementSubmit = vi.fn().mockResolvedValue({ - qrUrl: 'https://example.com/replacement.png', - manualKey: 'REPLACEMENTKEY' - }); - const initialOnMessage = vi.fn(); - const latestOnMessage = vi.fn(); - - const { rerender } = render( - - ); - await waitFor(() => expect(initialSubmit).toHaveBeenCalledTimes(1)); - - rerender( - - ); - await act(async () => { - setup.resolve({ qrUrl: 'https://example.com/qr-latest.png', manualKey: 'LATESTCOMMITTEDKEY' }); - }); - - expect(initialSubmit).toHaveBeenCalledTimes(1); - expect(replacementSubmit).not.toHaveBeenCalled(); - expect(initialOnMessage).not.toHaveBeenCalled(); - expect(latestOnMessage).toHaveBeenCalledWith({ kind: 'info', key: 'qr_ready' }); - expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute( - 'src', - 'https://example.com/qr-latest.png' - ); - }); - - it('maps a pending setup failure with the latest committed messages and callbacks', async () => { - const setup = deferred<{ qrUrl: string; manualKey: string }>(); - const onSubmit = vi.fn(() => setup.promise); - const initialOnError = vi.fn(); - const initialOnMessage = vi.fn(); - const latestOnError = vi.fn(); - const latestOnMessage = vi.fn(); - - const { rerender } = render( - - ); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - - rerender( - - ); - await act(async () => { - setup.reject(Object.assign(new Error('rate limited'), { extensions: { code: 'RATE_LIMITED' } })); - }); - - expect(initialOnError).not.toHaveBeenCalled(); - expect(initialOnMessage).not.toHaveBeenCalled(); - expect(latestOnError).toHaveBeenCalledWith({ - message: 'Latest committed setup error.', - code: 'RATE_LIMITED' - }); - expect(latestOnMessage).toHaveBeenCalledWith({ - kind: 'error', - key: 'RATE_LIMITED', - message: 'Latest committed setup error.' - }); - expect(screen.getByText('Latest committed setup error.')).toBeInTheDocument(); - }); - - it('does not leak callbacks from a suspended render into pending setup', async () => { - const setup = deferred<{ qrUrl: string; manualKey: string }>(); - const onSubmit = vi.fn(() => setup.promise); - const committedOnMessage = vi.fn(); - const abandonedOnMessage = vi.fn(); - const never = new Promise(() => {}); - - function SuspendAfterEnroll({ suspend }: { suspend: boolean }) { - if (suspend) throw never; - return null; - } - - function EnrollTree({ onMessage, suspend }: { onMessage: typeof committedOnMessage; suspend: boolean }) { - return ( - Suspended update
}> - - -
- ); - } - - const { rerender } = render(); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - - rerender(); - await act(async () => { - setup.resolve({ qrUrl: 'https://example.com/qr.png', manualKey: 'ABCDEFGHIJKLMNOP' }); - }); - - expect(committedOnMessage).toHaveBeenCalledWith({ kind: 'info', key: 'qr_ready' }); - expect(abandonedOnMessage).not.toHaveBeenCalled(); - - rerender(); - }); - - it('shows manual entry key formatted in groups of 4', async () => { - render(); - - await waitFor(() => expect(screen.getByText(/ABCD/)).toBeInTheDocument()); - // Key should be rendered as code element with groups-of-4 formatting - const codeEl = screen.getByText(/ABCD EFGH IJKL MNOP/); - expect(codeEl.tagName).toBe('CODE'); - }); - - it('advances to verify step when Next is clicked', async () => { - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(screen.getByTestId('setup-next')).toBeInTheDocument()); - await user.click(screen.getByTestId('setup-next')); - - expect(screen.getByText(defaultMfaTotpEnrollMessages.verifyTitle)).toBeInTheDocument(); - expect(screen.getByTestId('totp-code')).toBeInTheDocument(); - }); - - it('returns to setup step when Back is clicked from verify', async () => { - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(screen.getByTestId('setup-next')).toBeInTheDocument()); - await user.click(screen.getByTestId('setup-next')); - await user.click(screen.getByTestId('verify-back')); - - expect(screen.getByText(defaultMfaTotpEnrollMessages.setupTitle)).toBeInTheDocument(); - }); - - it('calls onConfirm with the entered code and advances to backup-codes', async () => { - const user = userEvent.setup(); - const adapters = makeAdapters(); - const onMessage = vi.fn(); - - render(); - - await waitFor(() => expect(screen.getByTestId('setup-next')).toBeInTheDocument()); - await user.click(screen.getByTestId('setup-next')); - - const codeInput = screen.getByTestId('totp-code'); - await user.type(codeInput, '123456'); - await user.click(screen.getByTestId('verify-submit')); - - await waitFor(() => expect(adapters.onConfirm).toHaveBeenCalledWith('123456')); - await waitFor(() => expect(adapters.onGenerateCodes).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(screen.getByText(defaultMfaBackupCodesDisplayMessages.title)).toBeInTheDocument() - ); - }); - - it('displays backup codes in the list', async () => { - const user = userEvent.setup(); - const codes = ['aaa-bbb-111', 'ccc-ddd-222']; - const adapters = { - onSubmit: makeSetupAdapter(), - onConfirm: makeConfirmAdapter(), - onGenerateCodes: makeCodesAdapter(codes) - }; - - render(); - - await waitFor(() => expect(screen.getByTestId('setup-next')).toBeInTheDocument()); - await user.click(screen.getByTestId('setup-next')); - await user.type(screen.getByTestId('totp-code'), '654321'); - await user.click(screen.getByTestId('verify-submit')); - - // Child block [[auth-mfa-backup-codes-display]] renders codes in a