Skip to content

Commit

Permalink
[recnet-web] RecNet Slack OAuth Flow (#361)
Browse files Browse the repository at this point in the history
## Description

<!--- Describe your changes in detail -->
Finish slack oauth flow.

## Related Issue

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an
issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps
to reproduce -->
<!--- Please link to the issue here: -->

- #261 
- #64 

## Notes

<!-- Other thing to say -->


## Test

<!--- Please describe in detail how you tested your changes locally. -->
Before test, update `.env.local`
- `NEXT_PUBLIC_BASE_URL` should be `https://localhost:3000`
- Add `SLACK_APP_CLIENT_ID`
- Add `SLACK_OAUTH_APP_SCOPES` 
- Add`SLACK_OAUTH_REDIRECT_URI="/api/slack/oauth/callback"`

Run `nx dev:ssl recnet` and go to `https://localhost:3000`
- Go to setting and subscription tab -> Click the button to install
slack app to your workspace
- Should redirect you to slack page
- Should able to choose different workspace
- After clicking "Allow", should redirect back to frontend and see the
success message
- Go to setting again, should see the workspace name of where you
install the app


## Screenshots (if appropriate):

<!--- Add screenshots of your changes here -->
![Screenshot 2024-11-21 at 5 59
40 PM](https://github.com/user-attachments/assets/76d381f6-4dfd-49ee-976a-944b686a6d55)
![Screenshot 2024-11-21 at 5 59
50 PM](https://github.com/user-attachments/assets/092f53a1-81aa-4914-bebb-2ebc6f27f1d5)
![Screenshot 2024-11-21 at 6 00
07 PM](https://github.com/user-attachments/assets/cae362f9-58b8-4b0a-9f2a-ea4c1eb7dfc4)

## TODO

- [ ] Clear `console.log` or `console.error` for debug usage
- [ ] Update the documentation `recnet-docs` if needed
  • Loading branch information
swh00tw authored Dec 2, 2024
2 parents fa96085 + 87a1e67 commit 461a3ba
Show file tree
Hide file tree
Showing 15 changed files with 305 additions and 27 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Thumbs.db

# Next.js
.next
certificates

# env
.env
Expand Down
3 changes: 3 additions & 0 deletions apps/recnet/.env.local.sample
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
CRON_SECRET=
RECNET_API_ENDPOINT="http://localhost:4000"
SLACK_APP_CLIENT_ID=""
SLACK_OAUTH_APP_SCOPES=""
SLACK_OAUTH_REDIRECT_URI=""
12 changes: 12 additions & 0 deletions apps/recnet/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@
],
"cwd": "apps/recnet"
}
},
"dev:ssl": {
"executor": "nx:run-commands",
"options": {
"commands": [
{
"command": "next dev --experimental-https",
"forwardAllArgs": true
}
],
"cwd": "apps/recnet"
}
}
},
"tags": ["type:app"]
Expand Down
27 changes: 27 additions & 0 deletions apps/recnet/src/app/api/slack/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";

import { serverClient } from "@recnet/recnet-web/app/_trpc/serverClient";

export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const code = searchParams.get("code");
const errorDesc = searchParams.get("error_description");

if (!code) {
redirect(
`/feeds?slackOAuthStatus=error${errorDesc ? `&error_description=${errorDesc}` : ""}`
);
}
let isSuccess = true;
let workspaceName = "";
try {
const data = await serverClient.slackOAuth2FA({ code });
workspaceName = data.workspaceName;
} catch (e) {
isSuccess = false;
}
redirect(
`/feeds?slackOAuthStatus=${isSuccess ? `success&workspace_name=${workspaceName}` : "error"}${errorDesc ? `&error_description=${errorDesc}` : ""}`
);
}
7 changes: 7 additions & 0 deletions apps/recnet/src/app/api/slack/oauth/install/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";

import { generateOAuthLink } from "../slackAppInstallHelper";

export async function GET(req: Request) {
redirect(generateOAuthLink());
}
5 changes: 5 additions & 0 deletions apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { serverEnv } from "@recnet/recnet-web/serverEnv";

export function generateOAuthLink(): string {
return `https://slack.com/oauth/v2/authorize?scope=${serverEnv.SLACK_OAUTH_APP_SCOPES}&client_id=${serverEnv.SLACK_APP_CLIENT_ID}&redirect_uri=${serverEnv.SLACK_OAUTH_REDIRECT_URI}`;
}
76 changes: 76 additions & 0 deletions apps/recnet/src/app/feeds/SlackOAuthModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";
import { Button, Dialog } from "@radix-ui/themes";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useEffect, useState } from "react";

