diff --git a/.github/workflows/pr-playwright-tests.yml b/.github/workflows/pr-playwright-tests.yml index 900553251d4..c6611636c27 100644 --- a/.github/workflows/pr-playwright-tests.yml +++ b/.github/workflows/pr-playwright-tests.yml @@ -9,6 +9,11 @@ env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} GEN_AI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + # for federated slack tests + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + MOCK_LLM_RESPONSE: true jobs: diff --git a/web/admin2_auth.json b/web/admin2_auth.json index 7b17ebfb687..610e2e97c56 100644 --- a/web/admin2_auth.json +++ b/web/admin2_auth.json @@ -2,10 +2,10 @@ "cookies": [ { "name": "fastapiusersauth", - "value": "h3qhacpHbE4_09HcOLlVW4lSee48m1UbjYTUiKYwNiw", + "value": "3_1lUBEkRwurVz_5uWN-jimnGhCD9drBvVggguRyCZI", "domain": "localhost", "path": "/", - "expires": 1745624493.119168, + "expires": 1758141723.8561, "httpOnly": true, "secure": false, "sameSite": "Lax" diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 69c2f2c983e..43f60b06bdc 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,4 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import * as dotenv from "dotenv"; + +dotenv.config({ path: ".vscode/.env" }); export default defineConfig({ globalSetup: require.resolve("./tests/e2e/global-setup"), diff --git a/web/src/app/assistants/SidebarWrapper.tsx b/web/src/app/assistants/SidebarWrapper.tsx index ebf0cdfc8a6..b6b92b08eae 100644 --- a/web/src/app/assistants/SidebarWrapper.tsx +++ b/web/src/app/assistants/SidebarWrapper.tsx @@ -15,6 +15,7 @@ import { useSidebarShortcut } from "@/lib/browserUtilities"; import { UserSettingsModal } from "@/app/chat/components/modal/UserSettingsModal"; import { usePopup } from "@/components/admin/connectors/Popup"; import { useUser } from "@/components/user/UserProvider"; +import { useFederatedOAuthStatus } from "@/lib/hooks/useFederatedOAuthStatus"; interface SidebarWrapperProps { size?: "sm" | "lg"; @@ -41,7 +42,12 @@ export default function SidebarWrapper({ }, [sidebarVisible]); const sidebarElementRef = useRef(null); - const { folders, openedFolders, chatSessions } = useChatContext(); + const { folders, chatSessions, ccPairs } = useChatContext(); + const { + connectors: federatedConnectors, + refetch: refetchFederatedConnectors, + } = useFederatedOAuthStatus(); + const explicitlyUntoggle = () => { setShowDocSidebar(false); @@ -114,6 +120,9 @@ export default function SidebarWrapper({ setUserSettingsToggled(false)} defaultModel={user?.preferences?.default_model!} /> diff --git a/web/src/app/chat/components/ChatPage.tsx b/web/src/app/chat/components/ChatPage.tsx index f23ad136f31..624e4506a47 100644 --- a/web/src/app/chat/components/ChatPage.tsx +++ b/web/src/app/chat/components/ChatPage.tsx @@ -18,6 +18,7 @@ import { import { usePopup } from "@/components/admin/connectors/Popup"; import { SEARCH_PARAM_NAMES } from "../services/searchParams"; import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks"; +import { useFederatedOAuthStatus } from "@/lib/hooks/useFederatedOAuthStatus"; import { FeedbackType } from "@/app/chat/interfaces"; import { OnyxInitializingLoader } from "@/components/OnyxInitializingLoader"; import { FeedbackModal } from "./modal/FeedbackModal"; @@ -161,6 +162,10 @@ export function ChatPage({ // Also fetch federated connectors for the sources list const { data: federatedConnectorsData } = useFederatedConnectors(); + const { + connectors: federatedConnectorOAuthStatus, + refetch: refetchFederatedConnectors, + } = useFederatedOAuthStatus(); const { user, isAdmin } = useUser(); const existingChatIdRaw = searchParams?.get("chatId"); @@ -796,6 +801,9 @@ export function ChatPage({ updateCurrentLlm={llmManager.updateCurrentLlm} defaultModel={user?.preferences.default_model!} llmProviders={llmProviders} + ccPairs={ccPairs} + federatedConnectors={federatedConnectorOAuthStatus} + refetchFederatedConnectors={refetchFederatedConnectors} onClose={() => { setUserSettingsToggled(false); setSettingsToggled(false); diff --git a/web/src/app/chat/components/modal/UserSettingsModal.tsx b/web/src/app/chat/components/modal/UserSettingsModal.tsx index 95934c1999c..d56cf41d958 100644 --- a/web/src/app/chat/components/modal/UserSettingsModal.tsx +++ b/web/src/app/chat/components/modal/UserSettingsModal.tsx @@ -49,9 +49,9 @@ export function UserSettingsModal({ updateCurrentLlm?: (newOverride: LlmDescriptor) => void; onClose: () => void; defaultModel: string | null; - ccPairs?: CCPairBasicInfo[]; - federatedConnectors?: FederatedConnectorOAuthStatus[]; - refetchFederatedConnectors?: () => void; + ccPairs: CCPairBasicInfo[]; + federatedConnectors: FederatedConnectorOAuthStatus[]; + refetchFederatedConnectors: () => void; }) { const { refreshUser, diff --git a/web/tests/e2e/connectors/federated_slack.spec.ts b/web/tests/e2e/connectors/federated_slack.spec.ts new file mode 100644 index 00000000000..e7d3cde43e6 --- /dev/null +++ b/web/tests/e2e/connectors/federated_slack.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from "@chromatic-com/playwright"; +import type { Page } from "@playwright/test"; +import { loginAs, loginAsRandomUser } from "../utils/auth"; + +test.use({ storageState: "admin_auth.json" }); + +const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID; +const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET; + +async function createFederatedSlackConnector(page: Page) { + // Navigate to add connector page + await page.goto("http://localhost:3000/admin/add-connector"); + await page.waitForLoadState("networkidle"); + + // Click on Slack connector tile (specifically the one with "Logo Slack" text, not "Slack Bots") + await page.getByRole("link", { name: "Logo Slack" }).first().click(); + await page.waitForLoadState("networkidle"); + + if (!SLACK_CLIENT_ID || !SLACK_CLIENT_SECRET) { + throw new Error("SLACK_CLIENT_ID and SLACK_CLIENT_SECRET must be set"); + } + + // Fill in the client ID and client secret + await page.getByLabel(/client id/i).fill(SLACK_CLIENT_ID); + await page.getByLabel(/client secret/i).fill(SLACK_CLIENT_SECRET); + + // Submit the form to create or update the federated connector + const createOrUpdateButton = await page.getByRole("button", { + name: /create|update/i, + }); + await createOrUpdateButton.click(); + + // Wait for success message or redirect + await page.waitForTimeout(2000); +} + +async function navigateToUserSettings(page: Page) { + // Wait for any existing modals to close + await page.waitForTimeout(1000); + + // Wait for potential modal backdrop to disappear + await page + .waitForSelector(".fixed.inset-0.bg-neutral-950\\/50", { + state: "detached", + timeout: 5000, + }) + .catch(() => {}); + + // Click on user dropdown/settings button + await page.locator("#onyx-user-dropdown").click(); + + // Click on settings option + await page.getByText("User Settings").click(); + + // Wait for settings modal to appear + await expect(page.locator("h2", { hasText: "User Settings" })).toBeVisible(); +} + +async function openConnectorsTab(page: Page) { + // Click on the Connectors tab in user settings + await page.getByRole("button", { name: "Connectors" }).click(); + + // Wait for connectors section to be visible + // Allow multiple instances of "Connected Services" to be visible + const connectedServicesLocators = page.getByText("Connected Services"); + await expect(connectedServicesLocators.first()).toBeVisible(); +} + +/** + * Cleanup function to delete the federated Slack connector from the admin panel + * This ensures test isolation by removing any test data created during the test + */ +async function deleteFederatedSlackConnector(page: Page) { + // Navigate to admin indexing status page + await page.goto("http://localhost:3000/admin/indexing/status"); + await page.waitForLoadState("networkidle"); + + // Expand the Slack section first (summary row toggles open on click) + const slackSummaryRow = page.locator("tr").filter({ + has: page.locator("text=/^\s*Slack\s*$/i"), + }); + if ((await slackSummaryRow.count()) > 0) { + await slackSummaryRow.first().click(); + // Wait a moment for rows to render + await page.waitForTimeout(500); + } + + // Look for the Slack federated connector row inside the expanded section + // The federated connectors have a "Federated Access" badge + const slackRow = page.locator("tr", { hasText: /federated access/i }); + + // Check if the connector exists + const rowCount = await slackRow.count(); + if (rowCount === 0) { + // No federated Slack connector found, nothing to delete + console.log("No federated Slack connector found to delete"); + return; + } + + // Click on the row to navigate to the detail page + await slackRow.first().click(); + await page.waitForLoadState("networkidle"); + + // Look for and click the delete button + // Open the Manage menu and click Delete + const manageButton = page.getByRole("button", { name: /manage/i }); + await manageButton + .waitFor({ state: "visible", timeout: 5000 }) + .catch(() => {}); + if (!(await manageButton.isVisible().catch(() => false))) { + console.log("Manage button not visible; skipping delete"); + return; + } + await manageButton.click(); + // Wait for the dropdown menu to appear and settle (Radix animation) + await page + .getByRole("menu") + .waitFor({ state: "visible", timeout: 3000 }) + .catch(() => {}); + await page.waitForTimeout(150); + + page.once("dialog", (dialog) => dialog.accept()); + const deleteMenuItem = page.getByRole("menuitem", { name: /^Delete$/ }); + await expect(deleteMenuItem).toBeVisible({ timeout: 5000 }); + await deleteMenuItem.click({ force: true }); + // Wait for deletion to complete and redirect + await page.waitForURL("**/admin/indexing/status*", { timeout: 15000 }); + await page.waitForLoadState("networkidle"); +} + +test("Federated Slack Connector - Create, OAuth Modal, and User Settings Flow", async ({ + page, +}) => { + try { + // Setup: Clear cookies and log in as admin + await page.context().clearCookies(); + await loginAs(page, "admin"); + + // Create a federated Slack connector in admin panel + await createFederatedSlackConnector(page); + + // Log in as a random user + await page.context().clearCookies(); + await loginAsRandomUser(page); + + // Navigate back to main page and verify OAuth modal appears + await page.goto("http://localhost:3000/chat"); + await page.waitForLoadState("networkidle"); + + // Check if the OAuth modal appears + await expect( + page.getByText(/improve answer quality by letting/i) + ).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/slack/i)).toBeVisible(); + + // Decline the OAuth connection + await page.getByRole("button", { name: "Skip for now" }).click(); + + // Wait for modal to disappear + await expect( + page.getByText(/improve answer quality by letting/i) + ).not.toBeVisible(); + + // Go to user settings and verify the connector appears + await navigateToUserSettings(page); + await openConnectorsTab(page); + + // Verify Slack connector appears in the federated connectors section + await expect(page.getByText("Federated Connectors")).toBeVisible(); + await expect(page.getByText("Slack")).toBeVisible(); + await expect(page.getByText("Not connected")).toBeVisible(); + + // Verify there's a Connect button available + await expect( + page.locator("button", { hasText: /^Connect$/ }) + ).toBeVisible(); + } finally { + // Cleanup: Delete the federated Slack connector + // Log back in as admin to delete the connector + await page.context().clearCookies(); + await loginAs(page, "admin"); + await deleteFederatedSlackConnector(page); + } +});