Skip to content
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

[recnet-web] RecNet Slack OAuth Flow #361

Merged
merged 16 commits into from
Dec 2, 2024
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
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
Loading