Skip to main content

Client Sources Page (Nango Connect UX) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Client-facing /client/settings/sources page where an org admin connects Nango-brokered data sources (slack_search first) via the Nango Connect popup, completing the e2e flow whose backend shipped in PR #4675.

Architecture: One new read endpoint GET /orgs/:orgId/sources (new SourcesService + SourcesController in api/src/integrations/credentials/) returning SA-enabled Nango providers with connection status. Frontend: new settings page + nav item; connect = existing POST /v1/integrations/nango/session β†’ @nangohq/frontend openConnectUI popup β†’ existing POST /v1/integrations/credentials/nango/confirm; disconnect = existing DELETE /orgs/:orgId/integrations/:id.

Tech Stack: NestJS 11 (api), Next.js 16 / React 19 (frontend), @nangohq/frontend (new dep), Jest.

Spec: docs/superpowers/specs/2026-07-22-client-sources-page-design.md

Worktree / branch: /home/alexdel/.config/superpowers/worktrees/humanwork/client-sources-ui / feat/client-sources-ui

Commands (run from worktree root unless stated): API build cd api && npm run build; API test cd api && npx jest <path> --runInBand --maxWorkers=2; frontend build cd frontend && npm run build; frontend test cd frontend && npx jest <path>.

House rules that bind this plan:

  • ALL API-side reads of IntegrationCredential.encryptedConfig go through readCredConfig(cred) (api/src/common/crypto.ts) β€” never read the field directly (#551).
  • Cross-org by-id reads return 404, not 403 (#1472). The OrgRolesGuard handles org-membership mismatch; do not add custom 403s. (Note: OrgRolesGuard itself responds with a uniform generic 403 per #3273 β€” that's correct for route-level org gating; the 404 rule applies to by-id resource lookups.)
  • One full local test run at a time, --maxWorkers=2.
  • Never cite an issue number you weren't given.

Task 1: SourcesService.listForOrg (backend read model)​

The service computes: for each provider in NANGO_CONNECT_PROVIDERS, is the mapped tool SA-enabled for the org (tool_permissions), and is there an IntegrationCredential row (connected). Disabled providers are omitted.

Files:

  • Create: api/src/integrations/credentials/sources.service.ts

  • Test: api/src/integrations/credentials/__tests__/sources.service.spec.ts

  • Step 1: Write the failing test

// api/src/integrations/credentials/__tests__/sources.service.spec.ts
import { SourcesService, SOURCE_TOOL_BY_PROVIDER } from '../sources.service';

describe('SourcesService.listForOrg', () => {
const ORG_ID = '55555555-5555-5555-5555-555555555555';

function build(overrides: {
permissions?: Array<{ toolName: string; enabled: boolean }>;
creds?: Array<{ id: string; integrationType: string; createdAt: Date; encryptedConfig: Record<string, any> }>;
}) {
const toolPermissions = {
listByOrg: jest.fn().mockResolvedValue(overrides.permissions ?? []),
} as any;
const credRepo = {
find: jest.fn().mockResolvedValue(overrides.creds ?? []),
} as any;
return new SourcesService(toolPermissions, credRepo);
}

it('omits providers whose mapped tool is not SA-enabled', async () => {
const service = build({ permissions: [] });
expect(await service.listForOrg(ORG_ID)).toEqual([]);
});

it('returns not_connected for an enabled provider with no credential row', async () => {
const service = build({
permissions: [{ toolName: 'slack_search', enabled: true }],
});
const rows = await service.listForOrg(ORG_ID);
expect(rows).toEqual([
{
provider: 'slack_search',
displayName: 'Slack Search',
status: 'not_connected',
connectedAt: null,
scopes: [],
},
]);
});

it('returns connected + connectedAt + scopes when a credential row exists', async () => {
const createdAt = new Date('2026-07-22T10:00:00Z');
const service = build({
permissions: [{ toolName: 'slack_search', enabled: true }],
creds: [
{
id: '66666666-6666-6666-6666-666666666666',
integrationType: 'slack_search',
createdAt,
// plaintext passthrough β€” MASTER_ENCRYPTION_KEY unset in unit tests,
// so readCredConfig returns this object as-is
encryptedConfig: {
nango_connection_id: 'acme-slack-search',
_meta: { nango_scopes: ['search:read'] },
},
},
],
});
const rows = await service.listForOrg(ORG_ID);
expect(rows).toEqual([
{
provider: 'slack_search',
displayName: 'Slack Search',
status: 'connected',
connectedAt: createdAt.toISOString(),
scopes: ['search:read'],
},
]);
});

it('ignores enabled tools that are not Nango source tools (e.g. order_lookup)', async () => {
const service = build({
permissions: [{ toolName: 'order_lookup', enabled: true }],
});
expect(await service.listForOrg(ORG_ID)).toEqual([]);
});

it('maps every NANGO_CONNECT_PROVIDER to a tool name (map completeness guard)', () => {
// If a provider is added to NANGO_CONNECT_PROVIDERS without a map entry,
// this fails at review time instead of silently hiding the tile forever.
for (const provider of Object.keys(SOURCE_TOOL_BY_PROVIDER)) {
expect(typeof SOURCE_TOOL_BY_PROVIDER[provider as keyof typeof SOURCE_TOOL_BY_PROVIDER]).toBe('string');
}
expect(Object.keys(SOURCE_TOOL_BY_PROVIDER).sort()).toEqual(
['google_drive', 'notion', 'quickbooks', 'slack_search', 'xero'].sort(),
);
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest src/integrations/credentials/__tests__/sources.service.spec.ts --runInBand Expected: FAIL β€” Cannot find module '../sources.service'

  • Step 3: Write the implementation
// api/src/integrations/credentials/sources.service.ts
/**
* SourcesService β€” client-facing read model for the /client/settings/sources
* page. "Sources" are Nango-brokered DATA-SOURCE connections (where the
* Specialist AI finds data: slack_search, notion, …), architecturally distinct
* from CHANNELS (means of communication). See
* docs/superpowers/specs/2026-07-22-client-sources-page-design.md.
*
* ADR-020: reads integration_credentials (per-Org resource) filtered by orgId;
* no Specialist scoping applies (connections are per-Org by design β€” root
* CLAUDE.md isolation matrix row "Client Slack-search token").
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { IntegrationCredential } from '../../common/entities';
import { readCredConfig } from '../../common/crypto';
import { ToolPermissionsService } from '../../tools/tool-permissions.service';
import { NANGO_CONNECT_PROVIDERS, NangoConnectProvider } from './credentials.dto';

/**
* Which agent-tool name gates each connectable provider. A provider appears on
* the client Sources page only when the SA has enabled its tool for the org
* (tool_permissions row, deny-by-default for Nango tools). Must cover every
* entry of NANGO_CONNECT_PROVIDERS (guarded by sources.service.spec).
*/
export const SOURCE_TOOL_BY_PROVIDER: Record<NangoConnectProvider, string> = {
slack_search: 'slack_search',
quickbooks: 'quickbooks_query',
xero: 'xero_query',
notion: 'notion_query',
google_drive: 'google_drive_query',
};

const SOURCE_DISPLAY_NAME: Record<NangoConnectProvider, string> = {
slack_search: 'Slack Search',
quickbooks: 'QuickBooks',
xero: 'Xero',
notion: 'Notion',
google_drive: 'Google Drive',
};

export interface SourceResponseDto {
provider: NangoConnectProvider;
displayName: string;
status: 'connected' | 'not_connected';
/** ISO timestamp of the credential row creation; null when not connected. */
connectedAt: string | null;
/** Non-secret OAuth scopes captured at confirm time ( _meta.nango_scopes ). */
scopes: string[];
}

@Injectable()
export class SourcesService {
constructor(
private readonly toolPermissions: ToolPermissionsService,
@InjectRepository(IntegrationCredential)
private readonly credRepo: Repository<IntegrationCredential>,
) {}

async listForOrg(orgId: string): Promise<SourceResponseDto[]> {
const permissions = await this.toolPermissions.listByOrg(orgId);
const enabledTools = new Set(
permissions.filter((p) => p.enabled).map((p) => p.toolName),
);

const visible = NANGO_CONNECT_PROVIDERS.filter((provider) =>
enabledTools.has(SOURCE_TOOL_BY_PROVIDER[provider]),
);
if (visible.length === 0) return [];

const creds = await this.credRepo.find({
where: { orgId, integrationType: In([...visible]) },
});
const credByType = new Map(creds.map((c) => [c.integrationType, c]));

return visible.map((provider) => {
const cred = credByType.get(provider);
if (!cred) {
return {
provider,
displayName: SOURCE_DISPLAY_NAME[provider],
status: 'not_connected' as const,
connectedAt: null,
scopes: [],
};
}
// #551: never read encryptedConfig directly β€” readCredConfig decrypts.
const config = readCredConfig(cred);
const scopes = Array.isArray(config?._meta?.nango_scopes)
? (config._meta.nango_scopes as string[])
: [];
return {
provider,
displayName: SOURCE_DISPLAY_NAME[provider],
status: 'connected' as const,
connectedAt: cred.createdAt ? new Date(cred.createdAt).toISOString() : null,
scopes,
};
});
}
}

Note: if readCredConfig's actual signature in api/src/common/crypto.ts takes the config object rather than the credential row (check the file β€” Shopify/Amazon call sites at shopify.service.ts:61, amazon.service.ts:82 are the reference), adapt the call to match; the test asserts behavior, not the helper's shape.

  • Step 4: Run test to verify it passes

Run: cd api && npx jest src/integrations/credentials/__tests__/sources.service.spec.ts --runInBand Expected: PASS (5 tests)

  • Step 5: Commit
git add api/src/integrations/credentials/sources.service.ts api/src/integrations/credentials/__tests__/sources.service.spec.ts
git commit -m "feat(sources): SourcesService β€” SA-enabled Nango providers + connection status"

Task 2: SourcesController β€” GET /orgs/:orgId/sources + module wiring​

Files:

  • Create: api/src/integrations/credentials/sources.controller.ts

  • Modify: api/src/integrations/credentials/credentials.module.ts (register controller + service; import ToolsModule)

  • Test: api/src/integrations/credentials/__tests__/sources.controller.spec.ts

  • Step 1: Write the failing test

// api/src/integrations/credentials/__tests__/sources.controller.spec.ts
import { SourcesController } from '../sources.controller';

describe('SourcesController', () => {
it('delegates to SourcesService.listForOrg with the path orgId', async () => {
const service = { listForOrg: jest.fn().mockResolvedValue([{ provider: 'slack_search' }]) } as any;
const controller = new SourcesController(service);
const result = await controller.list('55555555-5555-5555-5555-555555555555');
expect(service.listForOrg).toHaveBeenCalledWith('55555555-5555-5555-5555-555555555555');
expect(result).toEqual([{ provider: 'slack_search' }]);
});

it('allows read for member/billing roles (decorator contract, mirrors #2274)', () => {
// Assert the roles metadata on the handler so a future tightening is loud.
const roles = Reflect.getMetadata('orgRoles', SourcesController.prototype.list);
expect(roles).toEqual(expect.arrayContaining(['owner', 'admin', 'member', 'billing']));
});
});

Note: the metadata key in the second test must match what OrgRoles actually sets β€” open api/src/auth/roles.guard.ts, find the SetMetadata(<KEY>, roles) constant, and use that exact key. If the decorator uses a non-exported symbol/constant, import it; if that's not cleanly possible, replace the second test with one that asserts Reflect.getMetadataKeys(...).length > 0 is not silently empty β€” do NOT skip the delegation test.

  • Step 2: Run test to verify it fails

Run: cd api && npx jest src/integrations/credentials/__tests__/sources.controller.spec.ts --runInBand Expected: FAIL β€” Cannot find module '../sources.controller'

  • Step 3: Write the controller
// api/src/integrations/credentials/sources.controller.ts
/**
* SourcesController β€” client-facing list of connectable data sources.
*
* Routes:
* GET /orgs/:orgId/sources β€” SA-enabled Nango providers + connection status
*
* Auth: all org roles may read (mirrors the #2274 read-only Channels view);
* there are no mutating routes here β€” connect/confirm/revoke live on the
* existing Nango + integrations controllers.
* Cross-org access is rejected by OrgRolesGuard with the uniform generic 403
* (#3273 β€” response identical whether the org exists, isn't yours, or the
* role is insufficient, so nothing is enumerable). No by-id sub-routes here,
* so the #1472 404 rule doesn't apply.
*/
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { OrgRolesGuard, OrgRoles } from '../../auth/roles.guard';
import { SourcesService, SourceResponseDto } from './sources.service';

@Controller('orgs/:orgId/sources')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
export class SourcesController {
constructor(private readonly sourcesService: SourcesService) {}

@Get()
@OrgRoles('owner', 'admin', 'member', 'billing')
async list(@Param('orgId') orgId: string): Promise<SourceResponseDto[]> {
return this.sourcesService.listForOrg(orgId);
}
}
  • Step 4: Wire into the module

In api/src/integrations/credentials/credentials.module.ts:

  • add SourcesService to providers, SourcesController to controllers;

  • ensure ToolsModule (exports ToolPermissionsService, see api/src/tools/tools.module.ts:24) is in imports β€” check first; the module may already import it for ToolPermissionsGuard. If adding it creates a circular import, use forwardRef(() => ToolsModule) and note why in a comment.

  • IntegrationCredential must be in the module's TypeOrmModule.forFeature([...]) array (it already is β€” CredentialsService uses the same repo; verify, don't duplicate).

  • Step 5: Run tests + build

Run: cd api && npx jest src/integrations/credentials/ --runInBand && npm run build Expected: all credentials specs PASS; build clean.

  • Step 6: Commit
git add api/src/integrations/credentials/sources.controller.ts api/src/integrations/credentials/__tests__/sources.controller.spec.ts api/src/integrations/credentials/credentials.module.ts
git commit -m "feat(sources): GET /orgs/:orgId/sources β€” client-facing source list endpoint"

Task 3: Frontend API client β€” getSources / createNangoConnectSession / confirmNangoConnection​

Files:

  • Modify: frontend/src/lib/api.ts (add next to getIntegrations, ~line 2052)

  • Step 1: Add types + functions

// frontend/src/lib/api.ts β€” place directly after getIntegrations()

/** A connectable data source (Nango-brokered), from GET /orgs/:orgId/sources. */
export interface SourceInfo {
provider: "slack_search" | "quickbooks" | "xero" | "notion" | "google_drive";
displayName: string;
status: "connected" | "not_connected";
connectedAt: string | null;
scopes: string[];
}

export async function getSources(orgId: string): Promise<SourceInfo[]> {
return apiFetch<SourceInfo[]>(`/orgs/${orgId}/sources`);
}

/**
* Mint a Nango Connect Session for the given provider. The returned
* sessionToken drives the @nangohq/frontend Connect popup; connectionId is the
* server-derived deterministic id ({org_slug}-{provider}) the confirm step
* echoes back.
*/
export async function createNangoConnectSession(
provider: SourceInfo["provider"],
): Promise<{ sessionToken: string; connectionId: string }> {
return apiFetch<{ sessionToken: string; connectionId: string }>(
`/v1/integrations/nango/session`,
{ method: "POST", body: JSON.stringify({ provider }) },
);
}

/** Finalize a Nango connection after the Connect popup reports success. */
export async function confirmNangoConnection(
provider: SourceInfo["provider"],
nangoConnectionId: string,
): Promise<Integration> {
return apiFetch<Integration>(`/v1/integrations/credentials/nango/confirm`, {
method: "POST",
body: JSON.stringify({ provider, nango_connection_id: nangoConnectionId }),
});
}

Before writing, open frontend/src/lib/api.ts and copy the exact apiFetch POST idiom used by a neighbouring POST helper (e.g. how it sets method/body/headers) β€” match it exactly rather than the sketch above if they differ. Note the backend routes: the Nango endpoints are v1/integrations/... (global prefix rules apply β€” verify with grep -n "nango/session" api/src/integrations/credentials/credentials.controller.ts and mirror whatever path the existing controller actually exposes).

  • Step 2: Build check

Run: cd frontend && npx tsc --noEmit -p tsconfig.json 2>&1 | head -20 (or npm run build if the project has no standalone typecheck) Expected: no new errors.

  • Step 3: Commit
git add frontend/src/lib/api.ts
git commit -m "feat(sources): frontend API client for sources list + Nango session/confirm"

Task 4: Nango Connect wrapper (lib/nango.ts) + dependency + env​

Files:

  • Create: frontend/src/lib/nango.ts

  • Modify: frontend/package.json (via npm install @nangohq/frontend)

  • Step 1: Install the SDK

Run: cd frontend && npm install @nangohq/frontend Expected: added to dependencies in package.json.

  • Step 2: Read the SDK's actual API before writing the wrapper

Run: sed -n '1,120p' frontend/node_modules/@nangohq/frontend/dist/index.d.ts (adjust path if the package layout differs β€” check frontend/node_modules/@nangohq/frontend/package.json types field).

Confirm: the constructor options (self-hosted host option name), the openConnectUI signature, its event payload types (success/close event names), and how the session token is supplied (constructor, method arg, or setSessionToken). Adapt the wrapper below to the real API β€” this is the one step where the plan's code is a template, not gospel (bleeding-edge dep; per house rule, read node_modules docs before coding).

  • Step 3: Write the wrapper
// frontend/src/lib/nango.ts
/**
* Thin wrapper around @nangohq/frontend's Connect UI so the Sources page
* doesn't couple to SDK specifics. Self-hosted Nango: the Connect UI must be
* reachable from the client's BROWSER β€” NEXT_PUBLIC_NANGO_CONNECT_URL is the
* per-env public URL of our Nango server (spec Β§7 prerequisite).
*/
import Nango from "@nangohq/frontend";

const NANGO_CONNECT_URL = process.env.NEXT_PUBLIC_NANGO_CONNECT_URL ?? "";

export function nangoConfigured(): boolean {
return NANGO_CONNECT_URL.length > 0;
}

/**
* Open the Nango Connect popup for a minted session token.
* Resolves true when the user completed the OAuth consent (caller then hits
* the confirm endpoint), false when the popup was closed/cancelled.
* Rejects on SDK-level errors (popup blocked, misconfiguration).
*/
export function openNangoConnect(sessionToken: string): Promise<boolean> {
return new Promise((resolve, reject) => {
try {
const nango = new Nango({ host: NANGO_CONNECT_URL });
const connect = nango.openConnectUI({
onEvent: (event: { type: string }) => {
if (event.type === "connect") resolve(true);
if (event.type === "close") resolve(false);
},
});
connect.setSessionToken(sessionToken);
} catch (err) {
reject(err);
}
});
}

(Adjust option/event names per Step 2's findings. Keep the exported signature β€” openNangoConnect(sessionToken): Promise<boolean> β€” stable regardless, so Task 5 doesn't change.)

  • Step 4: Add the env var to local config + document

  • Add NEXT_PUBLIC_NANGO_CONNECT_URL= to frontend/.env.example (or the repo's equivalent env template β€” ls frontend/.env* and match convention; never commit a real URL with credentials, values are per-env in Vercel).

  • Step 5: Build check + commit

Run: cd frontend && npm run build Expected: clean.

git add frontend/src/lib/nango.ts frontend/package.json frontend/package-lock.json frontend/.env.example
git commit -m "feat(sources): Nango Connect UI wrapper + @nangohq/frontend dep"

Task 5: Sources page + nav item​

Files:

  • Create: frontend/src/app/client/settings/sources/page.tsx

  • Modify: frontend/src/app/client/settings/layout.tsx:56-59 (buildNavItems β€” add Sources to the always array after Channels; page itself is read-only for non-admins, mirroring Channels)

  • Test: frontend/src/app/client/settings/__tests__/buildNavItems.test.ts (extend)

  • Step 1: Extend the failing nav test

In buildNavItems.test.ts, extend the existing role expectations: every role that sees "Channels" also sees "Sources" (add expect(l).toContain("Sources"); beside each existing expect(l).toContain("Channels"); assertion).

  • Step 2: Run to verify it fails

Run: cd frontend && npx jest src/app/client/settings/__tests__/buildNavItems.test.ts Expected: FAIL β€” "Sources" missing.

  • Step 3: Add the nav item

In frontend/src/app/client/settings/layout.tsx, buildNavItems, always array:

const always: NavItem[] = [
{ href: "/client/settings/profile", label: "Profile" },
{ href: "/client/settings/notifications", label: "Notifications" },
{ href: "/client/settings/channels", label: "Channels" },
// Sources = data the Specialist AI can query (Nango-brokered), distinct
// from Channels = communication. Read-only for non-admins, like Channels.
{ href: "/client/settings/sources", label: "Sources" },
];
  • Step 4: Run nav test β€” passes

Run: cd frontend && npx jest src/app/client/settings/__tests__/buildNavItems.test.ts Expected: PASS.

  • Step 5: Write the page

Model markup/styling on the channels page (frontend/src/app/client/settings/channels/page.tsx β€” reuse its card/tile classNames and Toast import so the two pages read as siblings; copy the exact JSX shell of one channel tile as the starting point). Structure:

// frontend/src/app/client/settings/sources/page.tsx
"use client";

/**
* Client settings β€” Sources. Nango-brokered DATA-SOURCE connections for the
* Specialist AI (slack_search first). Architecturally distinct from Channels
* (communication): tokens live in Nango, the runtime only ever gets results.
* Spec: docs/superpowers/specs/2026-07-22-client-sources-page-design.md
*/

import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/lib/auth"; // match the channels page's exact import
import {
confirmNangoConnection,
createNangoConnectSession,
deleteIntegration, // reuse existing helper; check exact name in lib/api.ts (used by channels page Disconnect)
getIntegrations,
getSources,
type SourceInfo,
} from "@/lib/api";
import { nangoConfigured, openNangoConnect } from "@/lib/nango";
import { toast } from "@/components/shared/Toast"; // match channels page import

const SOURCE_HINTS: Record<string, string> = {
slack_search:
"Lets your Specialist search your Slack workspace for context. " +
"Connect as a dedicated service user β€” search sees only what that user can see.",
};

export default function SourcesSettingsPage() {
const { orgId, ready: authReady, orgRole, platformRole } = useAuth();
const canEdit =
platformRole === "superadmin" || orgRole === "admin" || orgRole === "owner";

const [sources, setSources] = useState<SourceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyProvider, setBusyProvider] = useState<string | null>(null);
// Consent finished but confirm failed β†’ offer "Finish connection" (spec Β§5).
const [pendingConfirm, setPendingConfirm] = useState<{
provider: SourceInfo["provider"];
connectionId: string;
} | null>(null);

const refresh = useCallback(async () => {
if (!orgId) return;
try {
setSources(await getSources(orgId));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load sources");
} finally {
setLoading(false);
}
}, [orgId]);

useEffect(() => {
if (authReady) void refresh();
}, [authReady, refresh]);

async function runConfirm(provider: SourceInfo["provider"], connectionId: string) {
try {
await confirmNangoConnection(provider, connectionId);
setPendingConfirm(null);
toast.success("Connected.");
await refresh();
} catch {
// Token is already safe in Nango; only our local row is missing.
setPendingConfirm({ provider, connectionId });
toast.error("Connected at the provider, but we couldn't finish on our side. Use β€œFinish connection” to retry.");
}
}

async function connect(provider: SourceInfo["provider"]) {
if (!canEdit) {
toast.info("Only admins / owners can manage sources.");
return;
}
setBusyProvider(provider);
try {
const { sessionToken, connectionId } = await createNangoConnectSession(provider);
const completed = await openNangoConnect(sessionToken);
if (completed) await runConfirm(provider, connectionId);
} catch {
toast.error("Couldn't start the connection β€” try again later.");
} finally {
setBusyProvider(null);
}
}

async function disconnect(source: SourceInfo) {
if (!canEdit) {
toast.info("Only admins / owners can manage sources.");
return;
}
if (!window.confirm(`Disconnect ${source.displayName}? Your Specialist will lose this data source.`)) return;
setBusyProvider(source.provider);
try {
// Resolve the credential row id for the DELETE (list is masked, ids are not secret).
const integrations = await getIntegrations(orgId!);
const row = integrations.find((i) => (i.type as string) === source.provider);
if (!row) {
await refresh(); // already gone; just resync
return;
}
await deleteIntegration(orgId!, row.id);
toast.success("Disconnected.");
await refresh();
} catch {
toast.error("Could not revoke at the provider β€” the connection is unchanged. Try again later.");
} finally {
setBusyProvider(null);
}
}

// Render: page header explaining Sources-vs-Channels in one sentence, then
// a tile per source: displayName, hint (SOURCE_HINTS), status badge,
// connectedAt when connected, and the action button:
// not_connected β†’ "Connect" (disabled if !nangoConfigured() with inline hint)
// pendingConfirm matches β†’ "Finish connection" β†’ runConfirm(...)
// connected β†’ "Disconnect"
// Empty list β†’ "No sources are enabled for your workspace yet. Your account
// manager can enable them." Copy the tile/card JSX from the channels page.
// If the Connect popup is blocked, openNangoConnect rejects β†’ the connect()
// catch shows the toast; additionally render an inline "allow popups" hint
// when the rejection's message mentions popups (best-effort string match).
return (/* tiles per the comment above, styled like channels/page.tsx */);
}

The render block is deliberately specified as behavior + copy (exact markup should be lifted from the channels page for visual consistency β€” do that, don't invent a new visual language). Check the exact names of useAuth, toast, and the delete helper in the channels page imports and match them.

  • Step 6: Build + manual smoke

Run: cd frontend && npm run build Expected: clean build, /client/settings/sources in the route list.

  • Step 7: Commit
git add frontend/src/app/client/settings/sources/page.tsx frontend/src/app/client/settings/layout.tsx frontend/src/app/client/settings/__tests__/buildNavItems.test.ts
git commit -m "feat(sources): client Sources settings page β€” Nango connect/disconnect UX"

Task 6: Docs keep-alive​

Files:

  • Modify: docs/integrations/NANGO_SETUP.md (per-client enablement Β§: step 4 now points at the UI page instead of raw curl)

  • Modify: api/src/integrations/CLAUDE.md (Structure list: add sources.controller.ts/sources.service.ts one-liner β€” client-facing read model, ADR-020 per-Org row)

  • Modify: frontend/CLAUDE.md (routes section, if it enumerates settings pages β€” check; add /client/settings/sources where /client/settings/channels is mentioned)

  • Step 1: Update the three docs

In NANGO_SETUP.md, replace the step-4 curl instructions ("Client admin connects: POST /v1/integrations/nango/session…") with:

4. Client admin connects in the UI: **/client/settings/sources β†’ Slack Search β†’ Connect**
(Nango Connect popup β†’ Slack consent). Requires `NEXT_PUBLIC_NANGO_CONNECT_URL` set for
the environment and the Nango server browser-reachable. The raw API flow
(`POST /v1/integrations/nango/session` β†’ consent β†’ `POST /v1/integrations/credentials/nango/confirm`)
remains available for scripting.
Recommend connecting a **dedicated service user** so search visibility is broad and stable.
  • Step 2: Commit
git add docs/integrations/NANGO_SETUP.md api/src/integrations/CLAUDE.md frontend/CLAUDE.md
git commit -m "docs(sources): client Sources page β€” runbook + module docs"

Task 7: Full verification​

  • Step 1: API β€” affected suites + build

Run: cd api && npx jest src/integrations/ --runInBand --maxWorkers=2 && npm run build Expected: PASS + clean build. (npm run build is the CI-equivalent strict check β€” tsc --noEmit under-reports; api/CLAUDE.md.)

  • Step 2: Frontend β€” tests + build

Run: cd frontend && npx jest src/app/client/settings && npm run build Expected: PASS + clean build.

  • Step 3: Rendered-UI verification on dev (house rule: verify from the rendered UI, not one data hook)

Manual, needs the dev env prerequisites (spec Β§7): Nango Connect UI browser-reachable + Slack app configured + org gates set for a pilot org. Walk: log in as org admin β†’ Settings β†’ Sources β†’ tile visible β†’ Connect β†’ Slack consent β†’ Connected state β†’ Disconnect. If prerequisites aren't ready, note it in the PR description as the pending pilot step β€” do NOT claim e2e verified.

  • Step 4: Commit any fixes, then hand off

Use superpowers:finishing-a-development-branch β€” PR targets dev (house branch model). PR body links the spec + this plan, and states explicitly whether Step 3 rendered-UI verification ran or is pending env prerequisites.


Self-review (done at write time)​

  • Spec coverage: Β§3 endpoint β†’ Tasks 1–2; Β§4 page/SDK/env β†’ Tasks 3–5; Β§5 error rows β†’ Task 5 (runConfirm retry = "Finish connection", popup-block hint, disconnect failure copy, re-connect-overwrites needs no code β€” deterministic id); Β§6 tests β†’ each task + Task 7; Β§7 prerequisites β†’ Task 4 Step 2 + Task 7 Step 3; Β§8 follow-ups β†’ PR description, out of code scope. Cross-org 404 (Β§5) rides OrgRolesGuard (Task 2), asserted by existing guard behavior rather than a new spec β€” acceptable since no custom auth code is added.
  • Placeholder scan: the two intentional "adapt to the real API" steps (Task 1 readCredConfig shape, Task 4 SDK signature) are verification instructions with concrete fallback behavior, not TBDs. Task 5's render block specifies behavior/copy and the concrete donor file for markup.
  • Type consistency: SourceResponseDto/SourceInfo field sets match (provider/displayName/status/connectedAt/scopes); openNangoConnect(sessionToken): Promise<boolean> used identically in Tasks 4–5; SOURCE_TOOL_BY_PROVIDER keys = NANGO_CONNECT_PROVIDERS members (guarded by test). notion_query/google_drive_query tool names are speculative for not-yet-productized providers β€” harmless (no tool_permissions rows exist for them), flagged for whoever productizes those tiles.