diff --git a/.changeset/loose-dodos-rhyme.md b/.changeset/loose-dodos-rhyme.md new file mode 100644 index 00000000..d094e4e0 --- /dev/null +++ b/.changeset/loose-dodos-rhyme.md @@ -0,0 +1,6 @@ +--- +"@knocklabs/react-core": patch +"@knocklabs/react": patch +--- + +feat: introduce ability to override slack scopes diff --git a/packages/react-core/src/modules/slack/hooks/useSlackAuth.ts b/packages/react-core/src/modules/slack/hooks/useSlackAuth.ts index fb0f80f7..ac1e6d2f 100644 --- a/packages/react-core/src/modules/slack/hooks/useSlackAuth.ts +++ b/packages/react-core/src/modules/slack/hooks/useSlackAuth.ts @@ -17,19 +17,45 @@ type UseSlackAuthOutput = { disconnectFromSlack: () => void; }; +type UseSlackAuthOptions = { + // When provided, the default scopes will be overridden with the provided scopes + scopes?: string[]; + // Additional scopes to add to the default scopes + additionalScopes?: string[]; +}; + +// Here we normalize the options to be a single object with scopes and additionalScopes +// The "options" parameter can be an array of scopes, an object with scopes and additionalScopes, or undefined +// We handle the array case because it was the previous way to pass options so we're being backward compatible +function normalizeOptions(options?: UseSlackAuthOptions | string[]): { + scopes: string[]; + additionalScopes: string[]; +} { + if (!options) { + return { scopes: DEFAULT_SLACK_SCOPES, additionalScopes: [] }; + } + + if (Array.isArray(options)) { + return { scopes: DEFAULT_SLACK_SCOPES, additionalScopes: options }; + } + + return { + scopes: options.scopes ?? DEFAULT_SLACK_SCOPES, + additionalScopes: options.additionalScopes ?? [], + }; +} + function useSlackAuth( slackClientId: string, redirectUrl?: string, - additionalScopes?: string[], + options?: UseSlackAuthOptions | string[], ): UseSlackAuthOutput { const knock = useKnockClient(); const { setConnectionStatus, knockSlackChannelId, tenantId, setActionLabel } = useKnockSlackClient(); - const combinedScopes = - additionalScopes && additionalScopes.length > 0 - ? Array.from(new Set(DEFAULT_SLACK_SCOPES.concat(additionalScopes))) - : DEFAULT_SLACK_SCOPES; + const { scopes, additionalScopes } = normalizeOptions(options); + const combinedScopes = Array.from(new Set(scopes.concat(additionalScopes))); const disconnectFromSlack = useCallback(async () => { setActionLabel(null); diff --git a/packages/react-core/test/slack/useSlackAuth.test.tsx b/packages/react-core/test/slack/useSlackAuth.test.tsx new file mode 100644 index 00000000..8e19023c --- /dev/null +++ b/packages/react-core/test/slack/useSlackAuth.test.tsx @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import useSlackAuth from "../../src/modules/slack/hooks/useSlackAuth"; + +const mockSetConnectionStatus = vi.fn(); +const mockSetActionLabel = vi.fn(); + +vi.mock("../../src/modules/slack", () => ({ + useKnockSlackClient: () => ({ + setConnectionStatus: mockSetConnectionStatus, + setActionLabel: mockSetActionLabel, + knockSlackChannelId: "test_channel_id", + tenantId: "test_tenant_id", + }), +})); + +const mockSlackClient = { + revokeAccessToken: vi.fn(), +}; + +vi.mock("../../src/modules/core", () => ({ + useKnockClient: () => ({ + slack: mockSlackClient, + apiKey: "test_api_key", + userToken: "test_user_token", + }), +})); + +describe("useSlackAuth", () => { + test("buildSlackAuthUrl returns the correct URL with default scopes", () => { + const { result } = renderHook(() => + useSlackAuth("test_client_id", "http://localhost:3000"), + ); + + const url = new URL(result.current.buildSlackAuthUrl()); + const params = new URLSearchParams(url.search); + const state = JSON.parse(params.get("state") || "{}"); + + expect(url.origin + url.pathname).toBe( + "https://slack.com/oauth/v2/authorize", + ); + expect(params.get("client_id")).toBe("test_client_id"); + expect(params.get("scope")).toBe( + "chat:write,chat:write.public,channels:read,groups:read", + ); + expect(state).toEqual({ + redirect_url: "http://localhost:3000", + access_token_object: { + object_id: "test_tenant_id", + collection: "$tenants", + }, + channel_id: "test_channel_id", + public_key: "test_api_key", + user_token: "test_user_token", + }); + }); + + test("buildSlackAuthUrl uses custom scopes when provided", () => { + const { result } = renderHook(() => + useSlackAuth("test_client_id", "http://localhost:3000", { + scopes: ["custom:scope"], + }), + ); + + const url = new URL(result.current.buildSlackAuthUrl()); + const params = new URLSearchParams(url.search); + + expect(params.get("scope")).toBe("custom:scope"); + }); + + test("buildSlackAuthUrl combines default and additional scopes", () => { + const { result } = renderHook(() => + useSlackAuth("test_client_id", "http://localhost:3000", { + additionalScopes: ["additional:scope"], + }), + ); + + const url = new URL(result.current.buildSlackAuthUrl()); + const params = new URLSearchParams(url.search); + const scopes = params.get("scope")?.split(","); + + expect(scopes).toContain("additional:scope"); + expect(scopes).toContain("chat:write"); + }); + + test("disconnectFromSlack handles successful disconnection", async () => { + mockSlackClient.revokeAccessToken.mockResolvedValueOnce("ok"); + + const { result } = renderHook(() => useSlackAuth("test_client_id")); + + await act(async () => { + await result.current.disconnectFromSlack(); + }); + + expect(mockSlackClient.revokeAccessToken).toHaveBeenCalledWith({ + tenant: "test_tenant_id", + knockChannelId: "test_channel_id", + }); + expect(mockSetConnectionStatus).toHaveBeenCalledWith("disconnecting"); + expect(mockSetConnectionStatus).toHaveBeenCalledWith("disconnected"); + expect(mockSetActionLabel).toHaveBeenCalledWith(null); + }); + + test("disconnectFromSlack handles error cases", async () => { + mockSlackClient.revokeAccessToken.mockRejectedValueOnce( + new Error("Failed"), + ); + + const { result } = renderHook(() => useSlackAuth("test_client_id")); + + await act(async () => { + await result.current.disconnectFromSlack(); + }); + + expect(mockSlackClient.revokeAccessToken).toHaveBeenCalled(); + expect(mockSetConnectionStatus).toHaveBeenCalledWith("disconnecting"); + expect(mockSetConnectionStatus).toHaveBeenCalledWith("error"); + expect(mockSetActionLabel).toHaveBeenCalledWith(null); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); +}); diff --git a/packages/react/src/modules/slack/components/SlackAuthButton/SlackAuthButton.tsx b/packages/react/src/modules/slack/components/SlackAuthButton/SlackAuthButton.tsx index 29f2efad..589a3f3a 100644 --- a/packages/react/src/modules/slack/components/SlackAuthButton/SlackAuthButton.tsx +++ b/packages/react/src/modules/slack/components/SlackAuthButton/SlackAuthButton.tsx @@ -4,7 +4,7 @@ import { useSlackAuth, useTranslations, } from "@knocklabs/react-core"; -import { FunctionComponent } from "react"; +import { FunctionComponent, useMemo } from "react"; import { useEffect } from "react"; import { openPopupWindow } from "../../../core/utils"; @@ -17,6 +17,9 @@ export interface SlackAuthButtonProps { slackClientId: string; redirectUrl?: string; onAuthenticationComplete?: (authenticationResp: string) => void; + // When provided, the default scopes will be overridden with the provided scopes + scopes?: string[]; + // Additional scopes to add to the default scopes additionalScopes?: string[]; } @@ -24,6 +27,7 @@ export const SlackAuthButton: FunctionComponent = ({ slackClientId, redirectUrl, onAuthenticationComplete, + scopes, additionalScopes, }) => { const { t } = useTranslations(); @@ -37,10 +41,18 @@ export const SlackAuthButton: FunctionComponent = ({ errorLabel, } = useKnockSlackClient(); + const useSlackAuthOptions = useMemo( + () => ({ + scopes, + additionalScopes, + }), + [scopes, additionalScopes], + ); + const { buildSlackAuthUrl, disconnectFromSlack } = useSlackAuth( slackClientId, redirectUrl, - additionalScopes, + useSlackAuthOptions, ); useEffect(() => {