Component contract
provider: ProviderDef— { name, displayName, logoUrl, oauthAuthorizeUrl, oauthCallbackPath, sandboxAvailable }connectionState: "disconnected" | "connecting" | "connected" | "revoked" | "error"— controlledlastSuccessAt?: ISOString— surfaced as "Last successful sync: 2 hours ago"connectedAccount?: { displayName, externalId }— e.g., "Connected as alice@acme.com (org-id: 00D…)" — only when connectedonConnect?: () => void— opens OAuth authorize URL in popup or redirectonDisconnect?: () => Promise— disconnect uses ui-destructive-confirmationonReconnect?: () => void— for revoked state; same as connect but copy differsfixture?: "sandbox" | "mock" | "live"— mirrors the consumer'susesIntegration.fixture; affects which OAuth URL is used (sandbox vs prod)auditLogPath?: string— when provided, shows "View audit log" linkerrorReason?: string— surfaced inline when state=error
Composition
ui-status-pill— primary state display; uses registry kinds (connected / disconnected / syncing — for in-flight reconnect / failed for error)ui-destructive-confirmation— disconnect promptui-toast— success/failure notifications after connect/disconnect/reconnectui-modal— when OAuth flow is popup-style (server returns to a callback that posts a message back to the parent window)
Interaction surface
-
Disconnected state.
Card shows provider logo + name + "Not connected" status pill + "Connect" button. Clicking opens OAuth authorize URL.
-
Connecting state (in-flight OAuth).
Status pill "Connecting…" with subtle pulse animation (respects prefers-reduced-motion). Connect button replaced by "Cancel" which closes the popup. If the popup is closed by user without completing, returns to disconnected.
-
Connected state.
Card shows provider logo + connectedAccount.displayName + "Connected" status pill + "Disconnect" button + lastSuccessAt timestamp + audit-log link. Sandbox-mode connections have a small "Sandbox" badge.
-
Revoked state.
Status pill "Revoked" (warning tone) + "Reconnect" button. Card shows when revocation was detected. Different from disconnected — preserves the previously-connected account info to make reconnection seamless.
-
Error state.
Status pill "Error" (danger tone) + errorReason inline + "Retry" button + audit-log link to investigate. Distinct from revoked — error means transient or schema-drift, revoked means provider explicitly revoked our access.
-
Disconnect.
Click Disconnect → ui-destructive-confirmation modal: "Disconnect from [provider]? Existing data syncs will stop." On confirm, onDisconnect() runs; on success, transitions to disconnected + toast "Disconnected from [provider]."
Failure modes
OAuth popup blocked
Trigger: user has popup-blocker on; click Connect; popup blocked.
Fallback: redirect to OAuth URL in same tab (with state preserved via URL params on return). Surface a one-time tip "Popups blocked — using redirect flow instead." Harness: simulate popup-blocked, assert redirect-style flow used.
OAuth user cancels
Trigger: user clicks Connect, OAuth popup opens, they close the popup without authorizing.
Card returns to disconnected state. No error toast (cancel is not a failure). Harness: stub popup close without callback, state returns to disconnected, no error toast.
OAuth callback with error
Trigger: provider returns an OAuth error (e.g., access_denied, server_error).
Card transitions to error state with errorReason from the OAuth error. Toast surfaces "[Provider] couldn't complete the connection: [reason]." Audit log row written. Harness: stub callback with error=access_denied, error state visible with reason.
Revocation detected mid-sync
Trigger: user revoked our app access at the provider; next API call returns 401 invalid_token.
Background detection: 401 with token-revoked semantics → connectionState transitions to revoked. Card UI updates within 60s (token cache TTL). Toast: "[Provider] connection was revoked at the provider." Harness: stub 401 invalid_token, state transitions to revoked, toast visible.
Reconnect preserves prior account context
Trigger: connected as alice@acme.com, revoked, user clicks Reconnect.
Reconnect flow shows "Reconnecting as alice@acme.com" — preserves the prior connected account. If the user authenticates as a different account, surface a "You connected as bob@acme.com — different from previous (alice@acme.com). Continue?" prompt. Harness: revoke + reconnect with same account = silent success; reconnect with different = prompt visible.
Sandbox vs prod isolation visible
Trigger: tenant has connected to sandbox accidentally when they meant prod.
Sandbox-mode card shows a distinct "Sandbox" badge in addition to the connection state. Disconnecting and re-connecting with mode toggle is required to switch — UI doesn't allow silent switch. Harness: fixture=sandbox, badge visible; switch to live, requires disconnect-then-reconnect.
Disconnect-during-active-sync
Trigger: user clicks Disconnect while a sync job (managed by ui-async-job-admin-page) is in-flight.
Disconnect prompt warns: "A sync is currently running. Disconnecting will cancel it." User can cancel-the-disconnect or proceed (which cancels both). Harness: stub running sync, click Disconnect, warning visible.
Audit log link permission-gated
Trigger: user lacks permission to view tenant audit log.
auditLogPath is provided but the user lacks audit:read ability. Link hidden client-side; if accessed directly, returns 403. Harness: stub permission false, link hidden.
Provider rate-limit on connect
Trigger: user clicks Connect repeatedly (e.g., spamming during a hang).
Per-tenant rate-limit on OAuth-initiate calls (5 per minute). After exceeded, "Too many attempts — try again in 60s" inline. Harness: 6 connect clicks in 60s, 6th surfaces rate-limit message.
Cross-tenant OAuth state forgery
Trigger: attacker tries to forge an OAuth state parameter to associate a connection to wrong tenant.
OAuth state is HMAC-signed with per-tenant secret + nonce. Forged state fails callback validation; returns 404 (anti-probing). Harness: forge state, callback returns 404, no connection created.
Schema drift in provider response
Trigger: provider changed their /me endpoint response shape; connectedAccount.displayName comes back null.
Card renders fallback "Connected (account info unavailable)". Inherits schema-drift-degrades-gracefully predicate from P2 vocabulary. Harness: stub null displayName, fallback rendered without error.
Accessibility
- Card is a
<article>witharia-labelledbypointing at the provider name heading. - Status changes announced via aria-live=polite (inherited from ui-status-pill).
- OAuth popup return announces "Connected to [provider]" via aria-live region.
- Disconnect modal inherits ui-destructive-confirmation a11y.
- Color contrast: provider logo placement ensures background contrast ≥ 4.5:1; status pill inherits its registry contrast.
- axe-clean ≥ serious across all states.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-oauth-connection-card | Outer wrapper | data-state attr; data-provider attr |
ui-oauth-connection-card-logo | Provider logo | Always rendered |
ui-oauth-connection-card-status-pill | Status display | ui-status-pill instance |
ui-oauth-connection-card-account | Connected account info | Visible only when connected |
ui-oauth-connection-card-last-success | "Last successful sync" | Visible when lastSuccessAt provided |
ui-oauth-connection-card-sandbox-badge | Sandbox indicator | Visible only when fixture=sandbox |
ui-oauth-connection-card-connect-button | Connect CTA | Visible only in disconnected state |
ui-oauth-connection-card-disconnect-button | Disconnect CTA | Visible only in connected state |
ui-oauth-connection-card-reconnect-button | Reconnect CTA | Visible only in revoked state |
ui-oauth-connection-card-retry-button | Retry CTA | Visible only in error state |
ui-oauth-connection-card-error-reason | Inline error | Visible only in error state |
ui-oauth-connection-card-audit-log-link | "View audit log" | Visible when auditLogPath + audit:read |
ui-oauth-connection-card-rate-limit-message | Inline rate-limit warning | Visible after 5+ connect attempts in 60s |
ui-oauth-connection-card-different-account-prompt | Different-account-on-reconnect | Modal/inline prompt |
ui-oauth-connection-card-active-sync-warning | Disconnect-during-sync warning | Inside ui-destructive-confirmation |
Agent test plan
Standalone probes against /admin-test/ui-oauth-connection-card-fixture. Variants: each connectionState; each fixture mode (sandbox/mock/live).
Probe list
- disconnected-state-shows-connect: connect button visible, others hidden
- connecting-state-shows-cancel: pulsing animation, cancel button visible
- connected-state-shows-account: displayName + lastSuccess + disconnect visible
- revoked-state-shows-reconnect: reconnect visible, account info preserved
- error-state-shows-reason-and-retry: errorReason visible, retry button visible
- sandbox-badge-only-when-sandbox: fixture=sandbox → badge visible
- popup-blocked-falls-back-to-redirect: stub popup blocked, redirect-flow used
- oauth-cancel-no-error: stub popup closed without callback, no error toast
- oauth-error-callback: stub error callback, state transitions to error
- revocation-detected-on-401: stub 401 invalid_token, state transitions to revoked
- reconnect-same-account-silent-success: stub same account on reconnect, no prompt
- reconnect-different-account-prompts: stub different account, prompt visible
- sandbox-prod-isolation: switch fixture, requires disconnect-reconnect
- disconnect-during-sync-warns: stub running sync, disconnect prompt has warning
- audit-log-link-permission-gated: stub no audit:read, link hidden
- connect-rate-limit: 6 clicks in 60s, 6th shows rate-limit message
- cross-tenant-state-forgery-404: forge state, callback 404
- schema-drift-graceful: stub null displayName, fallback "Connected (account info unavailable)"
- aria-live-status-announce: state change, aria-live announces transition
- color-contrast-status-pill: ≥ 4.5:1 inherited
- axe-clean-serious: across all states
Current consumers
| Branch | Provider | Fixture mode |
|---|---|---|
| EF-038 canvas-external-assets | Dropbox + Google Drive | live |
| EF-092 salesforce-connection | Salesforce | sandbox / live |
| EF-095 zoom-integration | Zoom | live |
| EF-096 zoom-settings-repair | Zoom | sandbox / live |
| EF-097 zapier-app | Zapier | mock |
Each consumer's usesIntegration envelope's fixture field maps directly to the card's fixture prop. The card centralizes the OAuth UX while the envelope centralizes the contract metadata. Together they form the integration surface contract.