diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md index 18c8544ce6c..e167017e84f 100644 --- a/doc/connections/slack-setup.md +++ b/doc/connections/slack-setup.md @@ -24,6 +24,10 @@ Creation, installation, message waiting, and success use the same production components and server evidence as the own-app flow. A click or OAuth return does not constitute successful configuration. +Reopening a completed Slack setup retains its Slack handoff. **Done** opens the +saved connection settings without repeating activation or reactivating a paused +bot. Unfinished setups still complete their normal server verification first. + ## Cloud deployment prerequisites Managed setup is disabled by default. Qualification requires all of: diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index 56f515e0e61..5b4679b6ec4 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -20,6 +20,22 @@ import { GITHUB_PRIVATE_KEY_PASTE_FIXTURE, } from "./chat-adapters-ui.shared"; +async function expectCompletedSlackSetupCloses(page: Page, seed: Seed) { + let finishRequests = 0; + await page.route("**/api/chat-endpoints/endpoint-slack/finish", async route => { + finishRequests++; + await route.fulfill({ status: 409, json: { error: "This connection is not waiting for a setup test" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&toolHref=%2Fapps%2Fconnect%3Fsource%3Dslack&resume=endpoint-slack`); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await page.reload(); + await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible(); + expect(finishRequests).toBe(0); +} + async function findConnectorBySearch(page: Page, query: string, slug: string) { const search = page.getByRole("searchbox", { name: "Search connectors" }); await expect(search).toBeVisible(); @@ -187,6 +203,8 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await expectCompletedSlackSetupCloses(page, seed); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); }); test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; @@ -379,6 +397,8 @@ test.describe.serial("native chat adapter UI", () => { await page.screenshot({ path: test.info().outputPath("automatic-slack-success.png"), fullPage: true }); await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await expectCompletedSlackSetupCloses(page, seed); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(2); const stored = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); expect(stored).not.toContain("fixture-config-token"); }); diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dc6c52282e3..e2bae6a68e9 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ createLinkIntent: vi.fn(), confirmIdentityLink: vi.fn(), pushToast: vi.fn(), + navigate: vi.fn(), setBreadcrumbs: vi.fn(), search: "", setParams: vi.fn(), @@ -57,7 +58,7 @@ vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }), })); vi.mock("@/lib/router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => mocks.navigate, useLocation: () => ({ state: null }), useParams: () => ({ endpointId: "endpoint-a", tab: mocks.tab }), useSearchParams: () => [new URLSearchParams(mocks.search), mocks.setParams], @@ -505,6 +506,47 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); + it.each( + (["managed", "automatic", "manual"] as const).flatMap(method => + (["active", "paused"] as const).map(status => ({ method, status }))), + )("opens settings when Done resumes a completed $method Slack connection that is $status", async ({ method, status }) => { + const endpoint = await render("slack"); + const saved: ChatEndpoint = { ...endpoint, status, providerAccountId: "workspace-a", setup: { + ...endpoint.setup!, step: "complete", slackSetupMethod: method, + slackRegistration: { status: "configured", appId: "ATEST" }, + slackAccount: { externalUserId: "UPERSON", paperclipUserId: "owner-user", status: "linked", welcomeStatus: "sent", dmChannelId: "DTEST" }, + } }; + mocks.get.mockResolvedValue(saved); + mocks.finishSlackSetup.mockRejectedValue(new Error("This connection is not waiting for a setup test")); + flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], saved)); + await settle(); + + await click("Done"); + + expect(mocks.finishSlackSetup).not.toHaveBeenCalled(); + expect(mocks.navigate).toHaveBeenCalledWith("/apps/chat/endpoint-a/settings"); + expect(mocks.pushToast).not.toHaveBeenCalled(); + }); + + it.each(["managed", "automatic"] as const)("still finalizes an unfinished %s Slack setup before opening settings", async method => { + const endpoint = await render("slack"); + const saved: ChatEndpoint = { ...endpoint, status: "verifying", providerAccountId: "workspace-a", setup: { + ...endpoint.setup!, step: "test", slackSetupMethod: method, + webhookVerifiedAt: "2026-01-01T00:00:00Z", + slackRegistration: { status: "configured", appId: "ATEST" }, + slackAccount: { externalUserId: "UPERSON", paperclipUserId: "owner-user", status: "linked", welcomeStatus: "sent", dmChannelId: "DTEST" }, + } }; + mocks.get.mockResolvedValue(saved); + mocks.finishSlackSetup.mockResolvedValue({ ...saved, status: "active", setup: { ...saved.setup, step: "complete" } }); + flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], saved)); + await settle(); + + await click("Done"); + + expect(mocks.finishSlackSetup).toHaveBeenCalledExactlyOnceWith("endpoint-a"); + expect(mocks.navigate).toHaveBeenCalledWith("/apps/chat/endpoint-a/settings"); + }); + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; const endpoint = await render("slack", true); diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index 3932cee316e..727c2e0c1e1 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -475,6 +475,13 @@ function ChatSdkEndpointSetup() { tone: "error", }), }); + const finishSetup = () => { + if (provider === "slack" && endpoint?.setup?.step === "complete") { + navigate(`/apps/chat/${endpoint.id}/settings`); + return; + } + testConnection.mutate(); + }; const repairing = isChatEndpointRepairing( endpoint, @@ -671,7 +678,7 @@ function ChatSdkEndpointSetup() {

