diff --git a/.oxlintrc.json b/.oxlintrc.json index ad09650..169e13c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,20 +4,7 @@ "./node_modules/ultracite/config/oxlint/core/.oxlintrc.json", "./node_modules/ultracite/config/oxlint/react/.oxlintrc.json" ], - "overrides": [ - { - "files": [ - "src/index.ts", - "src/schemas/index.ts", - "src/schemas/base/index.ts", - "src/schemas/responses/index.ts", - "src/schemas/requests/index.ts", - "src/errors/index.ts", - "src/app-bridge/index.ts" - ], - "rules": { - "oxc/no-barrel-file": "off" - } - } - ] + "rules": { + "oxc/no-barrel-file": "off" + } } diff --git a/CLAUDE.md b/CLAUDE.md index 44a169c..aaac452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,16 @@ Pre-commit hook (lefthook) auto-runs `bun x ultracite fix` on staged files. After any significant code change, always run `bun run lint` and `bun run fix` to ensure lint and formatting pass before committing. +## Documentation Rules + +After any significant code change, update the following: + +1. **`docs/progress.md`** — mark completed features, update status +2. **`README.md`** — keep usage examples and API docs current +3. **Feature-specific docs** — if README.md grows too large, create docs under `docs/` (e.g. `docs/app-bridge.md`) and link from the main README + +Always keep docs in sync with the code. Do not defer documentation to a later step. + ## Architecture `assembly-kit` is a TypeScript-first SDK for the Assembly platform. It is an **ESM-only single package** with 4 entry points, targeting Node.js 18+, Node.js 24+, and Bun. @@ -33,7 +43,7 @@ After any significant code change, always run `bun run lint` and `bun run fix` t | `assembly-kit` | `createClient()`, error classes, token utilities, `paginate()` | | `assembly-kit/schemas` | All Zod schemas and inferred types (no client dependency) | | `assembly-kit/app-bridge` | Framework-agnostic `sendToParent()` postMessage utilities | -| `assembly-kit/react` | React hooks wrapping app-bridge (`usePrimaryCta`, `useSecondaryCta`, `useActionsMenu`) | +| `assembly-kit/bridge-ui` | React hooks wrapping app-bridge (`usePrimaryCta`, `useSecondaryCta`, `useActionsMenu`) | ### Source Layer Dependency Order @@ -46,7 +56,7 @@ src/pagination/ ← paginate() AsyncIterable cursor helper src/client/ ← createClient() factory + AssemblyClient class src/resources/ ← workspace, clients, companies, internalUsers, notifications, customFields, tasks, token src/app-bridge/ ← parallel track, no dependency on layers above -src/react/ ← depends on app-bridge only +src/bridge-ui/ ← depends on app-bridge only ``` ### Zod Version diff --git a/README.md b/README.md index 5e5b91d..2ff9b0c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,242 @@ try { | `AssemblyResponseParseError` | 500 | API response failed Zod schema validation (`.zodError`) | | `AssemblyConnectionError` | 503 | Network error reaching the API | +### Schemas + +All Zod schemas live under `assembly-kit/schemas`. Each resource has a **base** schema, a **response** schema (wrapping the base for paginated API responses), and optionally a **request** schema for create/update payloads. + +```typescript +// Base schemas — the core shape of each resource +import { + ClientSchema, + CompanySchema, + TaskSchema, + TaskStatusSchema, + WorkspaceSchema, + InternalUserSchema, + InvoiceSchema, + CustomFieldSchema, + TokenPayloadSchema, + HexColorSchema, +} from "assembly-kit/schemas"; + +// TypeScript types inferred from schemas +import type { + Client, + Company, + Task, + TaskStatus, + Workspace, + InternalUser, +} from "assembly-kit/schemas"; +``` + +#### Response schemas + +Response schemas wrap the base schemas into the paginated shape returned by the Assembly API: + +```typescript +import { + ClientsResponseSchema, + CompaniesResponseSchema, + TasksResponseSchema, +} from "assembly-kit/schemas"; + +import type { + ClientsResponse, + CompaniesResponse, + TasksResponse, +} from "assembly-kit/schemas"; +``` + +#### Request schemas + +Request schemas define the shape of create/update payloads: + +```typescript +import { + ClientCreateRequestSchema, + ClientUpdateRequestSchema, + CompanyCreateRequestSchema, + TaskCreateRequestSchema, +} from "assembly-kit/schemas"; + +import type { + ClientCreateRequest, + ClientUpdateRequest, +} from "assembly-kit/schemas"; +``` + +#### Validating data + +```typescript +import { ClientSchema } from "assembly-kit/schemas"; + +const result = ClientSchema.safeParse(unknownData); + +if (result.success) { + console.log(result.data.name); +} else { + console.error(result.error); +} +``` + +#### Sub-path imports + +You can also import from specific schema groups to reduce bundle size: + +```typescript +import { ClientSchema } from "assembly-kit/schemas/base"; +import { ClientsResponseSchema } from "assembly-kit/schemas/responses"; +import { ClientCreateRequestSchema } from "assembly-kit/schemas/requests"; +``` + +### App Bridge + +The app-bridge entry point provides framework-agnostic utilities for communicating with the Assembly dashboard from an embedded iframe app. Works in any JavaScript environment — no React dependency required. + +#### `sendToParent` + +Sends a typed postMessage payload to the Assembly dashboard parent frame: + +```typescript +import { sendToParent, Icons } from "assembly-kit/app-bridge"; +import type { PrimaryCtaPayload } from "assembly-kit/app-bridge"; + +// Register a primary CTA button in the dashboard header +const payload: PrimaryCtaPayload = { + type: "header.primaryCta", + label: "Create Invoice", + icon: Icons.Plus, + onClick: "header.primaryCta.onClick", +}; + +sendToParent(payload); +``` + +When called without a `portalUrl`, it fans out the message to all known Assembly dashboard domains. Pass a specific origin to restrict: + +```typescript +sendToParent(payload, "https://dashboard.assembly.com"); +``` + +`sendToParent` is SSR-safe — it's a no-op when `window` is undefined. + +#### Payload types + +```typescript +import type { + PrimaryCtaPayload, // { type: "header.primaryCta", label?, icon?, onClick? } + SecondaryCtaPayload, // { type: "header.secondaryCta", label?, icon?, onClick? } + ActionsMenuPayload, // { type: "header.actionsMenu", items: ActionItem[] } + AppBridgePayload, // Discriminated union of all three + ActionItem, // { label, onClick, icon?, color? } + CtaConfig, // { label?, icon?, onClick?(), color? } + BridgeOpts, // { portalUrl?, show? } +} from "assembly-kit/app-bridge"; +``` + +#### Clearing a slot + +Send a payload with only the `type` field to remove a button, or an empty items array for the actions menu: + +```typescript +sendToParent({ type: "header.primaryCta" }); +sendToParent({ type: "header.actionsMenu", items: [] }); +``` + +### Bridge UI (React Hooks) + +React hooks that wrap `sendToParent` into a declarative API. They handle setup, cleanup, and `beforeunload` automatically. + +Requires `react >= 18` as a peer dependency. + +#### `usePrimaryCta` + +Registers a primary CTA button in the dashboard header: + +```tsx +import { usePrimaryCta } from "assembly-kit/bridge-ui"; +import { Icons } from "assembly-kit/app-bridge"; + +function MyApp() { + usePrimaryCta({ + label: "Create Invoice", + icon: Icons.Plus, + onClick: () => { + console.log("Primary CTA clicked"); + }, + }); + + return
My App
; +} +``` + +#### `useSecondaryCta` + +Registers a secondary CTA button. Same API as `usePrimaryCta`: + +```tsx +import { useSecondaryCta } from "assembly-kit/bridge-ui"; +import { Icons } from "assembly-kit/app-bridge"; + +function MyApp() { + useSecondaryCta({ + label: "Export", + icon: Icons.Download, + onClick: () => { + console.log("Secondary CTA clicked"); + }, + }); + + return
My App
; +} +``` + +#### `useActionsMenu` + +Registers a dropdown actions menu in the dashboard header: + +```tsx +import { useActionsMenu } from "assembly-kit/bridge-ui"; +import { Icons } from "assembly-kit/app-bridge"; + +function MyApp() { + useActionsMenu([ + { label: "Archive", onClick: "actions.archive", icon: Icons.Archive }, + { + label: "Delete", + onClick: "actions.delete", + icon: Icons.Trash, + color: "red", + }, + ]); + + return
My App
; +} +``` + +#### Visibility toggle + +All hooks accept an optional second argument to control visibility: + +```tsx +usePrimaryCta({ label: "Save", onClick: () => save() }, { show: hasChanges }); +``` + +When `show` is `false`, the slot is cleared in the dashboard header. Defaults to `true`. + +#### Portal URL + +If your app is embedded in a custom portal, pass the portal origin to restrict postMessage targeting: + +```tsx +usePrimaryCta( + { label: "Save", onClick: () => save() }, + { portalUrl: "https://my-portal.example.com" } +); +``` + ## Development ```bash diff --git a/bun.lock b/bun.lock index a265569..4161886 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/react": "^19.2.14", "bumpp": "^10.4.1", "bunup": "^0.16.31", "lefthook": "^2.1.1", @@ -20,9 +21,11 @@ "ultracite": "7.2.4", }, "peerDependencies": { + "react": "", "typescript": ">=4.5.0", }, "optionalPeers": [ + "react", "typescript", ], }, @@ -260,6 +263,8 @@ "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], "args-tokenizer": ["args-tokenizer@0.3.0", "", {}, "sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q=="], @@ -296,6 +301,8 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], diff --git a/bunup.config.ts b/bunup.config.ts index fe17263..05a3a33 100644 --- a/bunup.config.ts +++ b/bunup.config.ts @@ -10,6 +10,7 @@ const bunupConfig = defineConfig({ "src/schemas/responses/index.ts", "src/schemas/requests/index.ts", "src/errors/index.ts", + "src/bridge-ui/index.ts", ], format: "esm", minify: true, diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index a3d22a0..eb20966 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -39,7 +39,7 @@ This is a feature-by-feature implementation plan. Each feature is a self-contain 5. Create `.ultracite.json` or equivalent formatter config. 6. Create top-level `src/`, `tests/`, and `tests/fixtures/` directories. -> **Runtime compatibility rule (applies to all features):** Always import crypto as `node:crypto` (explicit protocol prefix). Bun and Node.js 18/24 both honour the `node:` prefix. Never use bare `'crypto'`. Do not use any Node.js APIs that Bun does not implement — this SDK only needs `node:crypto` (AES-128-CBC + HMAC-SHA256), which Bun supports natively. 7. Create `src/index.ts`, `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/react/index.ts` as empty stubs. 8. Generate token test fixtures: using the `@assembly-js/node-sdk` crypto functions, generate 3-4 encrypted token strings (client user, internal user, with tokenId, with baseUrl) for a fixed test apiKey. Save to `tests/fixtures/tokens.ts` as constants. These are used by all token-related tests without mocking crypto. 8. Verify: `bun build src/index.ts --outdir dist` succeeds. 9. Verify: `bun test` runs (zero tests, zero failures). +> **Runtime compatibility rule (applies to all features):** Always import crypto as `node:crypto` (explicit protocol prefix). Bun and Node.js 18/24 both honour the `node:` prefix. Never use bare `'crypto'`. Do not use any Node.js APIs that Bun does not implement — this SDK only needs `node:crypto` (AES-128-CBC + HMAC-SHA256), which Bun supports natively. 7. Create `src/index.ts`, `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/bridge-ui/index.ts` as empty stubs. 8. Generate token test fixtures: using the `@assembly-js/node-sdk` crypto functions, generate 3-4 encrypted token strings (client user, internal user, with tokenId, with baseUrl) for a fixed test apiKey. Save to `tests/fixtures/tokens.ts` as constants. These are used by all token-related tests without mocking crypto. 8. Verify: `bun build src/index.ts --outdir dist` succeeds. 9. Verify: `bun test` runs (zero tests, zero failures). **Definition of Done:** @@ -707,7 +707,7 @@ React hooks wrapping the app-bridge core. Shipped as a separate entry point so R ### Files to create ``` -src/react/ +src/bridge-ui/ ├── index.ts ├── use-primary-cta.ts ├── use-secondary-cta.ts @@ -718,7 +718,7 @@ src/react/ **8.1 — `usePrimaryCta`** -`src/react/use-primary-cta.ts`: +`src/bridge-ui/use-primary-cta.ts`: - Accept `(cta: CtaConfig, opts?: BridgeOpts)`. - `useEffect` (deps: `cta`, `opts.portalUrl`, `opts.show`): @@ -741,7 +741,7 @@ Similar: sends `header.actionsMenu` with items array. No click listener needed ( **8.4 — Barrel export** -`src/react/index.ts` exports all hooks. +`src/bridge-ui/index.ts` exports all hooks. ### Tests @@ -777,9 +777,9 @@ Final build config, export map, and type declarations. "import": "./dist/app-bridge/index.js", "types": "./dist/app-bridge/index.d.ts" }, - "./react": { - "import": "./dist/react/index.js", - "types": "./dist/react/index.d.ts" + "./bridge-ui": { + "import": "./dist/bridge-ui/index.js", + "types": "./dist/bridge-ui/index.d.ts" } }, "sideEffects": false @@ -882,6 +882,6 @@ The overall project is ready for `1.0.0` publish when: | `@assembly-js/node-sdk` token decode is async / hits Assembly API | Feature 3 must verify this. If it requires a network call, `parseToken()` becomes async and all callers update. | | `@assembly-js/node-sdk` not suitable as a dependency (license / size) | Reimplement token decode using Assembly's documented JWT format. | | `p-throttle` sliding window behavior differs from Assembly's exact rate limit model | Run load tests against staging; fall back to `bottleneck` with a larger `minTime` only if needed. | -| React hooks export causes issues with RSC (React Server Components) | Add `"use client"` directive at the top of `src/react/index.ts`. | +| React hooks export causes issues with RSC (React Server Components) | Add `"use client"` directive at the top of `src/bridge-ui/index.ts`. | | Zod version conflicts in consuming apps | Pin `zod` as a peer dependency; document compatibility. | | Assembly API response shapes change without notice | `validateResponses` flag gives teams an escape hatch; `AssemblyResponseParseError` is easy to catch. | diff --git a/docs/planning/prd.md b/docs/planning/prd.md index aabba3a..0be4b67 100644 --- a/docs/planning/prd.md +++ b/docs/planning/prd.md @@ -57,12 +57,12 @@ assembly-kit ### 3.2 Entry Points (Export Map) -| Export path | Purpose | -| ------------------------- | --------------------------------------------------- | -| `assembly-kit` | Core client, token utilities, error classes | -| `assembly-kit/schemas` | All Zod schemas and inferred TypeScript types | -| `assembly-kit/app-bridge` | Framework-agnostic postMessage utilities | -| `assembly-kit/react` | React-specific app-bridge hooks (peer dep: `react`) | +| Export path | Purpose | +| ------------------------- | --------------------------------------------- | +| `assembly-kit` | Core client, token utilities, error classes | +| `assembly-kit/schemas` | All Zod schemas and inferred TypeScript types | +| `assembly-kit/app-bridge` | Framework-agnostic postMessage utilities | +| `assembly-kit/bridge-ui` | React app-bridge hooks (peer dep: `react`) | --- @@ -371,7 +371,7 @@ React-specific wrappers over the app-bridge core, replacing the scattered `usePr **Requirements** -Entry point: `assembly-kit/react` +Entry point: `assembly-kit/bridge-ui` Peer dependency: `react >= 18` - `usePrimaryCta(cta: CtaConfig, opts?: BridgeOpts): void` @@ -425,7 +425,10 @@ Peer dependency: `react >= 18` "import": "./dist/app-bridge.js", "types": "./dist/app-bridge.d.ts" }, - "./react": { "import": "./dist/react.js", "types": "./dist/react.d.ts" } + "./bridge-ui": { + "import": "./dist/bridge-ui.js", + "types": "./dist/bridge-ui.d.ts" + } } } ``` @@ -623,6 +626,6 @@ React component mounts | 1 | Does `@assembly-js/node-sdk` expose token decoding without making a network call? | **Resolved** — fully local, synchronous AES-128-CBC + HMAC-SHA256 using Node.js `crypto`. No network call. See `node-sdk-internals.md`. | | 2 | Should token decoding be reimplemented or delegated to `@assembly-js/node-sdk`? | **Resolved** — reimplement. The algorithm is simple (4 lines of Node.js `crypto`), the package uses yarn/CJS internals, and we should avoid the singleton `OpenAPI` object entirely. Full details in `node-sdk-internals.md`. | | 3 | What is the exact format of the `Retry-After` header from Assembly's 429 responses? | Needs verification against API docs | -| 4 | Should `assembly-kit/react` be a separate npm package (`assembly-kit-react`) to avoid shipping React as a dep for pure server users? | Decision needed | +| 4 | Should `assembly-kit/bridge-ui` be a separate npm package to avoid shipping React as a dep for pure server users? | Decision needed | | 5 | Tasks endpoint (`/tasks/public`) uses a custom token encoding — should `encodePayload` from crypto utils be part of assembly-kit or remain app-specific? | Decision needed | | 6 | For custom apps with no token provided, only local/staging envs are supported by the existing SDK. Does Assembly's production API accept a raw `apiKey` without a compound key at all? | Needs verification | diff --git a/docs/progress.md b/docs/progress.md index 21a5418..58e3050 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -11,10 +11,10 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started - ✅ Repo & tooling bootstrap (bunup, tsconfig, lefthook, oxlint, ultracite) - ✅ `src/index.ts` — exports all error classes - ✅ `src` path alias (`@/*`) configured in tsconfig.json -- ✅ `bunup.config.ts` — 4 entry points (index, schemas, app-bridge, react) +- ✅ `bunup.config.ts` — 4 entry points (index, schemas, app-bridge, bridge-ui) - ✅ Runtime deps: `zod` installed - ⬜ Runtime deps: `ky`, `p-throttle` (needed for Feature 4) -- ✅ Entry point stubs: `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/react/index.ts` +- ✅ Entry point stubs: `src/schemas/index.ts`, `src/app-bridge/index.ts`, `src/bridge-ui/index.ts` - ⬜ Test fixtures: `test/fixtures/tokens.ts` (encrypted token constants for token tests) --- @@ -206,12 +206,12 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started > Dependency: Feature 7 · Peer dep: `react >= 18` -- ⬜ `src/react/use-primary-cta.ts` -- ⬜ `src/react/use-secondary-cta.ts` -- ⬜ `src/react/use-actions-menu.ts` -- ⬜ `src/react/index.ts` — barrel export with `"use client"` directive -- ⬜ TypeScript compile-time check that hooks accept correct types -- ⬜ `bun run type-check` passes +- ✅ `src/bridge-ui/use-primary-cta.ts` +- ✅ `src/bridge-ui/use-secondary-cta.ts` +- ✅ `src/bridge-ui/use-actions-menu.ts` +- ✅ `src/bridge-ui/index.ts` — barrel export with `"use client"` directive +- ✅ TypeScript compile-time check that hooks accept correct types +- ✅ `bun run type-check` passes --- diff --git a/package.json b/package.json index 2953ea9..4f5b090 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,12 @@ "default": "./dist/schemas/requests/index.js" } }, + "./bridge-ui": { + "import": { + "types": "./dist/bridge-ui/index.d.ts", + "default": "./dist/bridge-ui/index.js" + } + }, "./package.json": "./package.json" }, "scripts": { @@ -74,6 +80,7 @@ }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/react": "^19.2.14", "bumpp": "^10.4.1", "bunup": "^0.16.31", "lefthook": "^2.1.1", @@ -84,9 +91,13 @@ "ultracite": "7.2.4" }, "peerDependencies": { + "react": ">=18", "typescript": ">=4.5.0" }, "peerDependenciesMeta": { + "react": { + "optional": true + }, "typescript": { "optional": true } diff --git a/src/app-bridge/ensure-https.ts b/src/app-bridge/ensure-https.ts new file mode 100644 index 0000000..4769914 --- /dev/null +++ b/src/app-bridge/ensure-https.ts @@ -0,0 +1,7 @@ +/** Ensures a URL uses HTTPS, upgrading HTTP if necessary. */ +export const ensureHttps = (url: string): string => { + if (url.startsWith("https://")) { + return url; + } + return `https://${url.replace(/^http:\/\//, "")}`; +}; diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index 78645d7..2a94f22 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -1,4 +1,6 @@ export { DASHBOARD_DOMAINS } from "src/app-bridge/constants"; +export { ensureHttps } from "src/app-bridge/ensure-https"; +export { isAllowedOrigin } from "src/app-bridge/is-allowed-origin"; export { sendToParent } from "src/app-bridge/send"; export { Icons } from "src/app-bridge/types"; export type { diff --git a/src/app-bridge/is-allowed-origin.ts b/src/app-bridge/is-allowed-origin.ts new file mode 100644 index 0000000..b8791e4 --- /dev/null +++ b/src/app-bridge/is-allowed-origin.ts @@ -0,0 +1,23 @@ +import { DASHBOARD_DOMAINS } from "src/app-bridge/constants"; +import { ensureHttps } from "src/app-bridge/ensure-https"; + +/** + * Checks whether a postMessage event origin is from an allowed Assembly domain. + * + * When `portalUrl` is provided, only that exact origin (after HTTPS upgrade) is + * allowed. Otherwise, the origin must match one of the known `DASHBOARD_DOMAINS`. + * + * @param {string} origin - The origin from the MessageEvent. + * @param {string} [portalUrl] - Optional portal URL to restrict allowed origins. + * @returns {boolean} Whether the origin is trusted. + */ +export const isAllowedOrigin = ( + origin: string, + portalUrl?: string +): boolean => { + if (portalUrl) { + return origin === ensureHttps(portalUrl); + } + + return (DASHBOARD_DOMAINS as readonly string[]).includes(origin); +}; diff --git a/src/app-bridge/send.ts b/src/app-bridge/send.ts index dca26d4..dcd6013 100644 --- a/src/app-bridge/send.ts +++ b/src/app-bridge/send.ts @@ -1,14 +1,7 @@ import { DASHBOARD_DOMAINS } from "src/app-bridge/constants"; +import { ensureHttps } from "src/app-bridge/ensure-https"; import type { AppBridgePayload } from "src/app-bridge/types"; -/** Ensures a URL uses HTTPS, upgrading HTTP if necessary. */ -const ensureHttps = (url: string): string => { - if (url.startsWith("https://")) { - return url; - } - return `https://${url.replace(/^http:\/\//, "")}`; -}; - /** * Sends a postMessage payload to the Assembly dashboard parent frame. * diff --git a/src/bridge-ui/index.ts b/src/bridge-ui/index.ts new file mode 100644 index 0000000..e9d7bc1 --- /dev/null +++ b/src/bridge-ui/index.ts @@ -0,0 +1,5 @@ +"use client"; + +export { useActionsMenu } from "./use-actions-menu"; +export { usePrimaryCta } from "./use-primary-cta"; +export { useSecondaryCta } from "./use-secondary-cta"; diff --git a/src/bridge-ui/use-actions-menu.ts b/src/bridge-ui/use-actions-menu.ts new file mode 100644 index 0000000..04d1b52 --- /dev/null +++ b/src/bridge-ui/use-actions-menu.ts @@ -0,0 +1,47 @@ +import { useEffect } from "react"; +import { sendToParent } from "src/app-bridge/send"; +import type { + ActionItem, + ActionsMenuPayload, + BridgeOpts, +} from "src/app-bridge/types"; + +/** + * Registers an actions menu in the Assembly dashboard header. + * + * Sends a `header.actionsMenu` postMessage to the parent frame with the + * provided menu items. Each item's `onClick` is a string event type identifier + * that the dashboard uses internally to handle clicks — the hook does not + * listen for inbound click events. When the component unmounts or the page + * unloads, the menu is automatically cleared. + * + * @param {ActionItem[]} items - Array of menu items with labels, icons, and click event types. + * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle. + */ +export const useActionsMenu = ( + items: ActionItem[], + opts?: BridgeOpts +): void => { + const { portalUrl, show = true } = opts ?? {}; + + useEffect(() => { + const payload: ActionsMenuPayload = { + items: show ? items : [], + type: "header.actionsMenu", + }; + + sendToParent(payload, portalUrl); + }, [items, portalUrl, show]); + + useEffect(() => { + const handleUnload = (): void => { + sendToParent({ items: [], type: "header.actionsMenu" }, portalUrl); + }; + + addEventListener("beforeunload", handleUnload); + + return () => { + removeEventListener("beforeunload", handleUnload); + }; + }, [portalUrl]); +}; diff --git a/src/bridge-ui/use-primary-cta.ts b/src/bridge-ui/use-primary-cta.ts new file mode 100644 index 0000000..1c917bb --- /dev/null +++ b/src/bridge-ui/use-primary-cta.ts @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import { isAllowedOrigin } from "src/app-bridge/is-allowed-origin"; +import { sendToParent } from "src/app-bridge/send"; +import type { + BridgeOpts, + CtaConfig, + PrimaryCtaPayload, +} from "src/app-bridge/types"; + +/** + * Registers a primary CTA button in the Assembly dashboard header. + * + * Sends a `header.primaryCta` postMessage to the parent frame and listens + * for click events. When the component unmounts or the page unloads, the + * slot is automatically cleared. + * + * @param {CtaConfig} cta - Label, icon, and click handler for the primary CTA. + * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle. + */ +export const usePrimaryCta = (cta: CtaConfig, opts?: BridgeOpts): void => { + const { portalUrl, show = true } = opts ?? {}; + + useEffect(() => { + const payload: PrimaryCtaPayload = { + icon: show ? cta.icon : undefined, + label: show ? cta.label : undefined, + onClick: show ? "header.primaryCta.onClick" : undefined, + type: "header.primaryCta", + }; + + sendToParent(payload, portalUrl); + + const handleMessage = (event: MessageEvent): void => { + if ( + isAllowedOrigin(event.origin, portalUrl) && + event.data.type === "header.primaryCta.onClick" && + typeof event.data.id === "string" && + cta.onClick + ) { + cta.onClick(); + } + }; + + addEventListener("message", handleMessage); + + return () => { + removeEventListener("message", handleMessage); + }; + }, [cta, portalUrl, show]); + + useEffect(() => { + const handleUnload = (): void => { + sendToParent({ type: "header.primaryCta" }, portalUrl); + }; + + addEventListener("beforeunload", handleUnload); + + return () => { + removeEventListener("beforeunload", handleUnload); + }; + }, [portalUrl]); +}; diff --git a/src/bridge-ui/use-secondary-cta.ts b/src/bridge-ui/use-secondary-cta.ts new file mode 100644 index 0000000..634e1f1 --- /dev/null +++ b/src/bridge-ui/use-secondary-cta.ts @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import { isAllowedOrigin } from "src/app-bridge/is-allowed-origin"; +import { sendToParent } from "src/app-bridge/send"; +import type { + BridgeOpts, + CtaConfig, + SecondaryCtaPayload, +} from "src/app-bridge/types"; + +/** + * Registers a secondary CTA button in the Assembly dashboard header. + * + * Sends a `header.secondaryCta` postMessage to the parent frame and listens + * for click events. When the component unmounts or the page unloads, the + * slot is automatically cleared. + * + * @param {CtaConfig} cta - Label, icon, and click handler for the secondary CTA. + * @param {BridgeOpts} [opts] - Optional portal URL and visibility toggle. + */ +export const useSecondaryCta = (cta: CtaConfig, opts?: BridgeOpts): void => { + const { portalUrl, show = true } = opts ?? {}; + + useEffect(() => { + const payload: SecondaryCtaPayload = { + icon: show ? cta.icon : undefined, + label: show ? cta.label : undefined, + onClick: show ? "header.secondaryCta.onClick" : undefined, + type: "header.secondaryCta", + }; + + sendToParent(payload, portalUrl); + + const handleMessage = (event: MessageEvent): void => { + if ( + isAllowedOrigin(event.origin, portalUrl) && + event.data.type === "header.secondaryCta.onClick" && + typeof event.data.id === "string" && + cta.onClick + ) { + cta.onClick(); + } + }; + + addEventListener("message", handleMessage); + + return () => { + removeEventListener("message", handleMessage); + }; + }, [cta, portalUrl, show]); + + useEffect(() => { + const handleUnload = (): void => { + sendToParent({ type: "header.secondaryCta" }, portalUrl); + }; + + addEventListener("beforeunload", handleUnload); + + return () => { + removeEventListener("beforeunload", handleUnload); + }; + }, [portalUrl]); +};