Skip to content

feat: add ability to override slack scopes #522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/loose-dodos-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@knocklabs/react-core": patch
"@knocklabs/react": patch
---

feat: introduce ability to override slack scopes
36 changes: 31 additions & 5 deletions packages/react-core/src/modules/slack/hooks/useSlackAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
125 changes: 125 additions & 0 deletions packages/react-core/test/slack/useSlackAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,13 +17,17 @@ 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[];
}

export const SlackAuthButton: FunctionComponent<SlackAuthButtonProps> = ({
slackClientId,
redirectUrl,
onAuthenticationComplete,
scopes,
additionalScopes,
}) => {
const { t } = useTranslations();
Expand All @@ -37,10 +41,18 @@ export const SlackAuthButton: FunctionComponent<SlackAuthButtonProps> = ({
errorLabel,
} = useKnockSlackClient();

const useSlackAuthOptions = useMemo(
() => ({
scopes,
additionalScopes,
}),
[scopes, additionalScopes],
);

const { buildSlackAuthUrl, disconnectFromSlack } = useSlackAuth(
slackClientId,
redirectUrl,
additionalScopes,
useSlackAuthOptions,
);

useEffect(() => {
Expand Down