# Build a One-Click Connect List with Solis

In this tutorial, you build a list of integrations, each with a **Connect** button, using [Solis](https://prismatic.io/docs/embed/headless.md), Prismatic's custom UI SDK. One click sets up an integration from start to finish:

1. Your app creates an instance of the integration for the customer.
2. The customer authorizes their third-party account on the provider's OAuth consent screen.
3. The integration's `init` function suggests a configuration.
4. Your app saves that configuration, linked to the new credential, and deploys the instance.

When `init` can't fill in a required setting, such as which Slack channel to post to, your app asks for only that setting in a dialog before it deploys.

![A list of integrations grouped by category, each marked Connected, with Settings and Disconnect buttons](/docs/assets/images/one-click-connect-e02b584db73a01335b03db72214eb5d5.png)

The code comes from the **One-Click Connect** page, [`headless-one-click-list.tsx`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/routes/examples/headless-one-click-list.tsx), of Prismatic's [example embedded app](https://github.com/prismatic-io/embedded/tree/main/examples/example-embedded-app). You use two integrations from the app's repository, which show the two cases the list handles:

| Integration         | Needs                         | What **Connect** does                                                |
| ------------------- | ----------------------------- | -------------------------------------------------------------------- |
| HubSpot Contacts    | A HubSpot connection          | Deploys as soon as the customer authorizes HubSpot                   |
| Slack Notifications | A Slack connection, a channel | Asks for a channel after the customer authorizes Slack, then deploys |

## Before you begin[​](#before-you-begin "Direct link to Before you begin")

You need:

* A Prismatic organization with the headless configuration feature enabled. Contact your Prismatic representative to enable it.
* An admin account in that organization
* A customer in your organization to sign in as
* A HubSpot OAuth app and a Slack app, for the client ID and secret of each connection
* The [Prism CLI](https://prismatic.io/docs/cli.md) 10.6.0 or later, logged in with `prism login`
* [Bun](https://bun.sh) and [Node.js](https://nodejs.org) 22.19.0 or later
* The example embedded app, configured and running. Follow the **Run the example** step of [Build a custom marketplace with Solis](https://prismatic.io/docs/get-started/embedded-marketplace/custom-marketplace.md) to set it up.

One-click connect works with integrations that use [headless configuration](https://prismatic.io/docs/integrations/headless-configuration.md). Integrations that use config pages need Prismatic's config wizard, so the list leaves them out.

Create the customer-activated connections

Each integration authorizes the customer's account through a [customer-activated connection](https://prismatic.io/docs/integrations/connections/integration-agnostic-connections/customer-activated.md). Your organization defines the connection once, with your OAuth app, and each customer authorizes their own account. A customer who authorizes Slack for one integration can reuse that credential for another.

In Prismatic, create a customer-activated connection for each integration. The stable key must match the key the integration declares:

| Stable key      | Connection                     | OAuth scopes                                                                 |
| --------------- | ------------------------------ | ---------------------------------------------------------------------------- |
| `hubspot-oauth` | HubSpot's OAuth 2.0 connection | `oauth`, `crm.objects.contacts.read`, `crm.objects.contacts.write`           |
| `slack-oauth`   | Slack's OAuth 2.0 connection   | Bot scopes `channels:read`, `groups:read`, `chat:write`, `chat:write.public` |

Import the integrations

The integrations are in the example app's [`integrations/hubspot/`](https://github.com/prismatic-io/embedded/tree/main/examples/example-embedded-app/integrations/hubspot) and [`integrations/slack/`](https://github.com/prismatic-io/embedded/tree/main/examples/example-embedded-app/integrations/slack) directories. Import each one:

```bash
cd integrations/hubspot   # then the same in integrations/slack
bun install
bun run import

```

Then publish each integration and make it available and deployable in your marketplace. Or use Prism, with the integration ID that `bun run import` printed:

```bash
prism integrations:publish <integration-id>
prism integrations:marketplace <version-id> --available --deployable

```

Try one-click connect

In the example app, select **One-Click Connect** in the sidebar. The page lists the headless integrations in your marketplace, grouped by category.

1. Click **Connect** next to **HubSpot Contacts**. A window opens HubSpot's consent screen. After you authorize HubSpot, the row shows **Connected**.
2. Click **Connect** next to **Slack Notifications**, and authorize Slack. A **Finish connecting Slack Notifications** dialog lists your workspace's channels. Choose a channel and click **Connect**.

To try the flows, send a request to an instance's webhook URL. HubSpot takes an Acme contact, such as `{ "email": "ada@example.com", "firstName": "Ada" }`, and Slack takes a message, such as `{ "text": "Hello from Acme" }`.

The rest of this tutorial explains how the page and the integrations work together.

Suggest a configuration from init

Whether an integration connects in one click depends on what its `init` function returns. Open [`integrations/hubspot/src/configuration.ts`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/integrations/hubspot/src/configuration.ts). HubSpot Contacts needs nothing from the customer but a connection, so its schema has no fields, and `init` returns an empty configuration:

integrations/hubspot/src/configuration.ts (simplified)

```typescript
export const integrationConfiguration = configuration({
  instance: {
    schema: z.object({}),
    version: CONFIGURATION_VERSION,
    connections: {
      hubspot: customerActivatedConnection({ stableKey: "hubspot-oauth" }),
    },
  },
  init: {
    perform: async () => ({ configuration: {} }),
  },
});

```

Slack Notifications needs a channel, which has no sensible default. Its `init`, in [`integrations/slack/src/configuration.ts`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/integrations/slack/src/configuration.ts), returns the saved channel when there is one, and otherwise leaves the required `channelId` empty with a reason the app can show:

integrations/slack/src/configuration.ts (excerpt)

```typescript
export const configurationSchema = z.object({
  channelId: z.string().min(1).meta({ title: "Channel" }),
  channelName: z.string().optional().meta({ title: "Channel name" }),
});

// ...

init: {
  perform: async (context) => {
    if (context.configurationVersion === CONFIGURATION_VERSION) {
      const saved = configurationSchema.safeParse(context.configuration);
      if (saved.success) {
        return { configuration: saved.data, unresolvedReasons: [] };
      }
    }
    return {
      configuration: { channelId: "" },
      unresolvedReasons: ["Choose the Slack channel Acme posts to."],
    };
  },
},

```

To connect more of your integrations in one click, give their `init` functions defaults for every required field they can fill in. Slack also defines a `listChannels` [server function](https://prismatic.io/docs/integrations/headless-configuration/server-functions.md), which the app calls to list channels in the dialog.

List headless integrations

Open [`frontend/routes/examples/headless-one-click-list.tsx`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/routes/examples/headless-one-click-list.tsx). The list reads the marketplace with `useMarketplace()` and keeps only integrations whose `configurationExperience` is `headless`:

frontend/routes/examples/headless-one-click-list.tsx (excerpt)

```tsx
const marketplace = useMarketplace({ includeActiveIntegrations: true });
if (marketplace.status !== "success") return null;

const headless = marketplace.data.items.flatMap((item) =>
  item.status === "success" && item.data.configurationExperience === "headless"
    ? [{ integration: item.data, actions: item.actions }]
    : [],
);

```

The page then groups the integrations by `category` and renders one row per integration. Each row reads the customer's instance from `integration.instances`, and the instance's `lifecycle` decides whether the row shows **Connect** or **Connected** with **Settings** and **Disconnect** buttons.

Open the consent window inside the click

Browsers allow a click to open a window only before the click handler's first `await`. A connection's `createConnection` action opens the OAuth consent window as it starts, so you normally call it directly from a click handler. For an integration the customer hasn't set up, though, the connection requirement belongs to an instance that doesn't exist yet, and creating it takes a round trip.

So the click opens a blank window right away, and the app passes Solis a `host` with an `openExternalUrl` capability. When Solis asks the host to open the consent screen, the capability sends the blank window there. The code is in [`frontend/components/headless/consent-window.ts`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/components/headless/consent-window.ts):

frontend/components/headless/consent-window\.ts (excerpt)

```typescript
import type { HostApi } from "@prismatic-io/solis-react";

/** The window the next consent screen opens in. */
let pending: Window | null = null;

export const preopenedConsentHost: HostApi = {
  openExternalUrl(url) {
    const popup = pending;
    pending = null;
    if (popup && !popup.closed) {
      popup.location.href = url;
      return true;
    }
    // Nothing was opened for this consent screen, so the browser may block this.
    return window.open(url, "_blank") !== null;
  },
};

```

Pass the host to the provider. Define it at module level, because a new `host` object restarts the Solis session:

```tsx
<PrismaticProvider
  prismaticUrl={prismaticUrl}
  auth={{ token }}
  host={preopenedConsentHost}
>
  <IntegrationList />
</PrismaticProvider>

```

The same file's `openConsentWindow()` opens the blank window and returns `use()`, which marks it as the window for the next consent screen, and `close()`.

Create the instance and authorize the account

When the customer clicks **Connect**, the row opens the consent window, then creates an instance if the customer doesn't have one:

frontend/routes/examples/headless-one-click-list.tsx (excerpt)

```tsx
const connect = async () => {
  // Open the window now, while the browser still counts this as the click.
  const consent = openConsentWindow();
  if (!consent) return fail(POPUP_BLOCKED);
  const controller = new AbortController();
  if (!instanceId) {
    const created = await createInstance.execute({ name });
    if (created.status === "error") {
      consent.close();
      return fail(created.error.message);
    }
    setCreatedId(created.data.id);
  }
  setAttempt({ consent, controller });
};

```

`useConfiguration({ instanceId })` loads the new instance's configuration. Its `connections` resource lists the connections the instance needs in `data.instance`. Once those requirements load, the row hands the window to Solis with `consent.use()` and calls `createConnection` on the requirement, which runs the OAuth flow:

frontend/routes/examples/headless-one-click-list.tsx (excerpt)

```tsx
const [requirement] = requirements.instance;
consent.use();
const created = await requirement.actions.createConnection.execute({
  signal: controller.signal,
});
consent.close();
if (created.status === "error") {
  if (created.error.code === "PRISMATIC_CONNECT_ABORTED") return;
  return fail(created.error.message);
}
await configureAndDeploy([{ key: requirement.key, id: created.data.id }]);

```

The `signal` lets the row's **Cancel** button abort a pending authorization. The example sets up integrations with a single connection in one click; an integration that needs more than one shows an error instead.

Save the suggested configuration and deploy

With the new credential selected, the row runs `init`, checks its suggestion against the integration's schema, then saves and deploys:

frontend/routes/examples/headless-one-click-list.tsx (excerpt)

```tsx
const configureAndDeploy = async (
  chosen: ConfigurationConnectionSelection[],
) => {
  const initialized = await configuration.actions.init.execute({});
  if (initialized.status === "error") return fail(initialized.error.message);
  const proposal = readProposal(initialized.data);

  // Required fields init left empty need the customer, such as Slack's channel.
  if (
    unpopulatedFields(configuration.data.schema, proposal.configuration).length
  ) {
    setDialog({ kind: "finish", proposal });
    return;
  }

  const saved = await configuration.actions.save.execute({
    value: proposal.configuration,
    connections: chosen,
  });
  if (saved.status === "error") return fail(saved.error.message);

  const deployed = await instance.actions.deploy.execute();
  if (deployed.status === "error") return fail(deployed.error.message);
};

```

* `init` suggests values and saves nothing, so it's safe to run before the customer has chosen anything. Prismatic doesn't validate the suggestion.
* `unpopulatedFields()`, in [`frontend/components/headless/configuration-editor.tsx`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/components/headless/configuration-editor.tsx), reads the schema's `required` fields and returns the ones the suggestion leaves empty.
* `save({ value, connections })` validates the values against the schema and links the instance to the customer's credential. An instance can't deploy until it's linked to the connections it declares.
* `deploy` makes the instance run with the saved values.

Linking connections on save requires a Prismatic stack that supports it. The example checks `session.data.hasFeature("configuration.connections")`, where `session` comes from `usePrismatic()`, before it passes `connections` to `save`.

When `init` returns `connections` requirements of its own, pass the matching selections to `init` too. The example's integrations declare connections only on the instance, so their `init` needs none.

Ask for the remaining settings

For Slack, `unpopulatedFields()` returns `channelId`, so the row opens a dialog with the app's [Slack form](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/components/headless/slack-configuration-form.tsx) instead of saving. The form lists channels with the `listChannels` server function, using the credential the customer just authorized, and shows `init`'s `unresolvedReasons` above the field:

Call the listChannels server function with the new credential

```tsx
const listChannels = useServerFunction<
  Record<string, never>,
  { id: string; name: string; isPrivate: boolean }[]
>(configuration, "listChannels");

const result = await listChannels.execute({ inputs: {}, connections });

```

The example's Slack form wraps this call in a small [`useServerFunctionQuery`](https://github.com/prismatic-io/embedded/blob/main/examples/example-embedded-app/frontend/hooks/use-server-function-query.ts) hook, which reruns it when its inputs change. When the customer clicks **Connect** in the dialog, the form saves the chosen channel with the same connections and deploys, as in the previous step. After the instance is deployed, the row's **Settings** button opens the same form to change the channel, and **Disconnect** calls the instance's `remove` action.

See [Calling server functions](https://prismatic.io/docs/embed/headless/react.md#calling-server-functions) for more on server functions in your app.

## What you learned[​](#what-you-learned "Direct link to What you learned")

* An integration connects in one click when its `init` function can suggest every required setting.
* Solis's `createConnection` runs the OAuth flow, and a `host` with `openExternalUrl` lets one click both create an instance and open the consent window.
* `save({ value, connections })` links the instance to the customer's credential, and `deploy` makes it live.
* When `init` leaves required fields empty, your app asks for only those fields.

## Next steps[​](#next-steps "Direct link to Next steps")

* [Build a custom marketplace with Solis](https://prismatic.io/docs/get-started/embedded-marketplace/custom-marketplace.md) to list every integration, including those with config pages
* [Connections in headless](https://prismatic.io/docs/integrations/headless-configuration/connections.md)
* [Building configuration UIs with Solis React](https://prismatic.io/docs/embed/headless/react.md)
* [Customer-activated connections](https://prismatic.io/docs/integrations/connections/integration-agnostic-connections/customer-activated.md)
