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_searchnow;notion,google_drive,quickbooks,xerolater). Credential-less toward the runtime β tokens live in Nango, the executor resolves the connection server-side fromrun.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)β
- Client connect flow only. Ops gates (SA tool-permission,
slack_search_enabledflag, skill/binding publish) stay runbook-driven (docs/integrations/NANGO_SETUP.md); ops smoothing is a follow-up issue. - Generic Sources page β tile per Nango provider the SA has enabled for the org;
slack_searchis the only enabled provider in practice today. No hardcoding to one provider. - 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). - 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
}]
enabledfilter:ToolPermissionsService.listByOrg(orgId)through a small providerβtoolName map (slack_searchβslack_search; others map to*_querywhen surfaced). Disabled providers are omitted entirely (deny-by-default stays invisible to the client).status: existingIntegrationCredentiallookup (org_id,integration_type = provider). Only the non-secret_meta.nango_scopescrosses the boundary (viareadCredConfig, #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_enabledfeature 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):
- "Connect" β
POST /v1/integrations/nango/sessionβ{sessionToken, connectionId}. nango.openConnectUI({ sessionToken })β Slack consent in Nango-hosted popup.- SDK success event β
POST β¦/nango/confirmβ refetch β tile shows Connected (+ connect date, + Disconnect). - 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β
| Case | Behavior |
|---|---|
| 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 connected | Nango 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 blocked | Inline hint to allow popups (not a toast). |
| Disconnect fails at Nango | Existing contract kept: ServiceUnavailableException "Could not revoke at provider"; local row stays; visible error. Never silently pretend revoked. |
Cross-org orgId | Uniform generic 403 (OrgRolesGuard, #3273). |
6. Testingβ
- API spec (unit lane, sqlite-safe):
GET /sourcesreturns 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.specalready guards DTOβservice provider drift; the new endpoint readsNANGO_CONNECT_PROVIDERSdirectly, 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_searchwith aqueryin a live turn.
7. Prerequisites / verification before UI workβ
- Confirm the self-hosted Nango version ships the Connect UI and
NEXT_PUBLIC_NANGO_CONNECT_URLis browser-reachable in dev (h852.workenv). If not β fallback to the redirect variant (Β§2.3). - One-time HP Slack app setup per environment (runbook Β§"One-time HP setup"):
user scope
search:read, Nango callback URL, public distribution, Nango integration keyslack_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/NangoConfirmDtocarry noorgIdandOrgRolesGuardfalls 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-orgIdfailure downstream β a different bug, same root cause.) Backend follow-up: accept an explicitorgIdon these endpoints (guard-validated).