From 8538b112e7d1eabf3d10baaa828fe76db486a0e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:31:58 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(console):=20connector=20connections=20?= =?UTF-8?q?page=20=E2=80=94=20status=20view=20and=20Connect=20/=20Reconnec?= =?UTF-8?q?t=20/=20Disconnect=20over=20the=20connection=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../src/services/builtinComponents.tsx | 16 + .../setup/ConnectorConnectionsPage.test.tsx | 252 +++++++++++ .../views/setup/ConnectorConnectionsPage.tsx | 402 ++++++++++++++++++ .../views/setup/ConnectorCredentialsPage.tsx | 10 + .../src/views/setup/connectorConnections.ts | 205 +++++++++ ui/packages/i18n/src/locales/ar.ts | 37 ++ ui/packages/i18n/src/locales/de.ts | 37 ++ ui/packages/i18n/src/locales/en.ts | 37 ++ ui/packages/i18n/src/locales/es.ts | 37 ++ ui/packages/i18n/src/locales/fr.ts | 37 ++ ui/packages/i18n/src/locales/ja.ts | 37 ++ ui/packages/i18n/src/locales/ko.ts | 37 ++ ui/packages/i18n/src/locales/pt.ts | 37 ++ ui/packages/i18n/src/locales/ru.ts | 37 ++ ui/packages/i18n/src/locales/zh.ts | 37 ++ 15 files changed, 1255 insertions(+) create mode 100644 ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx create mode 100644 ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx create mode 100644 ui/packages/app-shell/src/views/setup/connectorConnections.ts diff --git a/ui/packages/app-shell/src/services/builtinComponents.tsx b/ui/packages/app-shell/src/services/builtinComponents.tsx index 2022040b08d..034bf685608 100644 --- a/ui/packages/app-shell/src/services/builtinComponents.tsx +++ b/ui/packages/app-shell/src/services/builtinComponents.tsx @@ -24,6 +24,8 @@ import { PositionGrantedSetsCell, PositionHolderCountCell } from '../views/metad import { PackagedAutomationPage } from '../views/setup/PackagedAutomationPage.js'; import { ConnectorCredentialsPage } from '../views/setup/ConnectorCredentialsPage.js'; import { CONNECTOR_CREDENTIALS_REF } from '../views/setup/connectorCredentials.js'; +import { ConnectorConnectionsPage } from '../views/setup/ConnectorConnectionsPage.js'; +import { CONNECTOR_CONNECTIONS_REF } from '../views/setup/connectorConnections.js'; import { PositionHoldersPage } from '../views/setup/PositionHoldersPage.js'; import { PermissionSetAssignmentPage } from '../views/setup/PermissionSetAssignmentPage.js'; import { PERMISSION_SET_ASSIGNMENT_REF, POSITION_HOLDERS_REF } from '../views/setup/peopleAssignments.js'; @@ -167,6 +169,20 @@ registerAppComponent({ component: ConnectorCredentialsPage, }); +/** + * Setup → Integrations → Connector Connections (ADR-0143 D5): an organization + * administrator connects, reconnects or disconnects each `oauth2` + * `authorization_code` connector instance for their organization. The entry + * naming this ref is the automation service's to contribute, gated on + * `manage_connector_connections`; the shell declares no entry for it. + */ +registerAppComponent({ + ref: CONNECTOR_CONNECTIONS_REF, + label: 'Connector Connections', + source: '@object-ui/app-shell', + component: ConnectorConnectionsPage, +}); + /** * ADR-0146 D7 — Setup → People & organization: who holds a position, and who * holds a permission set. Each page picks a catalog item and renders the diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx new file mode 100644 index 00000000000..3f90c9a10fa --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.test.tsx @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Integrations → Connector Connections (ADR-0143 D5, item 7 of + * objectstack#26702) — behaviour, over stubbed connection, registry and + * declaration doors. + * + * 1. **the status view** — one row per declared `oauth2` + * `authorization_code` instance (other auth kinds are not listed), with + * this organization's connection status and the server's reason, and a + * `degraded` instance's reason beside the connection that caused it. + * 2. **connect** — the sign-in window opens on the click, is detached from + * the page, and is sent to the URL the begin leg answered; the URL is + * never rendered. The page re-reads the status until the connect lands, + * then closes the window and shows the new status. + * 3. **refusals** — a blocked window sends nothing; a refused begin closes + * the window and shows the server's words; a URL that is not http(s) is + * never navigated to. + * 4. **disconnect** — only behind a confirmation; Cancel sends nothing. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ConnectorConnectionState } from '@objectstack/spec/contracts'; + +import { ConnectorConnectionsPage } from './ConnectorConnectionsPage'; + +const META = '/api/v1/meta/connector'; +const REGISTRY = '/api/v1/automation/connectors'; +const door = (name: string) => `/api/v1/automation/connectors/${name}/connection`; +/** What a begin answers. Its `state` value must never reach the page's text. */ +const AUTH_URL = 'https://vendor.example.test/authorize?client_id=c1&state=STATE-NEVER-RENDERED&code_challenge=x'; +const REASON = "connector instance 'hubspot' needs to be reconnected for organization 'org_1': the refresh token was refused"; + +const declared = [ + { name: 'hubspot', label: 'HubSpot', provider: 'mcp', auth: { type: 'oauth2', grant: 'authorization_code' } }, + { name: 'notion', label: 'Notion', provider: 'mcp', auth: { type: 'oauth2', grant: 'authorization_code', clientId: 'n1' } }, + { name: 'stripe', label: 'Stripe', provider: 'openapi', auth: { type: 'bearer', credentialRef: 'STRIPE_KEY' } }, + { name: 'feishu', label: 'Feishu', provider: 'rest', auth: { type: 'oauth2', grant: 'client_credentials', clientId: 'f1' } }, +]; + +const state = (connector: string, over: Partial = {}): ConnectorConnectionState => ({ + connector, + organizationId: 'org_1', + status: 'not_connected', + ...over, +}); + +const envelope = (data: unknown) => ({ success: true, data }); +const errorEnvelope = (code: string, message: string) => ({ success: false, error: { code, message } }); + +type Answer = { status: number; body: unknown }; +let calls: Array<{ url: string; method: string; body?: unknown }>; +let answers: Map; +let fakeWindow: { closed: boolean; opener: unknown; location: { href: string }; close: ReturnType } | null; +let openSpy: ReturnType; + +/** Queue answers for `METHOD url`; the last one repeats. */ +function answer(method: string, url: string, ...list: Answer[]) { + answers.set(`${method} ${url}`, list); +} + +beforeEach(() => { + calls = []; + answers = new Map(); + answer('GET', META, { status: 200, body: { type: 'connector', items: declared } }); + answer('GET', REGISTRY, { + status: 200, + body: envelope({ + connectors: [ + { name: 'hubspot', label: 'HubSpot', type: 'api', origin: 'declarative', state: 'degraded', degradedReason: `reconnect required: ${REASON}`, actions: [] }, + { name: 'notion', label: 'Notion', type: 'api', origin: 'declarative', state: 'ready', actions: [] }, + ], + total: 2, + }), + }); + answer('GET', door('hubspot'), { status: 200, body: envelope(state('hubspot', { status: 'reconnect_required', statusReason: REASON, connectedAt: '2026-10-30T08:00:00.000Z', connectedBy: 'ada@acme.test' })) }); + answer('GET', door('notion'), { status: 200, body: envelope(state('notion')) }); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push({ url, method, body: init?.body }); + const queue = answers.get(`${method} ${url}`); + const next = queue ? (queue.length > 1 ? queue.shift()! : queue[0]) : { status: 500, body: errorEnvelope('X', `unexpected ${method} ${url}`) }; + return { ok: next.status >= 200 && next.status < 300, status: next.status, json: async () => next.body } as unknown as Response; + }), + ); + fakeWindow = { closed: false, opener: window, location: { href: 'about:blank' }, close: vi.fn(() => { if (fakeWindow) fakeWindow.closed = true; }) }; + openSpy = vi.fn(() => fakeWindow); + vi.stubGlobal('open', openSpy); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderPage() { + return render( + + + , + ); +} + +const row = (name: string) => screen.getByTestId(`connection-row-${name}`); +const sent = (method: string, url: string) => calls.filter((c) => c.method === method && c.url === url); + +describe('the status view', () => { + it('lists only the instances an administrator connects, with this organization\'s status', async () => { + renderPage(); + expect(await screen.findByRole('heading', { name: 'Connector connections' })).toBeInTheDocument(); + await screen.findByTestId('connection-row-hubspot'); + + expect(screen.queryByTestId('connection-row-stripe')).toBeNull(); + expect(screen.queryByTestId('connection-row-feishu')).toBeNull(); + expect(within(row('notion')).getByText('Not connected')).toBeInTheDocument(); + expect(within(row('notion')).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + expect(within(row('notion')).queryByRole('button', { name: 'Disconnect' })).toBeNull(); + + expect(within(row('hubspot')).getByText('Reconnect required')).toBeInTheDocument(); + expect(screen.getByTestId('connection-reason-hubspot')).toHaveTextContent(REASON); + expect(within(row('hubspot')).getByText(/by ada@acme\.test$/)).toBeInTheDocument(); + expect(within(row('hubspot')).getByRole('button', { name: 'Reconnect' })).toBeInTheDocument(); + }); + + it('a degraded instance shows the server\'s reason beside the connection that caused it', async () => { + renderPage(); + const degraded = await screen.findByTestId('connection-degraded-hubspot'); + expect(degraded).toHaveTextContent('Degraded'); + expect(degraded).toHaveTextContent(`reconnect required: ${REASON}`); + expect(screen.queryByTestId('connection-degraded-notion')).toBeNull(); + }); + + it('a refused status read shows the server\'s words and offers no action', async () => { + answer('GET', door('notion'), { status: 403, body: errorEnvelope('PERMISSION_DENIED', 'Connecting … requires the `manage_connector_connections` capability.') }); + renderPage(); + const refused = await screen.findByTestId('connection-refused-notion'); + expect(refused).toHaveTextContent('manage_connector_connections'); + expect(within(row('notion')).queryByRole('button')).toBeNull(); + }); + + it('a failed declaration read is a failure with Try again; no connectable instance is the empty state', async () => { + answer('GET', META, { status: 500, body: errorEnvelope('INTERNAL_ERROR', 'boom') }, { status: 200, body: { items: [declared[2]] } }); + renderPage(); + expect(await screen.findByText('Could not load connector connections.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByTestId('connector-connections-empty')).toBeInTheDocument(); + }); +}); + +describe('connect', () => { + it('opens a detached window on the click, sends it to the begun URL, and shows the connect once it lands', async () => { + answer('POST', door('notion'), { status: 200, body: envelope({ connector: 'notion', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() }) }); + renderPage(); + await screen.findByTestId('connection-row-notion'); + answer('GET', door('notion'), + { status: 200, body: envelope(state('notion')) }, + { status: 200, body: envelope(state('notion', { status: 'connected', connectedAt: '2026-11-01T10:00:00.000Z', connectedBy: 'ada@acme.test', scopes: 'read write' })) }); + + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe('about:blank'); + await waitFor(() => expect(fakeWindow!.location.href).toBe(AUTH_URL)); + expect(fakeWindow!.opener).toBeNull(); + expect(sent('POST', door('notion'))).toHaveLength(1); + expect(sent('POST', door('notion'))[0].body).toBeUndefined(); + + await waitFor(() => expect(within(row('notion')).getByText('Connected')).toBeInTheDocument()); + expect(fakeWindow!.close).toHaveBeenCalled(); + expect(within(row('notion')).getByText('Scopes: read write')).toBeInTheDocument(); + expect(screen.getByText('Connected notion.')).toBeInTheDocument(); + expect(document.body.textContent).not.toContain('STATE-NEVER-RENDERED'); + }); + + it('a window closed before the connect lands stops waiting and shows the status read then', async () => { + answer('POST', door('notion'), { status: 200, body: envelope({ connector: 'notion', organizationId: 'org_1', authorizationUrl: AUTH_URL, expiresAt: new Date(Date.now() + 600_000).toISOString() }) }); + renderPage(); + await screen.findByTestId('connection-row-notion'); + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByText('Finish signing in in the window that opened.')).toBeInTheDocument(); + fakeWindow!.closed = true; + await waitFor(() => expect(within(row('notion')).getByRole('button', { name: 'Connect' })).toBeEnabled()); + expect(within(row('notion')).getByText('Not connected')).toBeInTheDocument(); + }); +}); + +describe('refusals', () => { + it('a blocked window sends nothing', async () => { + openSpy.mockReturnValue(null); + renderPage(); + await screen.findByTestId('connection-row-notion'); + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByRole('alert')).toHaveTextContent('The browser blocked the sign-in window.'); + expect(sent('POST', door('notion'))).toHaveLength(0); + }); + + it('a refused begin closes the window and shows the server\'s words', async () => { + const refusal = 'This deployment has no fixed public origin for the OAuth redirect, so a connector instance cannot be connected.'; + answer('POST', door('notion'), { status: 503, body: errorEnvelope('SERVICE_UNAVAILABLE', refusal) }); + renderPage(); + await screen.findByTestId('connection-row-notion'); + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByRole('alert')).toHaveTextContent(refusal); + expect(fakeWindow!.close).toHaveBeenCalled(); + expect(fakeWindow!.location.href).toBe('about:blank'); + }); + + it('an authorization URL that is not http(s) is never navigated to', async () => { + answer('POST', door('notion'), { status: 200, body: envelope({ connector: 'notion', organizationId: 'org_1', authorizationUrl: 'javascript:alert(1)', expiresAt: '' }) }); + renderPage(); + await screen.findByTestId('connection-row-notion'); + fireEvent.click(within(row('notion')).getByRole('button', { name: 'Connect' })); + expect(await screen.findByRole('alert')).toHaveTextContent('without an authorization URL to open'); + expect(fakeWindow!.location.href).toBe('about:blank'); + expect(fakeWindow!.close).toHaveBeenCalled(); + }); +}); + +describe('disconnect', () => { + it('is behind a confirmation; Cancel sends nothing, confirming sends DELETE and shows the answer', async () => { + answer('DELETE', door('hubspot'), { status: 200, body: envelope(state('hubspot')) }); + renderPage(); + await screen.findByTestId('connection-row-hubspot'); + + fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Disconnect' })); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + expect(sent('DELETE', door('hubspot'))).toHaveLength(0); + + fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Disconnect' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })); + await waitFor(() => expect(within(row('hubspot')).getByText('Not connected')).toBeInTheDocument()); + expect(sent('DELETE', door('hubspot'))).toHaveLength(1); + expect(screen.getByText('Disconnected hubspot.')).toBeInTheDocument(); + }); + + it('a refused disconnect keeps the dialog open with the server\'s words', async () => { + answer('DELETE', door('hubspot'), { status: 404, body: errorEnvelope('RESOURCE_NOT_FOUND', "Connector instance 'hubspot' is not connected for organization 'org_1', so there is nothing to revoke.") }); + renderPage(); + await screen.findByTestId('connection-row-hubspot'); + fireEvent.click(within(row('hubspot')).getByRole('button', { name: 'Disconnect' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('nothing to revoke'); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx new file mode 100644 index 00000000000..04dcf3aed23 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Integrations → Connector Connections (ADR-0143 D5, item 7 of + * objectstack#26702). + * + * One row per declared `oauth2` `authorization_code` connector instance: the + * instance's registry state (a `degraded` one with the server's reason beside + * the connection that caused it), this organization's connection — `Not + * connected`, `Connected` or `Reconnect required` with the reason the server + * recorded — its scopes and dates, and Connect / Reconnect / Disconnect. + * + * Connect opens the vendor's sign-in in its own window: the window is opened + * on the click (a window opened after an await is blocked), detached from this + * page (`opener` cleared), then sent to the `authorizationUrl` the server + * answered. The vendor redirects that window to the server's callback, which + * stores the connection; this page re-reads the status until the connect + * lands, the window is closed, or the begun connect expires. + * + * ⛔ It never displays a token, a code or the authorization URL: none of the + * door's status answers carries a token, and the URL goes only to the window. + */ + +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { AlertCircle, Info, Plug } from 'lucide-react'; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Empty, + EmptyDescription, + EmptyTitle, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@object-ui/components'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import type { ConnectorConnectionState } from '@objectstack/spec/contracts'; +import { PageHeader } from '../../layout/PageHeader.js'; +import { + beginConnection, + fetchConnection, + fetchConnectionListing, + revokeConnection, + type ConnectionListing, + type ConnectionRead, +} from './connectorConnections.js'; + +const STATUS_CLASS: Record = { + connected: 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400', + not_connected: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + reconnect_required: 'border-destructive/30 bg-destructive/10 text-destructive', +}; + +/** The sign-in window's name: a second Connect reuses it rather than stacking windows. */ +const SIGN_IN_WINDOW = 'objectstack-connector-connect'; +const SIGN_IN_FEATURES = 'popup,width=560,height=720'; + +interface Waiting { + name: string; + window: Window; + /** The connect being waited for has landed once `connectedAt` moves off this. */ + before?: string; + until: number; +} + +interface RevokeDraft { + name: string; + busy: boolean; + refusal: string | null; +} + +export interface ConnectorConnectionsPageProps { + /** How often a begun connect re-reads the status, in milliseconds. */ + pollMs?: number; +} + +export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnectionsPageProps) { + const { t } = useObjectTranslation(); + const displayLocale = useDisplayLocale(); + const [nonce, setNonce] = useState(0); + const [listing, setListing] = useState(null); + const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null); + const [waiting, setWaiting] = useState(null); + const [revokeDraft, setRevokeDraft] = useState(null); + const waitingRef = useRef(null); + waitingRef.current = waiting; + + const reload = () => { + setListing(null); + setNonce((n) => n + 1); + }; + + useEffect(() => { + const controller = new AbortController(); + fetchConnectionListing(controller.signal).then( + (next) => setListing(next), + () => { /* aborted: a newer read owns the page */ }, + ); + return () => controller.abort(); + }, [nonce]); + + const applyConnection = (name: string, connection: ConnectionRead) => { + setListing((cur) => (cur?.kind === 'listed' + ? { kind: 'listed', rows: cur.rows.map((r) => (r.instance.name === name ? { ...r, connection } : r)) } + : cur)); + }; + + // A begun connect: re-read until it lands, its window closes, or it expires. + useEffect(() => { + if (!waiting) return; + let busy = false; + const timer = setInterval(() => { + const current = waitingRef.current; + if (busy || !current) return; + busy = true; + const closed = current.window.closed; + void fetchConnection(current.name).then((read) => { + busy = false; + if (waitingRef.current !== current) return; + const landed = read.ok && read.state.status === 'connected' && read.state.connectedAt !== current.before; + const expired = Date.now() > current.until; + if (!landed && !closed && !expired) return; + applyConnection(current.name, read); + if (!current.window.closed) current.window.close(); + setWaiting(null); + if (landed) { + setNotice({ tone: 'ok', text: t('connectorConnections.connected', { defaultValue: 'Connected {{name}}.', name: current.name }) }); + } else if (expired) { + setNotice({ + tone: 'error', + text: t('connectorConnections.expired', { + defaultValue: 'The sign-in for {{name}} was not completed in time. Connect again.', + name: current.name, + }), + }); + } + }, () => { busy = false; }); + }, pollMs); + return () => clearInterval(timer); + }, [waiting, pollMs]); + + const connect = async (name: string, before?: string) => { + if (waiting) return; + setNotice(null); + const signIn = window.open('about:blank', SIGN_IN_WINDOW, SIGN_IN_FEATURES); + if (!signIn) { + setNotice({ + tone: 'error', + text: t('connectorConnections.popupBlocked', { + defaultValue: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.', + }), + }); + return; + } + // Detach the window before it reaches the vendor: the vendor's page gets no handle on this one. + signIn.opener = null; + const begun = await beginConnection(name); + if (!begun.ok) { + signIn.close(); + setNotice({ tone: 'error', text: begun.message }); + return; + } + signIn.location.href = begun.authorizationUrl; + const expiresAt = Date.parse(begun.expiresAt); + setWaiting({ name, window: signIn, before, until: Number.isNaN(expiresAt) ? Date.now() + 10 * 60_000 : expiresAt }); + }; + + const confirmRevoke = async () => { + if (!revokeDraft || revokeDraft.busy) return; + const name = revokeDraft.name; + setRevokeDraft({ ...revokeDraft, busy: true, refusal: null }); + const out = await revokeConnection(name); + if (!out.ok) { + setRevokeDraft({ name, busy: false, refusal: out.message }); + return; + } + applyConnection(name, out); + setRevokeDraft(null); + setNotice({ tone: 'ok', text: t('connectorConnections.disconnected', { defaultValue: 'Disconnected {{name}}.', name }) }); + }; + + const formatAt = (at: string | undefined): string | null => { + if (!at) return null; + const d = new Date(at); + return Number.isNaN(d.getTime()) ? at : d.toLocaleString(displayLocale); + }; + + const statusLabel = (status: ConnectorConnectionState['status']) => (status === 'connected' + ? t('connectorConnections.statusConnected', { defaultValue: 'Connected' }) + : status === 'reconnect_required' + ? t('connectorConnections.statusReconnect', { defaultValue: 'Reconnect required' }) + : t('connectorConnections.statusNotConnected', { defaultValue: 'Not connected' })); + + let body: ReactNode; + if (listing === null) { + body = ( +
+ + +
+ ); + } else if (listing.kind === 'failed') { + body = ( +
+
+ ); + } else if (listing.rows.length === 0) { + body = ( + + {t('connectorConnections.emptyTitle', { defaultValue: 'No connector needs connecting' })} + + {t('connectorConnections.emptyBody', { + defaultValue: 'None of the installed apps has a connector that an administrator signs in to with OAuth.', + })} + + + ); + } else { + body = ( + + + + {t('connectorConnections.colConnector', { defaultValue: 'Connector' })} + {t('connectorConnections.colConnection', { defaultValue: 'Connection' })} + {t('connectorConnections.colDetails', { defaultValue: 'Details' })} + {t('connectorConnections.colActions', { defaultValue: 'Actions' })} + + + + {listing.rows.map(({ instance, descriptor, connection }) => { + const name = instance.name; + const state = connection.ok ? connection.state : undefined; + const connectedAt = formatAt(state?.connectedAt); + const expiresAt = formatAt(state?.expiresAt); + const isWaiting = waiting?.name === name; + return ( + + +

{instance.label}

+ {name} + {descriptor?.state === 'degraded' ? ( +
+ + {t('connectorConnections.instanceDegraded', { defaultValue: 'Degraded' })} + + {descriptor.degradedReason ? ( +

{descriptor.degradedReason}

+ ) : null} +
+ ) : null} +
+ + {state ? ( +
+ + {statusLabel(state.status)} + + {state.statusReason ? ( +

+ {state.statusReason} +

+ ) : null} +
+ ) : ( +

+ {connection.ok ? null : connection.message} +

+ )} +
+ + {connectedAt ? ( +

+ {state?.connectedBy + ? t('connectorConnections.connectedBy', { defaultValue: 'Connected {{at}} by {{user}}', at: connectedAt, user: state.connectedBy }) + : t('connectorConnections.connectedAt', { defaultValue: 'Connected {{at}}', at: connectedAt })} +

+ ) : null} + {state?.scopes ? ( +

{t('connectorConnections.scopes', { defaultValue: 'Scopes: {{scopes}}', scopes: state.scopes })}

+ ) : null} + {expiresAt ? ( +

{t('connectorConnections.tokenExpires', { defaultValue: 'Access token renews by {{at}}', at: expiresAt })}

+ ) : null} + {!connectedAt && !state?.scopes && !expiresAt ? : null} +
+ + {state ? ( +
+ {isWaiting ? ( +

+ {t('connectorConnections.waiting', { defaultValue: 'Finish signing in in the window that opened.' })} +

+ ) : ( + + )} + {state.status !== 'not_connected' && !isWaiting ? ( + + ) : null} +
+ ) : null} +
+
+ ); + })} +
+
+ ); + } + + return ( +
+ } + refresh={{ + onRefresh: reload, + label: t('connectorConnections.refresh', { defaultValue: 'Refresh' }), + busy: listing === null, + }} + /> +
+ {notice ? ( +
+ {notice.tone === 'error' ?
+ ) : null} + {body} +
+ + { if (!open && !revokeDraft?.busy) setRevokeDraft(null); }}> + + + + {t('connectorConnections.disconnectTitle', { defaultValue: 'Disconnect {{name}}?', name: revokeDraft?.name ?? '' })} + + + {t('connectorConnections.disconnectBody', { + defaultValue: + 'The connection is removed here and the connector stops calling the vendor for this organization until it is connected again. The grant stays valid at the vendor until it expires or is revoked there.', + })} + + + {revokeDraft?.refusal ? ( +

{revokeDraft.refusal}

+ ) : null} + + + + +
+
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx index 8e348c1de70..7bf92a07e8d 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorCredentialsPage.tsx @@ -24,6 +24,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { AlertCircle, Info, KeyRound } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { Badge, Button, @@ -53,6 +54,7 @@ import { fetchCredentialListing, type CredentialListing, } from './connectorCredentials.js'; +import { connectorConnectionsPagePath } from './connectorConnections.js'; /** The status badge's look per status — green, amber, red. */ const STATUS_CLASS: Record = { @@ -347,6 +349,14 @@ export function ConnectorCredentialsPage() { ) : null} {body} +

+ {t('connectorCredentials.connectionsHint', { + defaultValue: 'A connector that signs in to its vendor with OAuth is connected in', + })}{' '} + + {t('connectorCredentials.connectionsLink', { defaultValue: 'Connector connections' })} + +

{ if (!open && !clearDraft?.busy) setClearDraft(null); }}> diff --git a/ui/packages/app-shell/src/views/setup/connectorConnections.ts b/ui/packages/app-shell/src/views/setup/connectorConnections.ts new file mode 100644 index 00000000000..da16f2275a8 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/connectorConnections.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connector connection door, as the console reads and drives it — Setup → + * Integrations → Connector Connections (ADR-0143 D5, item 7 of + * objectstack#26702). An organization administrator connects an `oauth2` + * `authorization_code` connector instance once; the platform refreshes the + * grant per dispatch, and this page shows where each connection stands. + * + * Four routes, all asking the organization-scoped `manage_connector_connections`: + * + * GET /api/v1/automation/connectors/:name/connection the status + * POST /api/v1/automation/connectors/:name/connection begin a connect + * DELETE /api/v1/automation/connectors/:name/connection disconnect + * GET /api/v1/automation/connectors/oauth/callback the vendor's redirect + * + * The console never calls the callback: the vendor sends the administrator's + * browser there. Which instances need a connection is read from their + * declaration (`GET /meta/connector`: `auth.type: 'oauth2'` with + * `grant: 'authorization_code'`), and each instance's registry state from + * `GET /automation/connectors`. + * + * ⛔ No token crosses this module: every answer is a `ConnectorConnectionState`, + * which carries a status, its reason, scopes and dates. The begin leg's + * `authorizationUrl` carries the `state` and PKCE challenge the server minted; + * it is handed to the sign-in window and nothing else — never rendered, + * logged or rebuilt here. + */ + +import type { ConnectorConnectionState } from '@objectstack/spec/contracts'; +import type { ConnectorDescriptor } from '@objectstack/spec/integration'; +import { actionErrorDetail } from '@object-ui/core'; +import { apiBase } from '../../utils/apiBase.js'; +import { componentRefToUrlSegments } from '../../services/componentRegistry.js'; +import { readMetadataItems } from './packagedFlows.js'; + +/** + * The console component a Setup entry names. The entry is the automation + * service's to contribute, gated on `manage_connector_connections`. + */ +export const CONNECTOR_CONNECTIONS_REF = 'automation:connector-connections'; + +/** Where the page opens inside the Setup app. */ +export function connectorConnectionsPagePath(): string { + return `/apps/setup/component/${componentRefToUrlSegments(CONNECTOR_CONNECTIONS_REF).join('/')}`; +} + +/** A declared instance an administrator connects. */ +export interface ConnectableInstance { + name: string; + label: string; +} + +/** A refusal, in the server's own words: rendered, never parsed. */ +export interface DoorRefusal { + status: number; + message: string; +} + +export type ConnectionRead = + | { ok: true; state: ConnectorConnectionState } + | ({ ok: false } & DoorRefusal); + +export type ConnectionBegin = + | { ok: true; authorizationUrl: string; expiresAt: string } + | ({ ok: false } & DoorRefusal); + +/** One row of the page: the instance, its registry state, and this organization's connection. */ +export interface ConnectionRow { + instance: ConnectableInstance; + descriptor?: Pick; + connection: ConnectionRead; +} + +export type ConnectionListing = + | { kind: 'listed'; rows: ConnectionRow[] } + | { kind: 'failed'; message: string }; + +const JSON_HEADERS = { Accept: 'application/json' } as const; +const STATUSES = new Set(['not_connected', 'connected', 'reconnect_required']); + +function dataOf(payload: unknown): Record | undefined { + const data = (payload as { data?: unknown } | null | undefined)?.data; + return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record) : undefined; +} + +function refused(payload: unknown): boolean { + return (payload as { success?: unknown } | null | undefined)?.success === false; +} + +function isOk(status: number, payload: unknown): boolean { + return status >= 200 && status < 300 && !refused(payload); +} + +/** + * The declared instances an administrator connects, out of a `/meta/connector` + * list: provider-bound, `oauth2`, `grant: 'authorization_code'`. The other + * grants mint their own tokens and need no one to connect them. + */ +export function readConnectableInstances(payload: unknown): ConnectableInstance[] { + const out: ConnectableInstance[] = []; + for (const item of readMetadataItems(payload)) { + const i = item as { name?: unknown; label?: unknown; provider?: unknown; auth?: { type?: unknown; grant?: unknown } } | null; + if (typeof i?.name !== 'string' || i.name.length === 0 || typeof i.provider !== 'string') continue; + if (i.auth?.type !== 'oauth2' || i.auth.grant !== 'authorization_code') continue; + out.push({ name: i.name, label: typeof i.label === 'string' && i.label.length > 0 ? i.label : i.name }); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** A status read's (or a disconnect's) conclusion from its status and body. */ +export function readConnectionState(status: number, payload: unknown): ConnectionRead { + if (isOk(status, payload)) { + const state = dataOf(payload) as ConnectorConnectionState | undefined; + if (typeof state?.connector === 'string' && STATUSES.has(state.status as string)) return { ok: true, state }; + return { ok: false, status, message: 'The connection door answered without a connection status.' }; + } + return { ok: false, status, message: actionErrorDetail(payload, `HTTP ${status}`) }; +} + +/** + * The begin leg's conclusion. Only an `http(s)` authorization URL is accepted: + * it is the one value that navigates a window. + */ +export function readConnectionBegin(status: number, payload: unknown): ConnectionBegin { + if (!isOk(status, payload)) return { ok: false, status, message: actionErrorDetail(payload, `HTTP ${status}`) }; + const data = dataOf(payload); + const url = data?.authorizationUrl; + let protocol: string | undefined; + try { + protocol = typeof url === 'string' ? new URL(url).protocol : undefined; + } catch { + protocol = undefined; + } + if (protocol !== 'https:' && protocol !== 'http:') { + return { ok: false, status, message: 'The connection door answered without an authorization URL to open.' }; + } + return { ok: true, authorizationUrl: url as string, expiresAt: typeof data?.expiresAt === 'string' ? data.expiresAt : '' }; +} + +function connectionUrl(name: string): string { + return `${apiBase()}/automation/connectors/${encodeURIComponent(name)}/connection`; +} + +async function bodyOf(res: Response): Promise { + return res.json().catch(() => null); +} + +async function call(url: string, init: RequestInit): Promise<{ status: number; body: unknown }> { + try { + const res = await fetch(url, { credentials: 'include', headers: JSON_HEADERS, cache: 'no-store', ...init }); + return { status: res.status, body: await bodyOf(res) }; + } catch (err) { + if ((err as { name?: unknown })?.name === 'AbortError') throw err; + return { status: 0, body: { success: false, error: { message: (err as Error)?.message ?? String(err) } } }; + } +} + +/** `GET …/:name/connection`. */ +export async function fetchConnection(name: string, signal?: AbortSignal): Promise { + const { status, body } = await call(connectionUrl(name), { signal }); + return readConnectionState(status, body); +} + +/** `POST …/:name/connection` — no body. */ +export async function beginConnection(name: string): Promise { + const { status, body } = await call(connectionUrl(name), { method: 'POST' }); + return readConnectionBegin(status, body); +} + +/** `DELETE …/:name/connection`. */ +export async function revokeConnection(name: string): Promise { + const { status, body } = await call(connectionUrl(name), { method: 'DELETE' }); + return readConnectionState(status, body); +} + +/** + * Every connectable instance with its registry state and this organization's + * connection. The declaration read failing is a failure; the descriptor list + * failing only leaves the registry state out. + */ +export async function fetchConnectionListing(signal?: AbortSignal): Promise { + const meta = await call(`${apiBase()}/meta/connector`, { signal }); + if (meta.status < 200 || meta.status >= 300 || refused(meta.body)) { + return { kind: 'failed', message: actionErrorDetail(meta.body, `HTTP ${meta.status}`) }; + } + const instances = readConnectableInstances(meta.body); + if (instances.length === 0) return { kind: 'listed', rows: [] }; + const registry = await call(`${apiBase()}/automation/connectors`, { signal }); + const listed = isOk(registry.status, registry.body) ? dataOf(registry.body)?.connectors : undefined; + const descriptors = new Map( + (Array.isArray(listed) ? (listed as ConnectorDescriptor[]) : []).map((d) => [d.name, d]), + ); + const rows = await Promise.all( + instances.map(async (instance): Promise => { + const d = descriptors.get(instance.name); + return { + instance, + ...(d ? { descriptor: { state: d.state, ...(d.degradedReason ? { degradedReason: d.degradedReason } : {}) } } : {}), + connection: await fetchConnection(instance.name, signal), + }; + }), + ); + return { kind: 'listed', rows }; +} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index f9212ce89a1..f316981667b 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5312,6 +5312,43 @@ const ar = { skippedTitle: "تم التخطي مؤقتًا", skippedBody: "تبقى الموصلات التي تستخدمها غير متصلة حتى يُعيَّن كل منها.", openPage: "عيّنها في بيانات اعتماد الموصلات", + connectionsHint: "يُوصَل الموصل الذي يسجّل الدخول إلى مزوّده عبر OAuth في", + connectionsLink: "اتصالات الموصلات", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "اتصالات الموصلات", + subtitle: "سجّل الدخول مرة واحدة إلى كل مزوّد تستدعيه موصلاتك نيابةً عن هذه المؤسسة. تجدّد المنصة الوصول تلقائيًا؛ أعد الاتصال عندما يتوقف المزوّد عن قبول التفويض.", + refresh: "تحديث", + dismiss: "إغلاق", + colConnector: "الموصل", + colConnection: "الاتصال", + colDetails: "التفاصيل", + colActions: "الإجراءات", + statusConnected: "متصل", + statusReconnect: "يلزم إعادة الاتصال", + statusNotConnected: "غير متصل", + instanceDegraded: "متدهور", + connectedBy: "اتصل في {{at}} بواسطة {{user}}", + connectedAt: "اتصل في {{at}}", + scopes: "النطاقات: {{scopes}}", + tokenExpires: "يُجدَّد رمز الوصول بحلول {{at}}", + waiting: "أكمل تسجيل الدخول في النافذة التي فُتحت.", + connect: "اتصال", + reconnect: "إعادة الاتصال", + disconnect: "قطع الاتصال", + connected: "تم الاتصال بـ {{name}}.", + disconnected: "تم قطع الاتصال بـ {{name}}.", + expired: "لم يكتمل تسجيل الدخول لـ {{name}} في الوقت المحدد. اتصل مرة أخرى.", + popupBlocked: "حظر المتصفح نافذة تسجيل الدخول. اسمح بالنوافذ المنبثقة لهذا الموقع وحاول مرة أخرى.", + disconnectTitle: "قطع الاتصال بـ {{name}}؟", + disconnectBody: "يُزال الاتصال هنا ويتوقف الموصل عن استدعاء المزوّد لهذه المؤسسة حتى يُعاد الاتصال. يبقى التفويض صالحًا لدى المزوّد حتى تنتهي صلاحيته أو يُلغى هناك.", + disconnectConfirm: "قطع الاتصال", + cancel: "إلغاء", + loadFailed: "تعذّر تحميل اتصالات الموصلات.", + retry: "إعادة المحاولة", + emptyTitle: "لا يوجد موصل يحتاج إلى اتصال", + emptyBody: "لا يحتوي أي من التطبيقات المثبتة على موصل يسجّل المسؤول الدخول إليه عبر OAuth.", }, setupAssignments: { positionsTitle: "شاغلو المناصب", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 4decce88333..cac8ac88fcf 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4818,6 +4818,43 @@ const de = { skippedTitle: "Vorerst übersprungen", skippedBody: "Die Connectors, die sie verwenden, bleiben getrennt, bis jeder Wert gesetzt ist.", openPage: "Unter Connector-Zugangsdaten festlegen", + connectionsHint: "Ein Connector, der sich per OAuth bei seinem Anbieter anmeldet, wird verbunden unter", + connectionsLink: "Connector-Verbindungen", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "Connector-Verbindungen", + subtitle: "Melden Sie sich einmal bei jedem Anbieter an, den Ihre Connectors im Namen dieser Organisation aufrufen. Die Plattform erneuert den Zugriff selbst; verbinden Sie neu, wenn ein Anbieter die Berechtigung nicht mehr akzeptiert.", + refresh: "Aktualisieren", + dismiss: "Schließen", + colConnector: "Connector", + colConnection: "Verbindung", + colDetails: "Details", + colActions: "Aktionen", + statusConnected: "Verbunden", + statusReconnect: "Neu verbinden erforderlich", + statusNotConnected: "Nicht verbunden", + instanceDegraded: "Eingeschränkt", + connectedBy: "Verbunden am {{at}} von {{user}}", + connectedAt: "Verbunden am {{at}}", + scopes: "Bereiche: {{scopes}}", + tokenExpires: "Zugriffstoken wird bis {{at}} erneuert", + waiting: "Schließen Sie die Anmeldung im geöffneten Fenster ab.", + connect: "Verbinden", + reconnect: "Neu verbinden", + disconnect: "Trennen", + connected: "{{name}} verbunden.", + disconnected: "{{name}} getrennt.", + expired: "Die Anmeldung für {{name}} wurde nicht rechtzeitig abgeschlossen. Verbinden Sie erneut.", + popupBlocked: "Der Browser hat das Anmeldefenster blockiert. Erlauben Sie Pop-ups für diese Website und versuchen Sie es erneut.", + disconnectTitle: "{{name}} trennen?", + disconnectBody: "Die Verbindung wird hier entfernt, und der Connector ruft den Anbieter für diese Organisation nicht mehr auf, bis er erneut verbunden wird. Die Berechtigung bleibt beim Anbieter gültig, bis sie abläuft oder dort widerrufen wird.", + disconnectConfirm: "Trennen", + cancel: "Abbrechen", + loadFailed: "Connector-Verbindungen konnten nicht geladen werden.", + retry: "Erneut versuchen", + emptyTitle: "Kein Connector muss verbunden werden", + emptyBody: "Keine der installierten Apps hat einen Connector, bei dem sich ein Administrator per OAuth anmeldet.", }, setupAssignments: { positionsTitle: "Positionsinhaber", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 6254b0551e4..12d8440bee8 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5639,6 +5639,43 @@ const en = { skippedTitle: 'Skipped for now', skippedBody: 'The connectors that use them stay disconnected until each one is set.', openPage: 'Set them in Connector credentials', + connectionsHint: 'A connector that signs in to its vendor with OAuth is connected in', + connectionsLink: 'Connector connections', + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: 'Connector connections', + subtitle: 'Sign in once to each vendor your connectors call on this organization\'s behalf. The platform renews access by itself; reconnect when a vendor stops accepting the grant.', + refresh: 'Refresh', + dismiss: 'Dismiss', + colConnector: 'Connector', + colConnection: 'Connection', + colDetails: 'Details', + colActions: 'Actions', + statusConnected: 'Connected', + statusReconnect: 'Reconnect required', + statusNotConnected: 'Not connected', + instanceDegraded: 'Degraded', + connectedBy: 'Connected {{at}} by {{user}}', + connectedAt: 'Connected {{at}}', + scopes: 'Scopes: {{scopes}}', + tokenExpires: 'Access token renews by {{at}}', + waiting: 'Finish signing in in the window that opened.', + connect: 'Connect', + reconnect: 'Reconnect', + disconnect: 'Disconnect', + connected: 'Connected {{name}}.', + disconnected: 'Disconnected {{name}}.', + expired: 'The sign-in for {{name}} was not completed in time. Connect again.', + popupBlocked: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.', + disconnectTitle: 'Disconnect {{name}}?', + disconnectBody: 'The connection is removed here and the connector stops calling the vendor for this organization until it is connected again. The grant stays valid at the vendor until it expires or is revoked there.', + disconnectConfirm: 'Disconnect', + cancel: 'Cancel', + loadFailed: 'Could not load connector connections.', + retry: 'Try again', + emptyTitle: 'No connector needs connecting', + emptyBody: 'None of the installed apps has a connector that an administrator signs in to with OAuth.', }, // Setup → People & organization: Position holders and Permission set // assignment (ADR-0146 D7). diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index aa7fbedea3c..d9722ae222e 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4941,6 +4941,43 @@ const es = { skippedTitle: "Omitidas por ahora", skippedBody: "Los conectores que las usan siguen desconectados hasta que se configure cada una.", openPage: "Configúralas en Credenciales de conectores", + connectionsHint: "Un conector que inicia sesión en su proveedor con OAuth se conecta en", + connectionsLink: "Conexiones de conectores", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "Conexiones de conectores", + subtitle: "Inicia sesión una vez en cada proveedor al que tus conectores llaman en nombre de esta organización. La plataforma renueva el acceso por sí misma; vuelve a conectar cuando un proveedor deje de aceptar la autorización.", + refresh: "Actualizar", + dismiss: "Descartar", + colConnector: "Conector", + colConnection: "Conexión", + colDetails: "Detalles", + colActions: "Acciones", + statusConnected: "Conectado", + statusReconnect: "Requiere reconexión", + statusNotConnected: "No conectado", + instanceDegraded: "Degradado", + connectedBy: "Conectado {{at}} por {{user}}", + connectedAt: "Conectado {{at}}", + scopes: "Ámbitos: {{scopes}}", + tokenExpires: "El token de acceso se renueva antes de {{at}}", + waiting: "Termina de iniciar sesión en la ventana que se abrió.", + connect: "Conectar", + reconnect: "Reconectar", + disconnect: "Desconectar", + connected: "{{name}} conectado.", + disconnected: "{{name}} desconectado.", + expired: "El inicio de sesión de {{name}} no se completó a tiempo. Vuelve a conectar.", + popupBlocked: "El navegador bloqueó la ventana de inicio de sesión. Permite las ventanas emergentes para este sitio e inténtalo de nuevo.", + disconnectTitle: "¿Desconectar {{name}}?", + disconnectBody: "La conexión se elimina aquí y el conector deja de llamar al proveedor para esta organización hasta que se vuelva a conectar. La autorización sigue siendo válida en el proveedor hasta que caduque o se revoque allí.", + disconnectConfirm: "Desconectar", + cancel: "Cancelar", + loadFailed: "No se pudieron cargar las conexiones de conectores.", + retry: "Reintentar", + emptyTitle: "Ningún conector necesita conexión", + emptyBody: "Ninguna de las aplicaciones instaladas tiene un conector en el que un administrador inicie sesión con OAuth.", }, setupAssignments: { positionsTitle: "Titulares de puestos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c64a87ef6fc..51fb0927382 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4937,6 +4937,43 @@ const fr = { skippedTitle: "Ignorés pour le moment", skippedBody: "Les connecteurs qui les utilisent restent déconnectés tant que chacun n'est pas défini.", openPage: "Les définir dans Identifiants des connecteurs", + connectionsHint: "Un connecteur qui se connecte à son fournisseur avec OAuth se connecte dans", + connectionsLink: "Connexions des connecteurs", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "Connexions des connecteurs", + subtitle: "Connectez-vous une fois à chaque fournisseur que vos connecteurs appellent au nom de cette organisation. La plateforme renouvelle l'accès d'elle-même ; reconnectez-vous lorsqu'un fournisseur n'accepte plus l'autorisation.", + refresh: "Actualiser", + dismiss: "Fermer", + colConnector: "Connecteur", + colConnection: "Connexion", + colDetails: "Détails", + colActions: "Actions", + statusConnected: "Connecté", + statusReconnect: "Reconnexion requise", + statusNotConnected: "Non connecté", + instanceDegraded: "Dégradé", + connectedBy: "Connecté le {{at}} par {{user}}", + connectedAt: "Connecté le {{at}}", + scopes: "Portées : {{scopes}}", + tokenExpires: "Le jeton d'accès est renouvelé avant {{at}}", + waiting: "Terminez la connexion dans la fenêtre qui s’est ouverte.", + connect: "Connecter", + reconnect: "Reconnecter", + disconnect: "Déconnecter", + connected: "{{name}} connecté.", + disconnected: "{{name}} déconnecté.", + expired: "La connexion pour {{name}} n'a pas été terminée à temps. Connectez-vous à nouveau.", + popupBlocked: "Le navigateur a bloqué la fenêtre de connexion. Autorisez les fenêtres contextuelles pour ce site et réessayez.", + disconnectTitle: "Déconnecter {{name}} ?", + disconnectBody: "La connexion est supprimée ici et le connecteur cesse d'appeler le fournisseur pour cette organisation jusqu'à ce qu'il soit reconnecté. L'autorisation reste valide chez le fournisseur jusqu'à son expiration ou sa révocation chez lui.", + disconnectConfirm: "Déconnecter", + cancel: "Annuler", + loadFailed: "Impossible de charger les connexions des connecteurs.", + retry: "Réessayer", + emptyTitle: "Aucun connecteur à connecter", + emptyBody: "Aucune des applications installées n'a de connecteur auquel un administrateur se connecte avec OAuth.", }, setupAssignments: { positionsTitle: "Titulaires de postes", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 8fb695a3524..316e967ca35 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4819,6 +4819,43 @@ const ja = { skippedTitle: "スキップ済み", skippedBody: "これらを使うコネクタは、それぞれが設定されるまで切断されたままです。", openPage: "コネクタ認証情報で設定する", + connectionsHint: "OAuth でベンダーにサインインするコネクタの接続先:", + connectionsLink: "コネクタ接続", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "コネクタ接続", + subtitle: "コネクタがこの組織に代わって呼び出す各ベンダーに一度サインインしてください。アクセスはプラットフォームが自動で更新します。ベンダーが許可を受け付けなくなったら再接続してください。", + refresh: "更新", + dismiss: "閉じる", + colConnector: "コネクタ", + colConnection: "接続", + colDetails: "詳細", + colActions: "操作", + statusConnected: "接続済み", + statusReconnect: "再接続が必要", + statusNotConnected: "未接続", + instanceDegraded: "機能低下", + connectedBy: "{{at}} に {{user}} が接続", + connectedAt: "{{at}} に接続", + scopes: "スコープ: {{scopes}}", + tokenExpires: "アクセストークンは {{at}} までに更新", + waiting: "開いたウィンドウでサインインを完了してください。", + connect: "接続", + reconnect: "再接続", + disconnect: "切断", + connected: "{{name}} を接続しました。", + disconnected: "{{name}} を切断しました。", + expired: "{{name}} のサインインが時間内に完了しませんでした。もう一度接続してください。", + popupBlocked: "ブラウザがサインインウィンドウをブロックしました。このサイトのポップアップを許可して再試行してください。", + disconnectTitle: "{{name}} を切断しますか?", + disconnectBody: "ここで接続が削除され、再接続するまでコネクタはこの組織のためにベンダーを呼び出しません。許可は期限切れになるかベンダー側で取り消されるまで、ベンダー側では有効なままです。", + disconnectConfirm: "切断", + cancel: "キャンセル", + loadFailed: "コネクタ接続を読み込めませんでした。", + retry: "再試行", + emptyTitle: "接続が必要なコネクタはありません", + emptyBody: "インストール済みのアプリに、管理者が OAuth でサインインするコネクタはありません。", }, setupAssignments: { positionsTitle: "役職の保持者", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 9708277f28c..939dfcd101d 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4816,6 +4816,43 @@ const ko = { skippedTitle: "건너뜀", skippedBody: "이를 사용하는 커넥터는 각각 설정될 때까지 연결이 끊긴 상태로 유지됩니다.", openPage: "커넥터 자격 증명에서 설정", + connectionsHint: "OAuth로 공급업체에 로그인하는 커넥터는 다음에서 연결합니다:", + connectionsLink: "커넥터 연결", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "커넥터 연결", + subtitle: "커넥터가 이 조직을 대신해 호출하는 각 공급업체에 한 번 로그인하세요. 플랫폼이 액세스를 자동으로 갱신합니다. 공급업체가 권한을 더 이상 받아들이지 않으면 다시 연결하세요.", + refresh: "새로 고침", + dismiss: "닫기", + colConnector: "커넥터", + colConnection: "연결", + colDetails: "세부 정보", + colActions: "작업", + statusConnected: "연결됨", + statusReconnect: "다시 연결 필요", + statusNotConnected: "연결되지 않음", + instanceDegraded: "저하됨", + connectedBy: "{{at}}에 {{user}}님이 연결", + connectedAt: "{{at}}에 연결됨", + scopes: "범위: {{scopes}}", + tokenExpires: "액세스 토큰은 {{at}}까지 갱신됨", + waiting: "열린 창에서 로그인을 완료하세요.", + connect: "연결", + reconnect: "다시 연결", + disconnect: "연결 해제", + connected: "{{name}}을(를) 연결했습니다.", + disconnected: "{{name}}의 연결을 해제했습니다.", + expired: "{{name}}의 로그인이 제시간에 완료되지 않았습니다. 다시 연결하세요.", + popupBlocked: "브라우저가 로그인 창을 차단했습니다. 이 사이트의 팝업을 허용한 후 다시 시도하세요.", + disconnectTitle: "{{name}}의 연결을 해제할까요?", + disconnectBody: "여기서 연결이 제거되며, 다시 연결할 때까지 커넥터는 이 조직을 위해 공급업체를 호출하지 않습니다. 권한은 만료되거나 공급업체에서 취소될 때까지 공급업체 측에서 유효합니다.", + disconnectConfirm: "연결 해제", + cancel: "취소", + loadFailed: "커넥터 연결을 불러올 수 없습니다.", + retry: "다시 시도", + emptyTitle: "연결이 필요한 커넥터가 없습니다", + emptyBody: "설치된 앱 중 관리자가 OAuth로 로그인하는 커넥터가 없습니다.", }, setupAssignments: { positionsTitle: "직책 보유자", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index b4aa685c1f9..ab413756959 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4936,6 +4936,43 @@ const pt = { skippedTitle: "Puladas por enquanto", skippedBody: "Os conectores que as usam continuam desconectados até que cada uma seja definida.", openPage: "Defina-as em Credenciais de conectores", + connectionsHint: "Um conector que entra no fornecedor com OAuth é conectado em", + connectionsLink: "Conexões de conectores", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "Conexões de conectores", + subtitle: "Entre uma vez em cada fornecedor que seus conectores chamam em nome desta organização. A plataforma renova o acesso sozinha; reconecte quando um fornecedor deixar de aceitar a autorização.", + refresh: "Atualizar", + dismiss: "Dispensar", + colConnector: "Conector", + colConnection: "Conexão", + colDetails: "Detalhes", + colActions: "Ações", + statusConnected: "Conectado", + statusReconnect: "Reconexão necessária", + statusNotConnected: "Não conectado", + instanceDegraded: "Degradado", + connectedBy: "Conectado em {{at}} por {{user}}", + connectedAt: "Conectado em {{at}}", + scopes: "Escopos: {{scopes}}", + tokenExpires: "O token de acesso é renovado até {{at}}", + waiting: "Conclua o login na janela que foi aberta.", + connect: "Conectar", + reconnect: "Reconectar", + disconnect: "Desconectar", + connected: "{{name}} conectado.", + disconnected: "{{name}} desconectado.", + expired: "O login de {{name}} não foi concluído a tempo. Conecte novamente.", + popupBlocked: "O navegador bloqueou a janela de login. Permita pop-ups para este site e tente novamente.", + disconnectTitle: "Desconectar {{name}}?", + disconnectBody: "A conexão é removida aqui e o conector deixa de chamar o fornecedor para esta organização até ser conectado novamente. A autorização continua válida no fornecedor até expirar ou ser revogada lá.", + disconnectConfirm: "Desconectar", + cancel: "Cancelar", + loadFailed: "Não foi possível carregar as conexões de conectores.", + retry: "Tentar novamente", + emptyTitle: "Nenhum conector precisa ser conectado", + emptyBody: "Nenhum dos aplicativos instalados tem um conector em que um administrador entre com OAuth.", }, setupAssignments: { positionsTitle: "Titulares de cargos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 2fd28604007..a16138c6d8e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -5073,6 +5073,43 @@ const ru = { skippedTitle: "Пропущено пока", skippedBody: "Коннекторы, которые их используют, остаются отключёнными, пока каждое значение не будет задано.", openPage: "Задать их в разделе учётных данных коннекторов", + connectionsHint: "Коннектор, который входит к своему поставщику через OAuth, подключается в разделе", + connectionsLink: "Подключения коннекторов", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "Подключения коннекторов", + subtitle: "Войдите один раз к каждому поставщику, которого ваши коннекторы вызывают от имени этой организации. Платформа сама продлевает доступ; подключитесь заново, когда поставщик перестанет принимать разрешение.", + refresh: "Обновить", + dismiss: "Закрыть", + colConnector: "Коннектор", + colConnection: "Подключение", + colDetails: "Сведения", + colActions: "Действия", + statusConnected: "Подключено", + statusReconnect: "Требуется переподключение", + statusNotConnected: "Не подключено", + instanceDegraded: "Работает с ограничениями", + connectedBy: "Подключено {{at}}, пользователь {{user}}", + connectedAt: "Подключено {{at}}", + scopes: "Области доступа: {{scopes}}", + tokenExpires: "Токен доступа продлевается до {{at}}", + waiting: "Завершите вход в открывшемся окне.", + connect: "Подключить", + reconnect: "Переподключить", + disconnect: "Отключить", + connected: "{{name}} подключён.", + disconnected: "{{name}} отключён.", + expired: "Вход для {{name}} не был завершён вовремя. Подключитесь заново.", + popupBlocked: "Браузер заблокировал окно входа. Разрешите всплывающие окна для этого сайта и повторите попытку.", + disconnectTitle: "Отключить {{name}}?", + disconnectBody: "Подключение удаляется здесь, и коннектор перестаёт вызывать поставщика для этой организации, пока его не подключат снова. Разрешение остаётся действительным у поставщика, пока не истечёт или не будет отозвано там.", + disconnectConfirm: "Отключить", + cancel: "Отмена", + loadFailed: "Не удалось загрузить подключения коннекторов.", + retry: "Повторить", + emptyTitle: "Нет коннекторов, требующих подключения", + emptyBody: "Ни в одном из установленных приложений нет коннектора, в который администратор входит через OAuth.", }, setupAssignments: { positionsTitle: "Занимающие должность", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 7104fdad5ad..ea656bd95f4 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4876,6 +4876,43 @@ const zh = { skippedTitle: "已暂时跳过", skippedBody: "在各自设置之前,使用它们的连接器保持断开。", openPage: "在连接器凭据页面中设置", + connectionsHint: "通过 OAuth 登录供应商的连接器在以下页面连接:", + connectionsLink: "连接器连接", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "连接器连接", + subtitle: "为连接器代表本组织调用的每个供应商登录一次。平台会自动续期访问权限;当供应商不再接受授权时请重新连接。", + refresh: "刷新", + dismiss: "关闭", + colConnector: "连接器", + colConnection: "连接", + colDetails: "详情", + colActions: "操作", + statusConnected: "已连接", + statusReconnect: "需要重新连接", + statusNotConnected: "未连接", + instanceDegraded: "已降级", + connectedBy: "{{at}} 由 {{user}} 连接", + connectedAt: "{{at}} 已连接", + scopes: "授权范围:{{scopes}}", + tokenExpires: "访问令牌在 {{at}} 前续期", + waiting: "请在打开的窗口中完成登录。", + connect: "连接", + reconnect: "重新连接", + disconnect: "断开连接", + connected: "已连接 {{name}}。", + disconnected: "已断开 {{name}}。", + expired: "{{name}} 的登录未在时限内完成。请重新连接。", + popupBlocked: "浏览器拦截了登录窗口。请允许此站点弹出窗口后重试。", + disconnectTitle: "断开 {{name}}?", + disconnectBody: "此处的连接将被移除,在重新连接之前,该连接器不再代表本组织调用供应商。授权在供应商处仍然有效,直到过期或在供应商处被撤销。", + disconnectConfirm: "断开连接", + cancel: "取消", + loadFailed: "无法加载连接器连接。", + retry: "重试", + emptyTitle: "没有需要连接的连接器", + emptyBody: "已安装的应用中没有需要管理员通过 OAuth 登录的连接器。", }, setupAssignments: { positionsTitle: '岗位持有人', From 294117d3f9974cfd8a5a30e1aa34100843e8e9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:40:29 +0000 Subject: [PATCH 2/7] docs(connectors): the Console's Connector connections page; changeset Co-Authored-By: Claude --- .changeset/26702-console-connector-connections.md | 13 +++++++++++++ content/docs/automation/connectors.mdx | 9 +++++++++ 2 files changed, 22 insertions(+) create mode 100644 .changeset/26702-console-connector-connections.md diff --git a/.changeset/26702-console-connector-connections.md b/.changeset/26702-console-connector-connections.md new file mode 100644 index 00000000000..b168e25c1bd --- /dev/null +++ b/.changeset/26702-console-connector-connections.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): Connector connections, a Setup page to connect, reconnect or disconnect each OAuth connector instance and see its status + +Clause-②: no +Metadata-protocol: none + +A connector instance declared with `auth: { type: 'oauth2', grant: 'authorization_code' }` needs an administrator to sign in to the vendor once for their organization. The Console now has a page for that (`automation:connector-connections`, linked from Connector credentials). It lists every such instance with the organization's connection status as the server answers it (Not connected, Connected, or Reconnect required with the reason the server recorded), the granted scopes, who connected it and when, and when the access token renews. A `degraded` instance shows the server's reason in the same row as the connection that caused it. + +**Connect** and **Reconnect** open the vendor's sign-in in its own window, detached from the page, and send it to the authorization URL the server answered. The page re-reads the status until the connect lands, then closes the window and shows the new status. **Disconnect** removes the connection after a confirmation. No token, code or authorization URL is ever rendered. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 459798a7b87..44869b368c8 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -640,6 +640,15 @@ a rotating refresh token is never spent twice. DELETE /api/v1/automation/connectors/:name/connection → revoke ``` + **In the Console**, the **Connector connections** page drives these calls. + It lists each `authorization_code` instance with the organization's status, + the reason the server recorded and, for a `degraded` instance, the server's + reason beside the connection that caused it. **Connect** and **Reconnect** + open the vendor's sign-in in its own window, detached from the page, and the + page shows the new status once the callback has stored it; **Disconnect** + revokes after a confirmation. No Setup navigation entry names the page yet: + it is reached from the link on **Connector Credentials**. + `GET …/:name/connection` answers the organization's status (`not_connected`, `connected`, `reconnect_required`), never a token. The `state` and the PKCE verifier are bound to the user and organization that From 5986e268cc47b4b8451d7fa1527c777485070be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 11:14:01 +0000 Subject: [PATCH 3/7] fix(console): connections page notice and poll effect follow the hooks rules Co-Authored-By: Claude --- .../views/setup/ConnectorConnectionsPage.tsx | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx index 04dcf3aed23..de5c15c8272 100644 --- a/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx +++ b/ui/packages/app-shell/src/views/setup/ConnectorConnectionsPage.tsx @@ -21,7 +21,7 @@ * door's status answers carries a token, and the URL goes only to the window. */ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { AlertCircle, Info, Plug } from 'lucide-react'; import { Badge, @@ -70,9 +70,17 @@ interface Waiting { window: Window; /** The connect being waited for has landed once `connectedAt` moves off this. */ before?: string; - until: number; + /** When the begun connect stops being completable, as the server answered it. */ + until?: number; } +/** What the notice line says: a message this page words, or the server's own sentence. */ +type Notice = + | { tone: 'ok'; kind: 'connected' | 'disconnected'; name: string } + | { tone: 'error'; kind: 'expired'; name: string } + | { tone: 'error'; kind: 'popupBlocked' } + | { tone: 'error'; kind: 'refusal'; text: string }; + interface RevokeDraft { name: string; busy: boolean; @@ -89,11 +97,9 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections const displayLocale = useDisplayLocale(); const [nonce, setNonce] = useState(0); const [listing, setListing] = useState(null); - const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null); + const [notice, setNotice] = useState(null); const [waiting, setWaiting] = useState(null); const [revokeDraft, setRevokeDraft] = useState(null); - const waitingRef = useRef(null); - waitingRef.current = waiting; const reload = () => { setListing(null); @@ -118,35 +124,31 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections // A begun connect: re-read until it lands, its window closes, or it expires. useEffect(() => { if (!waiting) return; + const current = waiting; + const deadline = current.until ?? Date.now() + 10 * 60_000; let busy = false; + let cancelled = false; const timer = setInterval(() => { - const current = waitingRef.current; - if (busy || !current) return; + if (busy) return; busy = true; const closed = current.window.closed; void fetchConnection(current.name).then((read) => { busy = false; - if (waitingRef.current !== current) return; + if (cancelled) return; const landed = read.ok && read.state.status === 'connected' && read.state.connectedAt !== current.before; - const expired = Date.now() > current.until; + const expired = Date.now() > deadline; if (!landed && !closed && !expired) return; applyConnection(current.name, read); if (!current.window.closed) current.window.close(); setWaiting(null); - if (landed) { - setNotice({ tone: 'ok', text: t('connectorConnections.connected', { defaultValue: 'Connected {{name}}.', name: current.name }) }); - } else if (expired) { - setNotice({ - tone: 'error', - text: t('connectorConnections.expired', { - defaultValue: 'The sign-in for {{name}} was not completed in time. Connect again.', - name: current.name, - }), - }); - } + if (landed) setNotice({ tone: 'ok', kind: 'connected', name: current.name }); + else if (expired && !closed) setNotice({ tone: 'error', kind: 'expired', name: current.name }); }, () => { busy = false; }); }, pollMs); - return () => clearInterval(timer); + return () => { + cancelled = true; + clearInterval(timer); + }; }, [waiting, pollMs]); const connect = async (name: string, before?: string) => { @@ -154,12 +156,7 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections setNotice(null); const signIn = window.open('about:blank', SIGN_IN_WINDOW, SIGN_IN_FEATURES); if (!signIn) { - setNotice({ - tone: 'error', - text: t('connectorConnections.popupBlocked', { - defaultValue: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.', - }), - }); + setNotice({ tone: 'error', kind: 'popupBlocked' }); return; } // Detach the window before it reaches the vendor: the vendor's page gets no handle on this one. @@ -167,12 +164,12 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections const begun = await beginConnection(name); if (!begun.ok) { signIn.close(); - setNotice({ tone: 'error', text: begun.message }); + setNotice({ tone: 'error', kind: 'refusal', text: begun.message }); return; } signIn.location.href = begun.authorizationUrl; - const expiresAt = Date.parse(begun.expiresAt); - setWaiting({ name, window: signIn, before, until: Number.isNaN(expiresAt) ? Date.now() + 10 * 60_000 : expiresAt }); + const until = Date.parse(begun.expiresAt); + setWaiting({ name, window: signIn, before, ...(Number.isNaN(until) ? {} : { until }) }); }; const confirmRevoke = async () => { @@ -186,7 +183,7 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections } applyConnection(name, out); setRevokeDraft(null); - setNotice({ tone: 'ok', text: t('connectorConnections.disconnected', { defaultValue: 'Disconnected {{name}}.', name }) }); + setNotice({ tone: 'ok', kind: 'disconnected', name }); }; const formatAt = (at: string | undefined): string | null => { @@ -195,6 +192,26 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections return Number.isNaN(d.getTime()) ? at : d.toLocaleString(displayLocale); }; + const noticeText = (n: Notice): string => { + switch (n.kind) { + case 'connected': + return t('connectorConnections.connected', { defaultValue: 'Connected {{name}}.', name: n.name }); + case 'disconnected': + return t('connectorConnections.disconnected', { defaultValue: 'Disconnected {{name}}.', name: n.name }); + case 'expired': + return t('connectorConnections.expired', { + defaultValue: 'The sign-in for {{name}} was not completed in time. Connect again.', + name: n.name, + }); + case 'popupBlocked': + return t('connectorConnections.popupBlocked', { + defaultValue: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.', + }); + default: + return n.text; + } + }; + const statusLabel = (status: ConnectorConnectionState['status']) => (status === 'connected' ? t('connectorConnections.statusConnected', { defaultValue: 'Connected' }) : status === 'reconnect_required' @@ -362,7 +379,7 @@ export function ConnectorConnectionsPage({ pollMs = 2000 }: ConnectorConnections : 'flex items-start gap-2 rounded-md border border-green-500/30 bg-green-500/5 p-3 text-sm text-green-700 dark:text-green-400'} > {notice.tone === 'error' ?