/**
* Modal to display the result of slack OAuth flow
*/
export function SlackOAuthModal() {
const [shouldShow, setShouldShow] = useState(false);
const [oauthStatus, setOAuthStatus] = useState<"success" | "error" | null>(
null
);
const pathname = usePathname();
const router = useRouter();

const searchParams = useSearchParams();

useEffect(() => {
const status = searchParams.get("slackOAuthStatus");
if (status) {
setShouldShow(true);
setOAuthStatus(status as "success" | "error");
}
}, [searchParams]);

if (!shouldShow || !oauthStatus) {
return null;
}

return (
<Dialog.Root
open={shouldShow}
onOpenChange={(open) => {
// when closed, remove the search param
if (!open) {
router.replace(pathname);
}
setShouldShow(open);
}}
>
<Dialog.Content
maxWidth={{ initial: "450px", md: "450px" }}
style={{
maxHeight: "75vh",
padding: "0",
}}
>
<div className="flex flex-col px-6 pb-6 pt-8">
<Dialog.Title>
{oauthStatus === "success"
? "✅ You are all set!"
: "❌ Slack OAuth flow failed"}
</Dialog.Title>
<Dialog.Description className="text-gray-11" size="2">
{oauthStatus === "success"
? `Successfully installed the Slack app! You can now receive message from us in workspace: ${searchParams.get("workspace_name")}.`
: searchParams.get("error_description") ||
"Slack OAuth flow failed. Please try again or contact us."}
</Dialog.Description>
<div className="flex flex-row justify-end items-center mt-8">
<Button
className="mr-4"
onClick={() => {
setShouldShow(false);
router.replace(pathname);
}}
>
{oauthStatus === "success" ? "Got it!" : "Close"}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
);
}
3 changes: 3 additions & 0 deletions apps/recnet/src/app/feeds/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
formatDate,
} from "@recnet/recnet-date-fns";

import { SlackOAuthModal } from "./SlackOAuthModal";

import { trpc } from "../_trpc/client";
import { OnboardingDialog } from "../onboard/OnboardingDialog";

