diff --git a/apps/app/.env.example b/apps/app/.env.example index 1e2576dfc..78ce84a64 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -7,4 +7,5 @@ NEXT_PUBLIC_SENTRY_DSN="" NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_SENTRY=0 NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -NEXT_PUBLIC_TRACK_EVENTS=0 \ No newline at end of file +NEXT_PUBLIC_TRACK_EVENTS=0 +NEXT_PUBLIC_SLACK_CLIENT_ID="" \ No newline at end of file diff --git a/apps/app/components/integration/index.ts b/apps/app/components/integration/index.ts index 39ae51e33..a734113b6 100644 --- a/apps/app/components/integration/index.ts +++ b/apps/app/components/integration/index.ts @@ -8,3 +8,5 @@ export * from "./single-integration-card"; export * from "./github"; // jira export * from "./jira"; +// slack +export * from "./slack"; diff --git a/apps/app/components/integration/slack/index.ts b/apps/app/components/integration/slack/index.ts new file mode 100644 index 000000000..3bd1c965c --- /dev/null +++ b/apps/app/components/integration/slack/index.ts @@ -0,0 +1 @@ +export * from "./select-channel"; \ No newline at end of file diff --git a/apps/app/components/integration/slack/select-channel.tsx b/apps/app/components/integration/slack/select-channel.tsx new file mode 100644 index 000000000..2680cd847 --- /dev/null +++ b/apps/app/components/integration/slack/select-channel.tsx @@ -0,0 +1,90 @@ +import React,{useState} from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; +// services +import projectService from "services/project.service"; +import IntegrationService from "services/integration"; + +// ui +import { DangerButton, Loader, SecondaryButton } from "components/ui"; +// hooks +import useToast from "hooks/use-toast"; +import useIntegrationPopup from "hooks/use-integration-popup"; +// types +import { IAppIntegration, IWorkspaceIntegration } from "types"; +// fetch-keys +import { WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; + + + +type Props = { + integration: IWorkspaceIntegration; +}; + +export const SelectChannel: React.FC = ({ + integration, +}) => { + const [deletingIntegration, setDeletingIntegration] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { startAuth, isConnecting: isInstalling } = useIntegrationPopup("slackChannel"); + + const { data: workspaceIntegrations } = useSWR( + workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, + () => + workspaceSlug + ? IntegrationService.getWorkspaceIntegrationsList(workspaceSlug as string) + : null + ); + + const isInstalled = workspaceIntegrations?.find( + (i: any) => i.integration_detail.id === integration.id + ); + + const handleRemoveIntegration = async () => { + if (!workspaceSlug || !integration || !workspaceIntegrations) return; + + const workspaceIntegrationId = workspaceIntegrations?.find( + (i) => i.integration === integration.id + )?.id; + + setDeletingIntegration(true); + + await IntegrationService.deleteWorkspaceIntegration( + workspaceSlug as string, + workspaceIntegrationId ?? "" + ) + .then(() => { + setDeletingIntegration(false); + }) + .catch(() => { + setDeletingIntegration(false); + }); + }; + + return ( + <> + {workspaceIntegrations ? ( + isInstalled ? ( + + {deletingIntegration ? "Removing..." : "Remove channel"} + + ) : ( + + {isInstalling ? "Adding..." : "Add channel"} + + ) + ) : ( + + + + )} + + ); +}; diff --git a/apps/app/components/project/single-integration-card.tsx b/apps/app/components/project/single-integration-card.tsx index bbf21f438..8d361b0aa 100644 --- a/apps/app/components/project/single-integration-card.tsx +++ b/apps/app/components/project/single-integration-card.tsx @@ -10,18 +10,34 @@ import projectService from "services/project.service"; import { useRouter } from "next/router"; import useToast from "hooks/use-toast"; // components -import { SelectRepository } from "components/integration"; +import { SelectRepository, SelectChannel } from "components/integration"; // icons import GithubLogo from "public/logos/github-square.png"; +import SlackLogo from "public/services/slack.png"; // types import { IWorkspaceIntegration } from "types"; // fetch-keys import { PROJECT_GITHUB_REPOSITORY } from "constants/fetch-keys"; +import { comboMatches } from "@blueprintjs/core"; type Props = { integration: IWorkspaceIntegration; }; +const integrationDetails: { [key: string]: any } = { + github: { + logo: GithubLogo, + installed: + "Activate GitHub integrations on individual projects to sync with specific repositories.", + notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.", + }, + slack: { + logo: SlackLogo, + installed: "Activate Slack integrations on individual projects to sync with specific cahnnels.", + notInstalled: "Connect with Slack with your Plane workspace to sync project issues.", + }, +}; + export const SingleIntegration: React.FC = ({ integration }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -83,29 +99,43 @@ export const SingleIntegration: React.FC = ({ integration }) => {
- GithubLogo + GithubLogo

