diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md index cdb39ccbd7c..18c8544ce6c 100644 --- a/doc/connections/slack-setup.md +++ b/doc/connections/slack-setup.md @@ -78,6 +78,9 @@ Provider-side app removal remains explicit through the saved Slack management UR permissions therefore pass through installation again before activation. - Expired or revoked manager grants require reconnection. Losing managed availability pauses setup; it never changes the saved ownership method. + A saved draft that needs workspace reauthorization shows **Reconnect Slack + workspace** on Connect, even before its app has been created. This button + opens Slack authorization and returns to the same saved agent setup. - Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After` is persisted across matching grants in the company, survives restarts, and blocks early retries. Other tenant instances must observe Slack's limits independently; diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index 3344443853a..56f515e0e61 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -208,7 +208,7 @@ test.describe.serial("native chat adapter UI", () => { await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); await expect(page.getByRole("alert")).toBeVisible(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Reconnect Slack workspace", exact: true }).click(); await expect.poll(() => authorizationRequests).toBe(1); await expect(page).toHaveURL("https://slack.test/manager-consent"); }); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx index 245773a6a70..d61420dc8c5 100644 --- a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx @@ -63,6 +63,47 @@ it("opens manager authorization directly when there is no saved workspace", asyn expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); }); +it.each([0, 1, 2])("offers workspace reconnection before an app exists with %i active workspaces", async (activeWorkspaces) => { + endpoint.setup!.slackManagerError = "slack_manager_reauthorize"; + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: Array.from({ length: activeWorkspaces }, (_, i) => ({ grantId: `grant-${i}`, workspaceName: `Workspace ${i}` })) }); + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Reconnect your Slack workspace to continue setting up this agent."); + const reconnect = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Reconnect Slack workspace"); + expect(reconnect).toBeDefined(); + expect(reconnect!.disabled).toBe(false); + expect(container.textContent).not.toContain("Connect another Slack workspace"); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); + await act(async () => reconnect!.click()); + expect(api.authorizeSlackManager).toHaveBeenCalledExactlyOnceWith("endpoint"); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it.each([0, 1, 2])("offers workspace reconnection after an authorization request fails with %i active workspaces", async (activeWorkspaces) => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: Array.from({ length: activeWorkspaces }, (_, i) => ({ grantId: `grant-${i}`, workspaceName: `Workspace ${i}` })) }); + api.authorizeSlackManager.mockRejectedValueOnce(new Error("Reconnect your Slack workspace to continue setting up this agent.")); + await render({ autoStart: activeWorkspaces === 0 }); + if (activeWorkspaces) { + const connectAnother = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Connect another Slack workspace"); + await act(async () => connectAnother!.click()); + } + const reconnect = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Reconnect Slack workspace"); + expect(reconnect).toBeDefined(); + expect(reconnect!.disabled).toBe(false); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1); + await act(async () => reconnect!.click()); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(2); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("keeps one workspace reconnect action when a saved app needs reauthorization", async () => { + endpoint.setup!.slackRegistration = { status: "failed", appId: "app", managerGrantId: "grant", errorCode: "slack_manager_reauthorize" } as NonNullable["slackRegistration"]; + await render(); + const reconnect = Array.from(container.querySelectorAll("button")).filter(item => item.textContent === "Reconnect Slack workspace"); + expect(reconnect).toHaveLength(1); + await act(async () => reconnect[0]!.click()); + expect(api.authorizeSlackManager).toHaveBeenCalledExactlyOnceWith("endpoint"); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); it("does not start while Choose agent is visible or after Save & exit", async () => { await render({ autoStart: false }); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx index d9023162f02..fda5c0d69aa 100644 --- a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -16,6 +16,7 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on const [selectedGrant, setSelectedGrant] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [authorizationRequested, setAuthorizationRequested] = useState(false); const [confirmed, setConfirmed] = useState(false); const inFlight = useRef(false); const requestId = useRef(crypto.randomUUID()); @@ -23,7 +24,8 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on const started = Boolean(registration); const workspaces = choices.data?.workspaces ?? []; const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null); - const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const needsAuthorization = authorizationRequested || !grantId || endpoint.setup?.slackManagerError === "slack_manager_reauthorize" || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const reconnectWorkspace = needsAuthorization && (started || Boolean(error) || endpoint.setup?.slackManagerError === "slack_manager_reauthorize"); const uncertain = registration?.status === "uncertain"; const creating = registration?.status === "creating"; async function run(action: () => Promise) { @@ -34,6 +36,7 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on finally { inFlight.current = false; setBusy(false); onBusy(false); } } async function authorize() { + setAuthorizationRequested(true); const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id); const target = await prepareOAuthNavigation(authorization); if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session); @@ -71,21 +74,21 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on {message &&

{message}

} {busy || choices.isPending || creating ?

{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}

: !choices.data?.managedAvailable ?

Managed Slack setup is unavailable. Try again later.

- : <> + : reconnectWorkspace ? null : <> {workspaces.length === 1 &&

Add {endpoint.assignedAgentName} to {workspaces[0]!.workspaceName}.

} {workspaces.length > 1 && } - {(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 &&
} + {!started && workspaces.length > 0 &&
} } {uncertain &&
Open Slack app settings
} - {choices.isError || !choices.isPending && !choices.data?.managedAvailable ? : : } ; diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index dc1d26a3991..668923c6b68 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" | "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"; /** 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 }) { @@ -27,7 +27,10 @@ export function SlackSetupFixture({ scenario = "create", managed = false, manage }, }; const grantId = "11111111-1111-4111-8111-111111111111"; - let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; + const authorizationReturned = new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; + let authorized = workspaceCount > 0 || authorizationReturned; + let authorizationFailed = false; + if (scenario === "manager_reauthorize" && !authorizationReturned) endpoint.setup!.slackManagerError = "slack_manager_reauthorize"; const created = () => { endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() }; @@ -61,6 +64,10 @@ export function SlackSetupFixture({ scenario = "create", managed = false, manage return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] }); } if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") { + if (scenario === "manager_authorization_failed" && !authorizationReturned && !authorizationFailed) { + authorizationFailed = true; + return Response.json({ error: "Reconnect your Slack workspace to continue setting up this agent." }, { status: 422 }); + } authorized = true; const returnUrl = new URL(window.location.href); returnUrl.hash = ""; 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 172c29e0b3a..84937acceee 100644 --- a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx @@ -63,6 +63,35 @@ export const Success: Story = { name: "02 · Connect · Success", args: { scenar } }; export const ApprovalPending: Story = { name: "Recovery · Slack approval pending", args: { scenario: "declined" } }; export const ApprovalDeclined: Story = { name: "Recovery · Slack approval declined", args: { scenario: "approval_denied" } }; +export const WorkspaceReconnect: Story = { + name: "Recovery · Reconnect workspace before app creation", + args: { scenario: "manager_reauthorize", workspaceCount: 0 }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + if (new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1") { + if ((args.workspaceCount ?? 1) > 1) { + await expect(await canvas.findByRole("combobox", { name: "Slack workspace" })).toBeVisible(); + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: /^Continue$/ })).toBeDisabled(); + return; + } + await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible(); + return; + } + if (args.scenario === "manager_authorization_failed" && (args.workspaceCount ?? 0) > 1 && !canvas.queryByRole("alert")) { + await userEvent.click(await canvas.findByRole("button", { name: "Connect another Slack workspace" })); + } + await expect(await canvas.findByRole("alert")).toHaveTextContent("Reconnect your Slack workspace to continue setting up this agent."); + await waitFor(() => expect(canvas.getByRole("button", { name: "Reconnect Slack workspace" })).toBeEnabled()); + await expect(canvas.getByRole("button", { name: "Save & exit" })).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: "Connect another Slack workspace" })).not.toBeInTheDocument(); + }, +}; +export const MobileWorkspaceReconnect: Story = { ...WorkspaceReconnect, name: "Mobile · Reconnect workspace", globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const WorkspaceReconnectWithSavedGrant: Story = { ...WorkspaceReconnect, name: "Recovery · Reconnect with older workspace grants", args: { scenario: "manager_reauthorize", workspaceCount: 2 } }; +export const WorkspaceAuthorizationFailed: Story = { ...WorkspaceReconnect, name: "Recovery · Workspace authorization request failed", args: { scenario: "manager_authorization_failed", workspaceCount: 0 } }; +export const WorkspaceAuthorizationFailedWithSavedGrants: Story = { ...WorkspaceReconnect, name: "Recovery · Workspace authorization failed with older grants", args: { scenario: "manager_authorization_failed", workspaceCount: 2 } }; export const EventConfigurationPending: Story = { name: "Recovery · App configuration pending", args: { scenario: "manifest_pending" } }; export const UncertainCreation: Story = { name: "Recovery · Uncertain creation", args: { scenario: "uncertain" } }; export const SavedCredentials: Story = { name: "Recovery · Saved credentials", args: { scenario: "recovery" } };