Skip to content
Tier

Integration connect

Pro

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). A connected integration ignores hold. 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; when focus is elsewhere, on the page or nowhere, it stays and the hold is announced.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0 and 2.9.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/integration-connect

Brings in: bidi, button, heading-level, heading-scope, input, locale, locked, relative-time, tokens, use-announcer, use-focus-within, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"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>

PropTypeDefaultDescription
namerequiredstring—The integration's name, e.g. "Calendar".
bystring | undefined—Who makes or runs it, as your app knows it.
staterequired"connected" | "expired" | "not-connected"—Whether it's connected now.
accountstring | undefined—Who or what it's connected as, when connected: an account, a workspace, a bot user.
connectedAtstring | undefined—When it was connected (ISO).
scopesIntegrationScope[] | undefined—The access it asks for. Leave out when the provider hasn't said; `[]` when it asks for none.
grantedstring[] | 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.
reachesstring[] | 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.
notReachesstring[] | 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`.
requireTestboolean | undefinedfalseConnecting 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.
disconnectIntegrationDisconnect | 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`.
busyboolean | undefinedfalseThe app is connecting or disconnecting: the buttons say so and wait.
errorstring | undefined—Why the last attempt failed, in the app's words.
holdIntegrationHold | 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). Once the integration is connected the hold is not shown, whatever is passed: a reconnect that waits on the other side is not drawn here, so clear `hold` when `state` becomes "connected".
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.
classNamestring | undefined—Extra classes for the outer element.
headingLevelHeadingLevel | 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

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

KeyEnglish
headingConnect {name}
headingConnected{name}
byMade by {by}
connectedConnected
connectedAsConnected as {account}
connectedSincesince {when}
expiredThe connection expired. Connect again to keep it working.
notConnectedNot connected
oauthConnect with {provider}
oauthHintYou sign in at {provider} and come back here. Your password stays with {provider}.
oror use an API key
keyAPI key
keyHintThe key is sent to test and to connect, then cleared from this page.
keyHintNoTestThe key is sent once, to connect, then cleared from this page.
scopesWhat it asks for
scopesGrantedWhat it was granted
scopesUnknownThe integration hasn’t said what access it asks for.
noScopesIt asks for no access beyond connecting.
neededNeeded
neededNoteConnecting grants the access marked Needed.
optionalNoteThe rest is granted only if you tick it.
writesCan change things
reachesWhat it can reach
reachesUnknownWhat it can reach hasn’t been described.
notReachesWhat it can’t reach
testTest connection
testingTesting…
testPassedConnection works
testFailedConnection failed
testNotRunCouldn’t run the test
testNotRunWhyThe test gave no result, so the connection is unchecked.
checkedAtchecked {when}
staleThe key changed since this test.
connectConnect
connectingConnecting…
connectFailedConnecting failed.
needsKeyEnter the API key first.
needsTestTest the connection first.
needsPassingTestThe last test failed. Check the key and test again.
cantTestThis connection has to pass a test first, and there’s no way to run one here. Ask whoever set it up.
busyWait for the current step to finish.
waitingConnectConnect request sent. Waiting for the app to show the connection.
waitingDisconnectDisconnect request sent. Waiting for the app to show it.
disconnectDisconnect
disconnectTitleDisconnect {name}?
stopsWhat stops
staysWhat stays
reconnectTo connect again:
keepKeep connected
confirmDisconnectDisconnect
disconnectingDisconnecting…
disconnectFailedDisconnecting failed. It’s still connected.
waitingTitleWaiting for approval on their side
waitingNoteNothing connects until they approve the app.
blockedTitleBlocked by their policy
blockedNoteNothing connects while their policy blocks it.
approveWhoWho has to approve
approveWhereWhere
blockedWhatWhat’s blocked
unblockerWho can unblock it
askedAtAsked {when}
holdCheckedAtLast checked {when}
checkAgainCheck again
checkingChecking…
waitCheckWait for the check to finish.
checkFailedChecking failed. Try again in a moment.
backBack 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.
{ 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.
{ /** 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.
| { 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.
{ method: "api-key"; key: string; scopes: string[] }
IntegrationDisconnect
What disconnecting stops and what stays, in the app's words.
{ /** 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 from @/lib/candor/heading-level
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.
1 | 2 | 3 | 4 | 5 | 6