{integration.integration_detail.title}

-

Select GitHub repository to enable sync.

+

+ {integration.integration_detail.provider === "github" + ? "Select GitHub repository to enable sync." + : integration.integration_detail.provider === "slack" + ? "Select Slack channel to enable sync." + : null} +

- 0 - ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` - : null - } - label={ - syncedGithubRepository && syncedGithubRepository.length > 0 - ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` - : "Select Repository" - } - onChange={handleChange} - /> + {integration.integration_detail.provider === "github" && ( + 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : null + } + label={ + syncedGithubRepository && syncedGithubRepository.length > 0 + ? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}` + : "Select Repository" + } + onChange={handleChange} + /> + )} + {integration.integration_detail.provider === "slack" && ( + + )}
)} diff --git a/apps/app/hooks/use-integration-popup.tsx b/apps/app/hooks/use-integration-popup.tsx index 03137a195..07d696a86 100644 --- a/apps/app/hooks/use-integration-popup.tsx +++ b/apps/app/hooks/use-integration-popup.tsx @@ -6,14 +6,22 @@ const useIntegrationPopup = (provider: string | undefined) => { const [authLoader, setAuthLoader] = useState(false); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const providerUrls: { [key: string]: string } = { github: `https://github.com/apps/${ process.env.NEXT_PUBLIC_GITHUB_APP_NAME - }/installations/new?state=${workspaceSlug as string}`, - slack: "", + }/installations/new?state=${workspaceSlug?.toString()}`, + slack: `https://slack.com/oauth/v2/authorize?scope=chat%3Awrite%2Cim%3Ahistory%2Cim%3Awrite%2Clinks%3Aread%2Clinks%3Awrite%2Cusers%3Aread%2Cusers%3Aread.email&user_scope=&&client_id=${ + process.env.NEXT_PUBLIC_SLACK_CLIENT_ID + }&state=${workspaceSlug?.toString()}&redirect_uri=https://53ff-2405-201-3005-e03e-bc47-b3f9-495d-414d.ngrok-free.app/installations/slack`, + slackChannel: `https://slack.com/oauth/v2/authorize?scope=incoming-webhook&client_id=${ + process.env.NEXT_PUBLIC_SLACK_CLIENT_ID + }&state=${workspaceSlug?.toString()}_${projectId?.toString()}&redirect_uri=https://53ff-2405-201-3005-e03e-bc47-b3f9-495d-414d.ngrok-free.app/installations/slack/connect-project`, }; + + //&project=${projectId?.toString()} + const popup = useRef(); const checkPopup = () => { diff --git a/apps/app/pages/api/slack-redirect.ts b/apps/app/pages/api/slack-redirect.ts new file mode 100644 index 000000000..74fb1379e --- /dev/null +++ b/apps/app/pages/api/slack-redirect.ts @@ -0,0 +1,21 @@ +// pages/api/slack/authorize.js +import axios from "axios"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handleSlackAuthorize(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.body; + + if (!code || code === "") return res.status(400).json({ message: "Code is required" }); + + const response = await axios({ + method: "post", + url: "https://slack.com/api/oauth.v2.access", + params: { + client_id: process.env.NEXT_PUBLIC_SLACK_CLIENT_ID, + client_secret: process.env.NEXT_PUBLIC_SLACK_CLIENT_SECRET, + code, + }, + }); + + res.status(200).json(response.data); +} diff --git a/apps/app/pages/installations/[provider]/connect-project.tsx b/apps/app/pages/installations/[provider]/connect-project.tsx new file mode 100644 index 000000000..7bce47386 --- /dev/null +++ b/apps/app/pages/installations/[provider]/connect-project.tsx @@ -0,0 +1,88 @@ +import React, { useEffect } from "react"; + +import useSWR from "swr"; + +// services +import appinstallationsService from "services/app-installations.service"; +import IntegrationService from "services/integration"; +// components +import { Spinner } from "components/ui"; + +import { useRouter } from "next/router"; + +import { WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; + +interface IGithuPostInstallationProps { + installation_id: string; + setup_action: string; + state: string; + provider: string; + code: string; + projectId: string; +} + +// TODO:Change getServerSideProps to router.query +const AppPostInstallation = ({ + installation_id, + setup_action, + state, + provider, + code, + projectId, +}: IGithuPostInstallationProps) => { + + console.log(state, provider, code) + const { data: workspaceIntegrations } = useSWR( + state ? WORKSPACE_INTEGRATIONS(state as string) : null, + () => (state ? IntegrationService.getWorkspaceIntegrationsList(state as string) : null) + ); + console.log(workspaceIntegrations) + + const workspaceIntegrationId = workspaceIntegrations?.find( + (integration) => integration.integration_detail.provider === provider + ); + + console.log(workspaceIntegrationId); + + useEffect(() => { + if (provider && state && code) { + appinstallationsService + .getSlackAuthDetails(code) + .then((res) => { + const payload = { + metadata: { + ...res, + }, + }; + workspaceIntegrationId && appinstallationsService + .addSlackChannel(state, projectId, workspaceIntegrationId?.integration?.toString(), payload) + .then((r) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err?.response; + }); + }) + .catch((err) => { + console.log(err); + }); + } + }, [state, installation_id, provider, code, projectId, workspaceIntegrationId]); + + return ( +
+

Installing. Please wait...

+ +
+ ); +}; + +export async function getServerSideProps(context: any) { + return { + props: context.query, + }; +} + +export default AppPostInstallation; diff --git a/apps/app/pages/installations/[provider]/index.tsx b/apps/app/pages/installations/[provider]/index.tsx index a804b8d97..50683c13b 100644 --- a/apps/app/pages/installations/[provider]/index.tsx +++ b/apps/app/pages/installations/[provider]/index.tsx @@ -1,15 +1,17 @@ import React, { useEffect } from "react"; - // services import appinstallationsService from "services/app-installations.service"; // components import { Spinner } from "components/ui"; +import { useRouter } from "next/router"; + interface IGithuPostInstallationProps { installation_id: string; setup_action: string; state: string; provider: string; + code: string; } // TODO:Change getServerSideProps to router.query @@ -18,12 +20,13 @@ const AppPostInstallation = ({ setup_action, state, provider, + code, }: IGithuPostInstallationProps) => { useEffect(() => { - if (state && installation_id) { + if (provider === "github" && state && installation_id) { appinstallationsService - .addGithubApp(state, provider, { installation_id }) - .then((res) => { + .addInstallationApp(state, provider, { installation_id }) + .then(() => { window.opener = null; window.open("", "_self"); window.close(); @@ -31,8 +34,32 @@ const AppPostInstallation = ({ .catch((err) => { console.log(err); }); + } else if (provider === "slack" && state && code) { + appinstallationsService + .getSlackAuthDetails(code) + .then((res) => { + const payload = { + metadata: { + ...res, + }, + }; + + appinstallationsService + .addInstallationApp(state, provider, payload) + .then((r) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + throw err?.response; + }); + }) + .catch((err) => { + console.log(err); + }); } - }, [state, installation_id, provider]); + }, [state, installation_id, provider, code]); return (
diff --git a/apps/app/services/app-installations.service.ts b/apps/app/services/app-installations.service.ts index 3ceae3b1a..6ab12bb8a 100644 --- a/apps/app/services/app-installations.service.ts +++ b/apps/app/services/app-installations.service.ts @@ -1,5 +1,11 @@ // services +import axios from "axios"; import APIService from "services/api.service"; +import IntegrationService from "services/integration"; + +import { + WORKSPACE_INTEGRATIONS, +} from "constants/fetch-keys"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -8,13 +14,36 @@ class AppInstallationsService extends APIService { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); } - async addGithubApp(workspaceSlug: string, provider: string, data: any): Promise { + async addInstallationApp(workspaceSlug: string, provider: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/workspace-integrations/${provider}/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } + + async addSlackChannel(workspaceSlug: string, projectId: string, integrationId: string | null | undefined, data: any): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/workspace-integrations/${integrationId}/project-slack-sync/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getSlackAuthDetails(code: string): Promise { + const response = await axios({ + method: "post", + url: "/api/slack-redirect", + data: { + code, + }, + }); + + return response.data; + } } export default new AppInstallationsService();