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.encryptedConfiggo throughreadCredConfig(cred)(api/src/common/crypto.ts) β never read the field directly (#551). - Cross-org by-id reads return 404, not 403 (#1472). The
OrgRolesGuardhandles org-membership mismatch; do not add custom 403s. (Note:OrgRolesGuarditself 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; importToolsModule) -
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
SourcesServicetoproviders,SourcesControllertocontrollers; -
ensure
ToolsModule(exportsToolPermissionsService, seeapi/src/tools/tools.module.ts:24) is inimportsβ check first; the module may already import it forToolPermissionsGuard. If adding it creates a circular import, useforwardRef(() => ToolsModule)and note why in a comment. -
IntegrationCredentialmust be in the module'sTypeOrmModule.forFeature([...])array (it already is βCredentialsServiceuses 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 togetIntegrations, ~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(vianpm 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=tofrontend/.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 thealwaysarray 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: addsources.controller.ts/sources.service.tsone-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/sourceswhere/client/settings/channelsis 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 (
runConfirmretry = "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) ridesOrgRolesGuard(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
readCredConfigshape, 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/SourceInfofield sets match (provider/displayName/status/connectedAt/scopes);openNangoConnect(sessionToken): Promise<boolean>used identically in Tasks 4β5;SOURCE_TOOL_BY_PROVIDERkeys =NANGO_CONNECT_PROVIDERSmembers (guarded by test).notion_query/google_drive_querytool names are speculative for not-yet-productized providers β harmless (no tool_permissions rows exist for them), flagged for whoever productizes those tiles.