Expand Down Expand Up @@ -124,6 +126,7 @@ export default function FeedPage({
"md:py-12"
)}
>
<SlackOAuthModal />
<OnboardingDialog />
{Object.keys(recsGroupByTitle).length > 0 ? (
<>
Expand Down
12 changes: 1 addition & 11 deletions apps/recnet/src/clientEnv.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import { z } from "zod";

function resolveBaseUrl(env: string | undefined) {
/**
* If the environment is preview, we need to use the Vercel branch URL.
* Otherwise, we use the base URL.
* Ref: https://vercel.com/docs/projects/environment-variables/framework-environment-variables#NEXT_PUBLIC_VERCEL_BRANCH_URL
*/
if (env === "preview") {
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`;
}
return process.env.NEXT_PUBLIC_BASE_URL;
}
import { resolveBaseUrl } from "./utils/resolveBaseUrl";

export const clientEnvSchema = z.object({
NEXT_PUBLIC_FIREBASE_API_KEY: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/recnet/src/components/DoubleConfirmButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface DoubleConfirmButtonProps {
onConfirm: () => Promise<void>;
children: React.ReactNode;
title: string;
description: string;
description: string | React.ReactNode;
cancelButtonProps?: React.ComponentProps<typeof Button>;
confirmButtonProps?: React.ComponentProps<typeof Button>;
}
Expand Down
119 changes: 104 additions & 15 deletions apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import {
Flex,
Text,
CheckboxCards,
Badge,
Button,
} from "@radix-ui/themes";
import { ChevronDown } from "lucide-react";
import { ChevronDown, Slack as SlackIcon } from "lucide-react";
import { useState } from "react";
import { Controller, useForm, useFormState } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";

import { trpc } from "@recnet/recnet-web/app/_trpc/client";
import { DoubleConfirmButton } from "@recnet/recnet-web/components/DoubleConfirmButton";
import { RecNetLink } from "@recnet/recnet-web/components/Link";
import { LoadingBox } from "@recnet/recnet-web/components/LoadingBox";
import { cn } from "@recnet/recnet-web/utils/cn";

Expand Down Expand Up @@ -60,6 +61,7 @@ function SubscriptionTypeCard(props: {
const { isDirty } = useFormState({ control });

const updateSubscriptionMutation = trpc.updateSubscription.useMutation();
const { data: slackOAuthData } = trpc.getSlackOAuthStatus.useQuery();

return (
<Accordion.Item value={type} className="w-full">
Expand Down Expand Up @@ -92,9 +94,11 @@ function SubscriptionTypeCard(props: {
onSubmit={handleSubmit(
async (data, e) => {
setIsSubmitting(true);
// handle special case for WEEKLY DIGEST
// for weekly digest, at least one channel must be selected
// if no, then show error message
/**
* Special case 1: WEEKLY_DIGEST
* For weekly digest, at least one channel must be selected
* if no, then show error message
*/
if (type === "WEEKLY_DIGEST" && data.channels.length === 0) {
setError("channels", {
type: "manual",
Expand All @@ -104,6 +108,24 @@ function SubscriptionTypeCard(props: {
setIsSubmitting(false);
return;
}
/*
* Special case 2: SLACK distribution channel
* When user selects slack channel, we need to check if the user has completed slack integration oauth flow or not
* If not, then show error message and ask user to complete slack integration
*/
if (
slackOAuthData?.workspaceName === null &&
data.channels.includes(subscriptionChannelSchema.enum.SLACK)
) {
setError("channels", {
type: "manual",
message:
"To enable slack distribution channel, you need to complete slack integration first. See 'Slack Integration' below to learn more",
});
setIsSubmitting(false);
return;
}

await updateSubscriptionMutation.mutateAsync({
type,
channels: data.channels,
Expand Down Expand Up @@ -151,16 +173,6 @@ function SubscriptionTypeCard(props: {
}}
/>
</div>
<Flex className="gap-x-1 text-gray-11">
<Badge size="1" color="orange">
BETA
</Badge>
<Text size="1">
Distribute by Slack is currently in beta version. Only people in
Cornell-NLP slack workspace can use this feature. And the email
account of the slack account must match the RecNet account.
</Text>
</Flex>
<Flex className="py-2 gap-x-1">
<Button
variant="solid"
Expand Down Expand Up @@ -194,10 +206,17 @@ function SubscriptionTypeCard(props: {

export function SubscriptionSetting() {
const { data, isFetching } = trpc.getSubscriptions.useQuery();
const { data: slackOAuthData, isFetching: isFetchingSlackOAuthData } =
trpc.getSlackOAuthStatus.useQuery();
const deleteSlackOAuthInfoMutation = trpc.deleteSlackOAuthInfo.useMutation();
const utils = trpc.useUtils();

const [openedType, setOpenType] = useState<SubscriptionType | undefined>(
undefined
);

const workspaceName = slackOAuthData?.workspaceName ?? null;

return (
<div>
<Dialog.Title>Subscription Setting</Dialog.Title>
Expand Down Expand Up @@ -232,6 +251,76 @@ export function SubscriptionSetting() {
})}
</Accordion.Root>
)}

<Text size="4" className="block mt-4">
Slack Integration
</Text>
<Text size="1" className="block text-gray-11 mb-2 mt-1">
Install our Slack App to enable distributing subscription through Slack.
</Text>
{isFetchingSlackOAuthData ? (
<LoadingBox />
) : workspaceName === null ? (
<RecNetLink href="api/slack/oauth/install">
<Button
variant="solid"
className="my-2 bg-[#2EB67D] dark:bg-[#4A154B] py-1 cursor-pointer"
size="3"
>
<SlackIcon />
Add our app to your workspace
</Button>
</RecNetLink>
) : (
<div className="flex flex-row justify-between items-center pr-4">
<Text size="2" className="text-gray-11">
✅ Currently installed in{" "}
<span className="text-blue-8">{workspaceName}</span>
</Text>
<DoubleConfirmButton
onConfirm={async () => {
await deleteSlackOAuthInfoMutation.mutateAsync();
utils.getSlackOAuthStatus.invalidate();
}}
title="Are you sure?"
description={
<div>
{[
"We will disconnect and will not be able to distribute subscription through slack.",
"But the slack app will still be installed in your workspace.",
"To remove it from your workspace, follow the instructions ",
].map((text, index) => (
<Text
key={index}
size="2"
className="inline-block text-gray-11 mr-1"
>
{text}
</Text>
))}
<RecNetLink
radixLinkProps={{
target: "_blank",
}}
href="https://slack.com/help/articles/360003125231-Remove-apps-and-custom-integrations-from-your-workspace"
>
here
</RecNetLink>
.
</div>
}
>
<Button variant="ghost" className="cursor-pointer">
<Text
size="1"
className="text-gray-10 hover:text-gray-11 transition-all ease-in-out"
>
remove?
</Text>
</Button>
</DoubleConfirmButton>
</div>
)}
</div>
);
}
Loading

0 comments on commit 461a3ba

Please sign in to comment.