Skip to main content

Client Sources page β€” Nango connect UX (slack_search first)

Date: 2026-07-22 Status: Design β€” pending review Branch: feat/client-sources-ui Follows: 2026-07-21-nango-slack-search-phase1-design.md (backend shipped in PR #4675); completes its deferred UX slice.

1. Goal & architectural framing​

Give client admins a self-serve UI to connect data sources β€” starting with Nango-brokered Slack workspace search (slack_search) β€” completing the e2e flow whose backend shipped in PR #4675.

Channels vs Sources is a deliberate architectural split, now reflected in the product's information architecture:

  • Channels = means of communication (Slack bot, Email, Telegram, WhatsApp, Teams). Inbound webhooks + outbound dispatch. Lives at /client/settings/channels.
  • Sources = where the Specialist AI finds data (Nango-brokered reads: slack_search now; notion, google_drive, quickbooks, xero later). Credential-less toward the runtime β€” tokens live in Nango, the executor resolves the connection server-side from run.orgId. Lives at a NEW /client/settings/sources.

Slack appears in both β€” intentionally. Different grant (bot token vs user token search:read), different broker (bespoke OAuth vs Nango), different trust path. Connecting Slack twice is honest, not redundant.

2. Scope (approved decisions)​

  1. Client connect flow only. Ops gates (SA tool-permission, slack_search_enabled flag, skill/binding publish) stay runbook-driven (docs/integrations/NANGO_SETUP.md); ops smoothing is a follow-up issue.
  2. Generic Sources page β€” tile per Nango provider the SA has enabled for the org; slack_search is the only enabled provider in practice today. No hardcoding to one provider.
  3. Connect via Nango Connect SDK popup (@nangohq/frontend + openConnectUI({ sessionToken })), pairing with the existing backend Connect Session endpoints. Fallback if the self-hosted Nango can't serve the Connect UI browser-side: full-page redirect (same page/API design, different click handler).
  4. Out of scope: quickbooks/xero connect UX migration, per-tile test button, "available but locked" upsell tiles, Playwright e2e (follow-up), any change to the bespoke Slack channel path.

3. Backend β€” one new read endpoint​

GET /orgs/:orgId/sources (in api/src/integrations/credentials/, guarded like the integrations GET: org roles, member/billing may read). Returns one entry per provider in NANGO_CONNECT_PROVIDERS that is SA-enabled for the org:

[{
"provider": "slack_search",
"displayName": "Slack Search",
"status": "connected", // connected | not_connected
"connectedAt": "2026-07-22T…",
"scopes": ["search:read"] // non-secret, from _meta.nango_scopes only
}]
  • enabled filter: ToolPermissionsService.listByOrg(orgId) through a small providerβ†’toolName map (slack_searchβ†’slack_search; others map to *_query when surfaced). Disabled providers are omitted entirely (deny-by-default stays invisible to the client).
  • status: existing IntegrationCredential lookup (org_id, integration_type = provider). Only the non-secret _meta.nango_scopes crosses the boundary (via readCredConfig, #551).
  • Cross-org access β†’ uniform generic 403 (OrgRolesGuard, #3273 β€” response identical whether the org exists, isn't yours, or the role is insufficient; the #1472 404 rule applies to by-id resource lookups, none here).

No other backend changes. Connect/confirm/revoke reuse shipped endpoints: POST /v1/integrations/nango/session, POST /v1/integrations/credentials/nango/confirm (idempotent upsert on UNIQUE(org_id, integration_type), re-verifies via getConnection), DELETE /orgs/:orgId/integrations/:id (Nango deleteConnection

  • local row removal). No new tables, no migration. The slack_search_enabled feature flag is NOT part of this page's contract β€” it gates agent advertisement, not connection.

4. Frontend​

New page frontend/src/app/client/settings/sources/page.tsx; "Sources" nav item beside "Channels" in client settings. Tile list + row states mirror the channels page conventions.

lib/api.ts additions: getSources(orgId), createNangoConnectSession(provider), confirmNangoConnection(provider, connectionId) (first frontend wiring of the two existing endpoints).

Connect interaction β€” @nangohq/frontend, wrapped once in lib/nango.ts, which reads the Nango host from new env NEXT_PUBLIC_NANGO_CONNECT_URL (per-env, browser-reachable):

  1. "Connect" β†’ POST /v1/integrations/nango/session β†’ {sessionToken, connectionId}.
  2. nango.openConnectUI({ sessionToken }) β†’ Slack consent in Nango-hosted popup.
  3. SDK success event β†’ POST …/nango/confirm β†’ refetch β†’ tile shows Connected (+ connect date, + Disconnect).
  4. Popup close/error β†’ tile stays "Available"; non-blocking feedback.

Disconnect: confirm dialog (copy states the AI loses Slack search access) β†’ existing DELETE β†’ refetch.

Tile copy carries the token-identity guidance (Phase 1 spec Β§2): "Connect as a dedicated service user β€” search sees only what that user can see."

5. Errors & edge cases​

CaseBehavior
Session mint fails (Nango down/misconfigured)Toast "Couldn't start the connection β€” try again later." Nothing persisted; retry-safe.
Consent OK but confirm fails (tab closed, blip)Token already safe in Nango; only the local row is missing. Tile shows "Finish connection" which re-calls confirm (idempotent). No orphan cleanup needed.
Re-connect while connectedNango generates a NEW connection id for the second consent (pinning is forbidden with a session token) β€” confirm verifies the new id belongs to this org, stores it, and best-effort deletes the previously-stored connection at Nango so the old one doesn't orphan. This IS the rotate-to-service-user path β€” no dedicated UI affordance this iteration: rotation = Disconnect β†’ Connect (brief search-dead window); a one-click "Reconnect" is a Β§8 follow-up.
Popup blockedInline hint to allow popups (not a toast).
Disconnect fails at NangoExisting contract kept: ServiceUnavailableException "Could not revoke at provider"; local row stays; visible error. Never silently pretend revoked.
Cross-org orgIdUniform generic 403 (OrgRolesGuard, #3273).

6. Testing​

  • API spec (unit lane, sqlite-safe): GET /sources returns only SA-enabled providers; connected/not_connected mapping; cross-org 404; member can read; session/confirm remain owner/admin-only (already enforced β€” assert unchanged).
  • Lockstep: credentials.nango-provider-lockstep.spec already guards DTO↔service provider drift; the new endpoint reads NANGO_CONNECT_PROVIDERS directly, so no new drift surface.
  • Frontend: npm run build; verify from the rendered UI on dev against the real Railway-hosted Nango (rendered-UI rule).
  • Pilot check (from the enablement plan): with all org gates on, confirm the agent calls slack_search with a query in a live turn.

7. Prerequisites / verification before UI work​

  1. Confirm the self-hosted Nango version ships the Connect UI and NEXT_PUBLIC_NANGO_CONNECT_URL is browser-reachable in dev (h852.work env). If not β†’ fallback to the redirect variant (Β§2.3).
  2. One-time HP Slack app setup per environment (runbook Β§"One-time HP setup"): user scope search:read, Nango callback URL, public distribution, Nango integration key slack_search.

8. Follow-up issues (not this iteration)​

  • Ops one-click enablement (tool-permission + flag together on /ops/clients/:id).
  • Per-tile connection test (gateway dry-run).
  • Playwright e2e for the connect flow.
  • Surfacing notion/google_drive/quickbooks/xero tiles as they're productized.
  • Multi-org members hit a uniform 403 on the Nango session/confirm endpoints: NangoSessionDto/ NangoConfirmDto carry no orgId and OrgRolesGuard falls back to the caller's single org membership β€” a non-superadmin user with several memberships (e.g. AMs) can't connect on behalf of an org. (SuperAdmins bypass the guard entirely and instead hit an undefined-orgId failure downstream β€” a different bug, same root cause.) Backend follow-up: accept an explicit orgId on these endpoints (guard-validated).