- : navigate(`/apps/chat/${endpoint.id}/access`)} - onTest={() => testConnection.mutate()} + onTest={finishSetup} onSaveExit={() => navigate("/apps")} /> )} diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index 668923c6b68..461e974c7af 100644 --- a/ui/storybook/fixtures/SlackSetupFixture.tsx +++ b/ui/storybook/fixtures/SlackSetupFixture.tsx @@ -7,7 +7,7 @@ import { type ChatEndpoint } from "@/api/chatEndpoints"; import { storybookAgents, storybookAuthSession } from "./paperclipData"; import { defaultSlackAppConfiguration } from "@paperclipai/shared"; -export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "manager_reauthorize" | "manager_authorization_failed" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; +export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "manager_reauthorize" | "manager_authorization_failed" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success" | "complete"; /** Production wizard over a local provider fixture. No requests go to Slack. */ export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) { @@ -48,12 +48,13 @@ export function SlackSetupFixture({ scenario = "create", managed = false, manage if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied"; if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" }; if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; - if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed(); - if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) { + if (["verify", "avatar_failed", "welcome_failed", "success", "complete"].includes(scenario)) installed(); + if (["avatar_failed", "welcome_failed", "success", "complete"].includes(scenario)) { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); endpoint.setup!.step = "test"; endpoint.setup!.testStartedAt = new Date().toISOString(); } + if (scenario === "complete") { endpoint.status = "active"; endpoint.setup!.step = "complete"; } setSignalVerification(() => () => { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); }); window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); @@ -113,7 +114,10 @@ export function SlackSetupFixture({ scenario = "create", managed = false, manage if (path.endsWith("/slack-person/link-intent")) return Response.json({ confirmationUrl: "https://board.example/confirm?token=fixture" }); if (path === "/api/chat-identity-links/confirm") { linked = true; return Response.json({ ok: true }); } if (path.endsWith("/test-status")) return Response.json({ messageReceivedAt: null }); - if (path.endsWith("/finish")) { endpoint.status = "active"; endpoint.setup!.step = "complete"; return Response.json(endpoint); } + if (path.endsWith("/finish")) { + if (endpoint.status !== "verifying" || endpoint.setup!.step !== "test") return Response.json({ error: "This connection is not waiting for a setup test" }, { status: 409 }); + endpoint.status = "active"; endpoint.setup!.step = "complete"; return Response.json(endpoint); + } return original(input, init); }; setReady(true); diff --git a/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx index 117444cae60..f4f6495a6aa 100644 --- a/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx @@ -63,6 +63,7 @@ export const AvatarUploadFailed: Story = { name: "Recovery · Avatar upload fail export const WelcomeFailed: Story = { name: "Recovery · Welcome DM failed", args: { scenario: "welcome_failed" } }; export const Mobile: Story = { name: "Mobile · App configuration access token", args: { scenario: "create" }, globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const AlreadyConnected: Story = { ...Success, name: "04 · Already connected", args: { scenario: "complete" } }; export const SuccessMobile: Story = { name: "Mobile · Success", args: { scenario: "success" }, globals: { viewport: { value: "mobile1", isRotated: false } } }; export const ChooseConnectionMobile: Story = { diff --git a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx index 84937acceee..b35b00062ce 100644 --- a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx @@ -62,6 +62,7 @@ export const Success: Story = { name: "02 · Connect · Success", args: { scenar await expect(await canvas.findByRole("link", { name: /^Open Slack$/ })).toHaveAttribute("href", "https://app.slack.com/client/TSTORY/DSTORY"); } }; export const ApprovalPending: Story = { name: "Recovery · Slack approval pending", args: { scenario: "declined" } }; +export const AlreadyConnected: Story = { ...Success, name: "02 · Connect · Already connected", args: { scenario: "complete" } }; export const ApprovalDeclined: Story = { name: "Recovery · Slack approval declined", args: { scenario: "approval_denied" } }; export const WorkspaceReconnect: Story = { name: "Recovery · Reconnect workspace before app creation",