# Integration connect

Connect an outside service to the app: through the provider's own sign-in (OAuth) or with an API key, with the access it asks for in plain words, a connection test and what it found, and what the integration can and can't reach. Access the integration needs is listed as Needed; optional access is granted only when the person ticks it, and nothing starts ticked. A connection test that couldn't run says so, not that the connection works. Once connected it shows who it's connected as and since when; Disconnect asks first, saying what stops and what stays.

When the other side has to act first, `hold` says so in place of the ways of connecting: an admin there must approve the app (who, and where they do it, with Check again), or their policy blocks it (what is blocked, and who can unblock it). Each has a way back (`onBack`). When a hold comes or goes while the person is working here, focus moves to the hold's heading, or back to the card's.

- Registry name: `integration-connect` (`@candor/integration-connect`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/integration-connect

## Install

```bash
npx candor-ui add integration-connect
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/integration-connect`.
It brings in: bidi, button, heading-level, heading-scope, input, locale, locked, relative-time, tokens, use-announcer, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { IntegrationConnect } from "@/components/candor/integration-connect"

const scopes = [
  { id: "events.read", label: "Read events on calendars shared with the app", required: true },
  { id: "events.write", label: "Create and move events", description: "Agents can book a meeting once you’ve approved it.", writes: true },
  { id: "freebusy.read", label: "See when people are free", description: "Free and busy times only, not what the events are." },
]

export default function Example() {
  const [state, setState] = React.useState<"connected" | "not-connected">("not-connected")
  const [granted, setGranted] = React.useState<string[]>([])
  const [connectedAt, setConnectedAt] = React.useState<string>()
  return (
    <IntegrationConnect
      className="max-w-lg"
      name="Calendar"
      by="Example Calendar Inc."
      state={state}
      account={state === "connected" ? "ops-bot@example.com" : undefined}
      connectedAt={state === "connected" ? connectedAt : undefined}
      scopes={scopes}
      granted={granted}
      reaches={["Events on calendars shared with the app", "Who is invited to them"]}
      notReaches={["Private events", "Calendars nobody shared", "Your email"]}
      oauth={{ provider: "Example ID", onStart: () => {} }}
      apiKey={{ placeholder: "cal_live_…" }}
      onTest={async (key) => {
        await new Promise((r) => setTimeout(r, 500))
        return key === undefined || key.startsWith("cal_")
          ? { ok: true, checkedAt: new Date().toISOString(), detail: "Signed in as ops-bot@example.com; 2 calendars shared." }
          : { ok: false, checkedAt: new Date().toISOString(), reason: "The key was refused. Keys start with cal_." }
      }}
      requireTest
      onConnect={async ({ scopes: ticked }) => {
        await new Promise((r) => setTimeout(r, 400))
        setGranted(ticked)
        setConnectedAt(new Date().toISOString())
        setState("connected")
      }}
      disconnect={{
        stops: ["New and moved events stop syncing", "Agents can no longer book meetings"],
        stays: ["Events already synced stay in the app until you delete them"],
        reconnect: "Connect again from this page; the access is asked for again.",
      }}
      onDisconnect={async () => {
        await new Promise((r) => setTimeout(r, 400))
        setState("not-connected")
      }}
    />
  )
}
```

## <IntegrationConnect>

Props:

- `name: string`: The integration's name, e.g. "Calendar".
- `by?: string | undefined`: Who makes or runs it, as your app knows it.
- `state: "connected" | "expired" | "not-connected"`: Whether it's connected now.
- `account?: string | undefined`: Who or what it's connected as, when connected: an account, a workspace, a bot user.
- `connectedAt?: string | undefined`: When it was connected (ISO).
- `scopes?: IntegrationScope[] | undefined`: The access it asks for. Leave out when the provider hasn't said; `[]` when it asks for none.
- `granted?: string[] | undefined`: Once connected, the ids of the optional scopes it holds; the Needed ones are always shown. Leave out to show every scope as asked for.
- `reaches?: string[] | undefined`: What it can reach in the app and in the service, in your words: "Events on calendars you share with the app". Leave out when it hasn't been described.
- `notReaches?: string[] | undefined`: What it can't reach, in your words: "Private events", "Other people’s calendars".
- `oauth?: { provider: string; onStart: (scopes: string[]) => void } | undefined`: Shows a button to connect through the provider's own sign-in; `onStart` is called with the optional scopes ticked.
- `apiKey?: { label?: string | undefined; hint?: string | undefined; placeholder?: string | undefined } | undefined`: Shows an API key field. `label`, `hint` and `placeholder` override the defaults. The key is cleared from the field once `onConnect` resolves.
- `onTest?: ((key?: string) => Promise<IntegrationTestResult>) | undefined`: Shows Test connection. Before connecting it's called with the key entered; once connected, with none. Resolve with what the server found; if it throws or rejects, the card says the test couldn't run, with the error's message as the reason.
- `onConnect?: ((connection: IntegrationConnection) => void | Promise<void>) | undefined`: Called when the person connects with a key. The button waits on a returned promise; once it resolves, Connect stays locked until you pass a new `state` or `error`.
- `requireTest?: boolean | undefined` (default `false`): Connecting with a key stays locked until a test with the current key passed. Without `onTest` no test can run, so it stays locked and says so.
- `disconnect?: IntegrationDisconnect | undefined`: What disconnecting stops and what stays. Needed for Disconnect to show.
- `onDisconnect?: (() => void | Promise<void>) | undefined`: Shows Disconnect once connected, asking first. The confirm button waits on a returned promise; if it throws or rejects, the card says it's still connected. Once it resolves, Disconnect stays locked until you pass a new `state` or `error`.
- `busy?: boolean | undefined` (default `false`): The app is connecting or disconnecting: the buttons say so and wait.
- `error?: string | undefined`: Why the last attempt failed, in the app's words.
- `hold?: IntegrationHold | undefined`: The other side has to act first: an admin there must approve the app, or their policy blocks it. While it is set and the integration isn't connected, a panel saying who has to act, and where or what, stands in place of the ways of connecting (sign-in and API key).
- `onCheckAgain?: (() => void | Promise<void>) | undefined`: Shows Check again while `hold` is waiting; the button waits on a returned promise. Update `hold` with what the check found.
- `onBack?: (() => void) | undefined`: Shows the way back from a hold, to the ways of connecting: clear `hold` here. Pass it whenever you pass `hold`, or the person has no way back.
- `className?: string | undefined`
- `headingLevel?: HeadingLevel | undefined`: The level of the panel's heading, so it fits under the page's own headings. Default: one below the nearest `HeadingScope`, else 2 (`<h2>`). Its sub-headings sit one level below it.

## Strings (`integrationConnect`)

The fixed words, English by default. Override any of them under `integrationConnect` in `<LocaleProvider strings={{ integrationConnect: { … } }}>`; the table is exported as `integrationConnectStrings`.

- `heading`: Connect {name}
- `headingConnected`: {name}
- `by`: Made by {by}
- `connected`: Connected
- `connectedAs`: Connected as {account}
- `connectedSince`: since {when}
- `expired`: The connection expired. Connect again to keep it working.
- `notConnected`: Not connected
- `oauth`: Connect with {provider}
- `oauthHint`: You sign in at {provider} and come back here. Your password stays with {provider}.
- `or`: or use an API key
- `key`: API key
- `keyHint`: The key is sent to test and to connect, then cleared from this page.
- `keyHintNoTest`: The key is sent once, to connect, then cleared from this page.
- `scopes`: What it asks for
- `scopesGranted`: What it was granted
- `scopesUnknown`: The integration hasn’t said what access it asks for.
- `noScopes`: It asks for no access beyond connecting.
- `needed`: Needed
- `neededNote`: Connecting grants the access marked Needed.
- `optionalNote`: The rest is granted only if you tick it.
- `writes`: Can change things
- `reaches`: What it can reach
- `reachesUnknown`: What it can reach hasn’t been described.
- `notReaches`: What it can’t reach
- `test`: Test connection
- `testing`: Testing…
- `testPassed`: Connection works
- `testFailed`: Connection failed
- `testNotRun`: Couldn’t run the test
- `testNotRunWhy`: The test gave no result, so the connection is unchecked.
- `checkedAt`: checked {when}
- `stale`: The key changed since this test.
- `connect`: Connect
- `connecting`: Connecting…
- `connectFailed`: Connecting failed.
- `needsKey`: Enter the API key first.
- `needsTest`: Test the connection first.
- `needsPassingTest`: The last test failed. Check the key and test again.
- `cantTest`: This connection has to pass a test first, and there’s no way to run one here. Ask whoever set it up.
- `busy`: Wait for the current step to finish.
- `waitingConnect`: Connect request sent. Waiting for the app to show the connection.
- `waitingDisconnect`: Disconnect request sent. Waiting for the app to show it.
- `disconnect`: Disconnect
- `disconnectTitle`: Disconnect {name}?
- `stops`: What stops
- `stays`: What stays
- `reconnect`: To connect again:
- `keep`: Keep connected
- `confirmDisconnect`: Disconnect
- `disconnecting`: Disconnecting…
- `disconnectFailed`: Disconnecting failed. It’s still connected.
- `waitingTitle`: Waiting for approval on their side
- `waitingNote`: Nothing connects until they approve the app.
- `blockedTitle`: Blocked by their policy
- `blockedNote`: Nothing connects while their policy blocks it.
- `approveWho`: Who has to approve
- `approveWhere`: Where
- `blockedWhat`: What’s blocked
- `unblocker`: Who can unblock it
- `askedAt`: Asked {when}
- `holdCheckedAt`: Last checked {when}
- `checkAgain`: Check again
- `checking`: Checking…
- `waitCheck`: Wait for the check to finish.
- `checkFailed`: Checking failed. Try again in a moment.
- `back`: Back to connecting

## Types

### IntegrationHold

The other system has to act before the connection can finish, as the app's server reports it: an admin there must approve the app (`waiting`), or their policy blocks it (`blocked`). Every string is the app's words, or the other system's, and is drawn as text.

```ts
{
  kind: "waiting";
  /** Who on the other side has to approve the app, e.g. "An admin of the Shop account". */
  who: string;
  /** Where they approve it, e.g. "Settings › Connected apps". */
  where: string;
  /** When the app asked, as an ISO time. Left out, no time is shown. */
  askedAt?: string | undefined;
  /** When the app last checked whether they approved, as an ISO time. Left out, no time is shown. */
  checkedAt?: string | undefined;
} | {
  kind: "blocked";
  /** What their policy blocks, e.g. "Apps from outside the company signing in with OAuth". */
  what: string;
  /** Who can unblock it, e.g. "Their security team". */
  who: string;
}
```

### IntegrationScope

One piece of access the integration asks for (an OAuth scope or the like), in plain words.

```ts
{
  /** The scope as the provider names it ("calendar.read"); what the connect callbacks send back. */
  id: string;
  /** What it allows, in plain words: "Read your calendar’s events". Shown in place of the id. */
  label: string;
  /** More about it, when a sentence helps. */
  description?: string | undefined;
  /** Connecting can’t work without it, so it isn’t a choice: it’s listed as Needed. Without this, the person ticks it to grant it; nothing starts ticked. */
  required?: boolean | undefined;
  /** It lets the integration change or delete things, not only read. Marked so the person sees it. */
  writes?: boolean | undefined;
}
```

### IntegrationTestResult

What a connection test found, as the app's server reports it.

```ts
| { ok: true; checkedAt: string; detail?: string | undefined }
  | { ok: false; checkedAt: string; reason: string }
```

### IntegrationConnection

A connection made with a key: the key entered and the ids of the optional scopes the person ticked.

```ts
{ method: "api-key"; key: string; scopes: string[] }
```

### IntegrationDisconnect

What disconnecting stops and what stays, in the app's words.

```ts
{
  /** What stops once it's disconnected: "New events stop syncing", "Agents can no longer create issues". */
  stops: string[];
  /** What stays: "Events already synced stay in the app". */
  stays?: string[] | undefined;
  /** How to connect again, when it isn't simply this card. */
  reconnect?: string | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### HeadingLevel

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
