import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ConnectionName, IntegrationSlug, OAuthClientSlug, ProviderItemId, ProviderKey, identityPathTier, rankResponseSample, type HealthCheckCandidate, type HealthCheckResult, type HealthCheckSpec, type OAuthClientSummary, type Owner, } from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; import { toast } from "sonner"; import { addConnectionOptimistic, checkConnectionHealth, connectionsAllAtom, createOAuthClientOptimistic, integrationHealthCheckAtom, integrationHealthCheckCandidatesAtom, oauthClientsOptimisticAtom, probeOAuth, providerItemsAtom, providersAtom, registerDynamicOAuthClient, removeOAuthClientOptimistic, setIntegrationHealthCheck, startOAuth, updateConnection, validateConnection, } from "../api/atoms"; import { connectionCheckKeys, connectionWriteKeys, healthCheckWriteKeys, oauthClientWriteKeys, } from "../api/reactivity-keys"; import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { messageFromExit } from "../api/error-reporting"; import { trackEvent } from "../api/analytics"; import { useOrganizationId } from "../api/organization-context"; import { ownerLabel, ownerLabelForHost, useOwnerDisplay } from "../api/owner-display"; import { ConnectionOwnerDropdown, connectionOwnerOptionsForHost, defaultConnectionOwnerForHost, normalizeConnectionOwner, resolveOAuthConnectionOwnerForHost, type ConnectionOwnerOption, } from "../plugins/connection-owner"; import { oauthCallbackUrl, oauthClientIdMetadataDocumentUrl, useOAuthPopupFlow, type OAuthCompletionPayload, type OAuthPopupReservation, } from "../plugins/oauth-sign-in"; import { clientDisplayName, clientHost, optimisticDcrClientSlug, selectDcrClientsForIntegration, uniqueClientSlug, useOAuthClientsForIntegration, type OAuthClientOption, } from "../plugins/use-effective-oauth-client"; import { cn } from "../lib/utils"; import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage"; import { OAuthClientForm, registrationScopes, type OAuthClientFormPrefill, } from "./oauth-client-form"; import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; import { connectionIdentifier } from "../lib/connection-name"; import { Badge } from "./badge"; import { Button } from "./button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./dropdown-menu"; import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./dialog"; import { Input } from "./input"; import { Label } from "./label"; import { RadioGroup, RadioGroupItem } from "./radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; // --------------------------------------------------------------------------- // Add-account modal — the connection-create form. // // Field order: (1) display name · (2) authentication method · (3) credential // · (4) saved-to owner. A connection is immutable once created. Step 2 collects // one value per distinct input the method declares — usually one, but a // multi-input method (e.g. Datadog's two keys) shows one field per variable. // // OAuth: step 2 lists the registered apps usable for this integration and lets // you PICK one (or "Register a new app"). While registering, name/saved-to are // hidden (they don't apply yet). Once an app is selected, the footer's "Connect // with OAuth" mints the connection with the name + saved-to. The CLIENT owner // (whose app) is distinct from the CONNECTION's saved-to owner. // --------------------------------------------------------------------------- const ONEPASSWORD_PROVIDER = ProviderKey.make("onepassword"); type CredentialOrigin = "paste" | "onepassword"; type CredentialInput = { readonly variable: string; readonly label: string }; type CredentialPayloadOrigin = | { readonly values: Record } | { readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId; }; }; export function createCredentialPayloadOrigin(args: { readonly origin: CredentialOrigin; readonly inputs: readonly CredentialInput[]; readonly values: Readonly>; readonly onePasswordItemId: string; readonly singleInput: boolean; }): CredentialPayloadOrigin | null { if (args.inputs.length === 0) return { values: { token: "" } }; if (args.origin === "onepassword") { const id = args.onePasswordItemId.trim(); if (!args.singleInput || id.length === 0) return null; return { from: { provider: ONEPASSWORD_PROVIDER, id: ProviderItemId.make(id) }, }; } const values = Object.fromEntries( args.inputs.map((input) => [input.variable, (args.values[input.variable] ?? "").trim()]), ); return Object.values(values).every((value) => value.length > 0) ? { values } : null; } const numberBadge = (n: number) => ( {n} ); function isOnePasswordRegistered( providers: AsyncResult.AsyncResult, ) { return AsyncResult.match(providers, { onInitial: () => false, onFailure: () => false, onSuccess: ({ value }) => value.some((provider: ProviderKey) => String(provider) === String(ONEPASSWORD_PROVIDER)), }); } /** Constant-label reveal control (aria-pressed carries the state, per the * screen-reader guidance against relabeling toggles). Masked keys make typos * invisible; every credential field pairs with one of these. */ function RevealKeyButton(props: { readonly revealed: boolean; readonly onToggle: () => void }) { return ( ); } function PasteCredentialInputs(props: { readonly inputs: readonly CredentialInput[]; readonly singleInput: boolean; /** Force the per-input label even for a single input (env vars name their * field rather than relying on a generic "value / token" placeholder). */ readonly showLabels?: boolean; /** Single-input only: the placement's lead + prefix (e.g. "Authorization: * Bearer "), rendered as a non-editable affix INSIDE the field so the input * reads as the header value being built. Replaces the separate preview line. */ readonly affix?: string | null; readonly values: Readonly>; readonly onChange: (values: Record) => void; }) { // One reveal state for the whole group: a user checking their paste wants // to see all of it, not toggle per field. const [revealed, setRevealed] = useState(false); const inputType = revealed ? "text" : "password"; if (!props.singleInput) { return (
{props.inputs.map((input) => (
) => props.onChange({ ...props.values, [input.variable]: e.target.value, }) } className="h-9 font-mono text-sm" data-ph-block /> setRevealed((v) => !v)} />
))}
); } // Past the multi-input grid above, singleInput is always true, so labelled // reduces to showLabels: env vars opt in to naming their single field. const labelled = props.showLabels ?? false; return (
{props.inputs.map((input) => (
{labelled && ( )} {props.affix ? ( // Merged field: the placement's lead + prefix is a FIXED addon (its // own muted segment, divider, non-selectable), and the user types // only the token after it — the field reads as the header value being // built. Only the border reacts to focus (no full-field glow). The // autoComplete/ignore attrs stop the browser and password managers // from offering to GENERATE a password here; the app's own 1Password // picker covers filling an existing secret.
{/* min-w-0 + inner ellipsis: a method saved with a very long prefix must truncate inside the field, not stretch it. */} {props.affix} {/* oxlint-disable-next-line react/forbid-elements */} ) => props.onChange({ ...props.values, [input.variable]: e.target.value }) } className="min-w-0 flex-1 bg-transparent px-3 outline-none" data-ph-block /> setRevealed((v) => !v)} />
) : (
) => props.onChange({ ...props.values, [input.variable]: e.target.value, }) } className="font-mono" data-ph-block /> setRevealed((v) => !v)} />
)}
))}
); } function OnePasswordItemSelect(props: { readonly value: string; readonly onChange: (value: string) => void; }) { const itemsResult = useAtomValue(providerItemsAtom(ONEPASSWORD_PROVIDER)); const state = AsyncResult.matchWithError( itemsResult as AsyncResult.AsyncResult< readonly { readonly id: ProviderItemId; readonly name: string }[], Error >, { onInitial: () => ({ items: [] as readonly { readonly id: ProviderItemId; readonly name: string; }[], loading: true, error: null as string | null, }), onError: () => ({ items: [] as readonly { readonly id: ProviderItemId; readonly name: string; }[], loading: false, error: "Failed to load 1Password items", }), onDefect: () => ({ items: [] as readonly { readonly id: ProviderItemId; readonly name: string; }[], loading: false, error: "Failed to load 1Password items", }), onSuccess: ({ value }) => ({ items: value, loading: false, error: null }), }, ); if (state.loading) { return

Loading 1Password items…

; } if (state.error) { return

{state.error}

; } if (state.items.length === 0) { return

No 1Password items found.

; } return (
); } function CredentialValueFields(props: { readonly inputs: readonly CredentialInput[]; readonly singleInput: boolean; readonly showLabels?: boolean; /** Single-input only: placement lead + prefix to merge into the field. */ readonly affix?: string | null; /** Allow an external provider (1Password) origin. Off for env methods: the * wire's external `from` is keyed under the canonical `token` variable, but * an env credential must be keyed under its env var name, so a 1Password * origin would persist the secret under the wrong key and the subprocess * would launch without it. Paste stays correctly keyed per variable. */ readonly allowExternalProvider?: boolean; readonly values: Readonly>; readonly onValuesChange: (values: Record) => void; readonly origin: CredentialOrigin; readonly onOriginChange: (origin: CredentialOrigin) => void; readonly onePasswordItemId: string; readonly onOnePasswordItemIdChange: (value: string) => void; }) { const providers = useAtomValue(providersAtom); const onePasswordAvailable = props.singleInput && (props.allowExternalProvider ?? true) && isOnePasswordRegistered(providers); return (
{onePasswordAvailable ? ( props.onOriginChange(value as CredentialOrigin)} className="grid grid-cols-2 gap-2" > ) : null} {onePasswordAvailable && props.origin === "onepassword" ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // Step header — the label introducing each section of the form. Four style // variants are kept for design review; flip STEP_HEADER_VARIANT to preview each // one in isolation. The numbered-circle treatment implied a sequential wizard // the form isn't (every section shows at once), so the alternatives drop the // numbers. // - "numbered": numbered circle + label + inline hint (current). // - "eyebrow": uppercase micro-caps, hint on its own line. Matches the app's // existing section headers (e.g. AccountsSection). // - "sentence": plain form label in foreground weight, hint inline. // - "accent": a short leading rule for rhythm without implied sequence. // --------------------------------------------------------------------------- type StepHeaderVariant = "numbered" | "eyebrow" | "sentence" | "accent"; /** Swap this to preview each step-header style. */ const STEP_HEADER_VARIANT: StepHeaderVariant = "eyebrow"; function StepHeader(props: { readonly index: number; readonly label: string; readonly hint?: string; readonly htmlFor?: string; }) { const { index, label, hint, htmlFor } = props; const variants: Record = { numbered: ( ), eyebrow: (
{hint ? {hint} : null}
), sentence: ( ), accent: ( ), }; return variants[STEP_HEADER_VARIANT]; } /** Derive the connection's display label from the user's free-text name (or a * default of " "). With an empty `label` this yields the * derived name shown as the name input's placeholder, so the optional-but- * prefilled intent is visible. */ export const connectionLabel = (label: string, owner: Owner, integrationName: string): string => label.trim() || `${ownerLabel(owner)} ${integrationName}`; export const connectionLabelForHost = ( label: string, owner: Owner, integrationName: string, organizationId: string | null, ): string => label.trim() || `${ownerLabelForHost(owner, organizationId)} ${integrationName}`; /** The default owner a new connection is saved under when the user makes no * explicit choice. Personal: a connection is most often a personal credential. */ export const DEFAULT_CONNECTION_OWNER: Owner = "user"; const authMethodKey = (method: AuthMethod): string => method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; /** The selectable methods: the declared catalog methods plus any custom method * created in this session, deduped by stable method identity (custom appended * last). A just-created method shows + can be selected before the catalog * refresh lands. */ export const mergeCustomMethods = ( declared: readonly AuthMethod[], created: readonly AuthMethod[], ): readonly AuthMethod[] => { const keys = new Set(declared.map(authMethodKey)); return [...declared, ...created.filter((method: AuthMethod) => !keys.has(authMethodKey(method)))]; }; /** Derive a stable JS-identifier-safe callable connection name from the label. */ export const connectionNameFrom = ( label: string, owner: Owner, integrationName: string, organizationId: string | null, ): ConnectionName => connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId)); /** Uniquify a derived callable name against the owner's existing connections * for the integration: a fresh OAuth connect mints a NEW connection, and two * untyped connects both derive `personalGmail`; without a suffix the second * silently replaces the first account's token and label. */ export const uniqueConnectionName = ( base: ConnectionName, takenNames: ReadonlySet, ): ConnectionName => { if (!takenNames.has(String(base))) return base; let suffix = 2; while (takenNames.has(`${String(base)}${suffix}`)) suffix++; return ConnectionName.make(`${String(base)}${suffix}`); }; /** The `oauth.start` identity label: only a label the user actually typed. * An untyped connect sends none, so the server may fill the label from the * provider's OIDC claims (the account email) instead of a generic * "Personal Gmail" that would also clobber a curated label on reconnect. */ export const typedIdentityLabel = (label: string): string | undefined => { const trimmed = label.trim(); return trimmed.length > 0 ? trimmed : undefined; }; export const oauthIdentityLabelFromHealth = (input: { readonly result: HealthCheckResult; readonly typedLabel: string; readonly storedIdentityLabel?: string | null; }): string | null => { const identity = input.result.identity?.trim(); if (input.result.status !== "healthy" || !identity) return null; if (input.typedLabel.trim().length > 0) return null; // Fill only an unset label: an untyped connect stores none unless the grant // carried OIDC claims, and anything already stored (typed, curated, or // claims-derived) wins over a probed identity. return (input.storedIdentityLabel ?? "").trim().length === 0 ? identity : null; }; // --------------------------------------------------------------------------- // Transparent CIMD connect orchestration. // // Client ID Metadata Document support is not Dynamic Client Registration: // there is no POST to a registration endpoint and no provider-side app to pick. // The OAuth client identity is this host's public metadata-document URL, stored // locally as a public PKCE client (`clientSecret: ""`) so the existing // `oauth.start` flow can run unchanged. // --------------------------------------------------------------------------- type CimdExistingClient = Pick< OAuthClientSummary, "owner" | "slug" | "grant" | "authorizationUrl" | "tokenUrl" | "resource" | "clientId" >; type CimdCreateClientArgs = { readonly owner: Owner; readonly slug: OAuthClientSlug; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly grant: "authorization_code"; readonly clientId: string; readonly clientSecret: ""; }; type RunCimdConnectDeps = { readonly createClient: (args: CimdCreateClientArgs) => Promise; readonly start: (args: CimdStartArgs) => void; /** Claim the sign-in window before any await. See `useOAuthPopupFlow.reserve`. */ readonly reserve: () => OAuthPopupReservation; /** Close a claimed window this flow turned out not to need. */ readonly release: () => void; }; type CimdStartArgs = { readonly client: OAuthClientSlug; readonly owner: Owner; readonly reservation: OAuthPopupReservation; }; type RunCimdConnectInput = { readonly owner: Owner; readonly integrationName: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly clientIdMetadataDocumentUrl: string; readonly existingClients: readonly CimdExistingClient[]; }; type CimdOutcome = | { readonly kind: "started"; readonly client: OAuthClientSlug; readonly reused: boolean } | { readonly kind: "popup-blocked" } | { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" }; export async function runCimdConnect( deps: RunCimdConnectDeps, input: RunCimdConnectInput, ): Promise { if (input.authorizationUrl.trim().length === 0 || input.tokenUrl.trim().length === 0) { return { kind: "failed", reason: "missing-endpoints" }; } // Claim the window while the click still counts. Minting the client is a // round trip, and the browser stops honouring `window.open` once the // activation from the click expires. const reservation = deps.reserve(); if (reservation.kind === "blocked") return { kind: "popup-blocked" }; const resource = input.resource ?? null; const existing = input.existingClients.find( (client) => client.owner === input.owner && client.grant === "authorization_code" && client.authorizationUrl === input.authorizationUrl && client.tokenUrl === input.tokenUrl && (client.resource ?? null) === resource && client.clientId === input.clientIdMetadataDocumentUrl, ); if (existing) { deps.start({ client: existing.slug, owner: input.owner, reservation }); return { kind: "started", client: existing.slug, reused: true }; } const slug = uniqueClientSlug( `${input.integrationName} CIMD`, input.existingClients.map((client) => String(client.slug)), ); const created = await deps.createClient({ owner: input.owner, slug, authorizationUrl: input.authorizationUrl, tokenUrl: input.tokenUrl, resource, grant: "authorization_code", clientId: input.clientIdMetadataDocumentUrl, clientSecret: "", }); if (created === null) { deps.release(); return { kind: "failed", reason: "create-failed" }; } deps.start({ client: created, owner: input.owner, reservation }); return { kind: "started", client: created, reused: false }; } // --------------------------------------------------------------------------- // Transparent DCR (RFC 7591) connect orchestration. // // For DCR-capable methods (MCP OAuth) the user clicks one "Connect" button and // we do everything: probe the integration's discovery URL → register a client // against the advertised registration endpoint → start // the OAuth flow with the minted client. No app picker, no pasted client id. // // This is extracted as a pure-ish orchestrator (injected `probe`/`register`/ // `start`) so the SEQUENCE is unit-testable without React/atoms. The caller // supplies thin adapters over the `probeOAuth` / `registerDynamicOAuthClient` / // popup-start atoms. // --------------------------------------------------------------------------- /** Discovery result from the probe step (subset of the `probeOAuth` response). */ type DcrProbeResult = { readonly issuer?: string | null; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly scopesSupported?: readonly string[]; readonly registrationEndpoint?: string | null; readonly tokenEndpointAuthMethodsSupported?: readonly string[]; }; type DcrRegisterArgs = { readonly owner: Owner; readonly slug: OAuthClientSlug; readonly issuer?: string | null; readonly registrationEndpoint: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly scopes: readonly string[]; readonly tokenEndpointAuthMethodsSupported?: readonly string[]; readonly clientName: string; readonly redirectUri?: string; readonly originIntegration?: IntegrationSlug; }; type DcrStartArgs = { readonly client: OAuthClientSlug; readonly owner: Owner; readonly reservation: OAuthPopupReservation; }; /** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed * off (the popup/inline start ran); `"popup-blocked"` means the browser refused * the sign-in window, which no app picker can fix, so the caller reports it * rather than falling back; `"fallback"` means we could not auto-set-up (a * failed probe, no registration endpoint, or a failed registration) and the * caller should fall back to the bring-your-own-app picker. A failed probe * carries no probe result; the other two reasons always carry the probe that * seeds the picker. */ type DcrOutcome = | { readonly kind: "started" } | { readonly kind: "popup-blocked" } | { readonly kind: "fallback"; readonly reason: "probe-failed" } | { readonly kind: "fallback"; readonly reason: "no-registration-endpoint" | "registration-failed"; readonly probe: DcrProbeResult; /** A specific, user-facing reason to surface instead of the generic * "register an app" copy (e.g. a server that rejects the DCR redirect * URI). Absent when the fallback carries no actionable detail. */ readonly message?: string; }; type RunDcrConnectDeps = { /** Probe the discovery URL → resolved endpoints + (maybe) a registration * endpoint. Resolves to null when the probe fails. */ readonly probe: (url: string) => Promise; /** Register a DCR client → the minted client slug, `{ error }` with a * user-facing message when the server rejects registration, or null on an * unexplained failure. */ readonly register: ( args: DcrRegisterArgs, ) => Promise; /** Start the OAuth flow with the minted client (popup / inline). */ readonly start: (args: DcrStartArgs) => void; /** Claim the sign-in window before any await. See `useOAuthPopupFlow.reserve`. */ readonly reserve: () => OAuthPopupReservation; /** Close a claimed window this flow turned out not to need. */ readonly release: () => void; }; type RunDcrConnectInput = { readonly discoveryUrl: string; /** The integration's genuine protected-resource URL (the MCP discovery URL), * used as the RFC 8707 resource indicator when the server's PRM names no * `resource`. Distinct from `discoveryUrl`, which falls back to the token * endpoint for probing: the token endpoint is NOT a resource identifier, so * it must never become the indicator. Undefined for integrations with no * discovery URL (non-MCP DCR), collapsing the resource to null as before. */ readonly resourceFallback?: string; readonly owner: Owner; /** Scopes declared by the integration's method (override the probed ones). */ readonly declaredScopes?: readonly string[]; /** Browser-facing callback URL registered with DCR when available. */ readonly redirectUri?: string; /** Integration that requested this DCR client. */ readonly integration: IntegrationSlug; }; /** RFC 7591 `client_name` sent for every dynamic registration. Deliberately * the bare product name, never "Executor for ": some servers * (e.g. Mercury) vet the name and reject any value containing their own * brand with `invalid_client_metadata`, killing the automatic connect. The * name is cosmetic (it only labels the provider's consent screen and app * list, where the provider is already evident), so the suffix bought * nothing worth that failure class. */ const DCR_CLIENT_NAME = "Executor"; /** * Run the transparent DCR connect sequence: reserve → probe → register → start. * * - Popup refused → `{ kind: "popup-blocked" }` before any network call. * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO). * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`. * - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", probe, message }` * so the caller can show why (e.g. a redirect-URI rejection) over the generic copy. * - Register failed without detail (null) → `{ kind: "fallback", reason: "registration-failed", probe }`. * - Success → registers, calls `start`, returns `{ kind: "started" }`. */ export async function runDcrConnect( deps: RunDcrConnectDeps, input: RunDcrConnectInput, ): Promise { // Claim the sign-in window FIRST, on the click that got us here. Probe and // register are two network round trips; the browser's user activation expires // well before they finish, so a window opened after them is refused and the // connect dies with nothing on screen. const reservation = deps.reserve(); if (reservation.kind === "blocked") return { kind: "popup-blocked" }; const probe = await deps.probe(input.discoveryUrl); if (probe === null) { deps.release(); return { kind: "fallback", reason: "probe-failed" }; } const registrationEndpoint = probe.registrationEndpoint; if (!registrationEndpoint) { deps.release(); return { kind: "fallback", reason: "no-registration-endpoint", probe }; } const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint); const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []); const minted = await deps.register({ owner: input.owner, slug, issuer: probe.issuer ?? null, registrationEndpoint, authorizationUrl: probe.authorizationUrl, tokenUrl: probe.tokenUrl, resource: probe.resource ?? input.resourceFallback ?? null, scopes, tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, clientName: DCR_CLIENT_NAME, redirectUri: input.redirectUri, originIntegration: input.integration, }); if (minted === null) { deps.release(); return { kind: "fallback", reason: "registration-failed", probe }; } // OAuthClientSlug is a branded string; an object return carries the failure // message (e.g. the server rejected the redirect URI) for the BYO fallback. if (typeof minted === "object") { deps.release(); return { kind: "fallback", reason: "registration-failed", probe, message: minted.error }; } deps.start({ client: minted, owner: input.owner, reservation }); return { kind: "started" }; } // --------------------------------------------------------------------------- // One row in the OAuth app picker: a radio-select Label plus an actions menu // (Edit / Remove) so the registered app can be managed inline. The page that // used to own this management was removed in favour of doing it here, next to // where the app is picked. The menu lives OUTSIDE the