diff --git a/.gitignore b/.gitignore index ad63f2e6..6ecca4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ Thumbs.db # Next.js .next +certificates # env .env diff --git a/apps/recnet/.env.local.sample b/apps/recnet/.env.local.sample index 1a534a55..f55eda99 100644 --- a/apps/recnet/.env.local.sample +++ b/apps/recnet/.env.local.sample @@ -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="" diff --git a/apps/recnet/project.json b/apps/recnet/project.json index efb6fa04..32fdac87 100644 --- a/apps/recnet/project.json +++ b/apps/recnet/project.json @@ -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"] diff --git a/apps/recnet/src/app/api/slack/oauth/callback/route.ts b/apps/recnet/src/app/api/slack/oauth/callback/route.ts new file mode 100644 index 00000000..31034e63 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/callback/route.ts @@ -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}` : ""}` + ); +} diff --git a/apps/recnet/src/app/api/slack/oauth/install/route.ts b/apps/recnet/src/app/api/slack/oauth/install/route.ts new file mode 100644 index 00000000..16b9e7e4 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/install/route.ts @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +import { generateOAuthLink } from "../slackAppInstallHelper"; + +export async function GET(req: Request) { + redirect(generateOAuthLink()); +} diff --git a/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts b/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts new file mode 100644 index 00000000..5ebeaab6 --- /dev/null +++ b/apps/recnet/src/app/api/slack/oauth/slackAppInstallHelper.ts @@ -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}`; +} diff --git a/apps/recnet/src/app/feeds/SlackOAuthModal.tsx b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx new file mode 100644 index 00000000..4a955142 --- /dev/null +++ b/apps/recnet/src/app/feeds/SlackOAuthModal.tsx @@ -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 ( + { + // when closed, remove the search param + if (!open) { + router.replace(pathname); + } + setShouldShow(open); + }} + > + +
+ + {oauthStatus === "success" + ? "✅ You are all set!" + : "❌ Slack OAuth flow failed"} + + + {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."} + +
+ +
+
+
+
+ ); +} diff --git a/apps/recnet/src/app/feeds/page.tsx b/apps/recnet/src/app/feeds/page.tsx index 755d3bb8..1f7748c5 100644 --- a/apps/recnet/src/app/feeds/page.tsx +++ b/apps/recnet/src/app/feeds/page.tsx @@ -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"; @@ -124,6 +126,7 @@ export default function FeedPage({ "md:py-12" )} > + {Object.keys(recsGroupByTitle).length > 0 ? ( <> diff --git a/apps/recnet/src/clientEnv.ts b/apps/recnet/src/clientEnv.ts index 6f5986cf..68721d01 100644 --- a/apps/recnet/src/clientEnv.ts +++ b/apps/recnet/src/clientEnv.ts @@ -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(), diff --git a/apps/recnet/src/components/DoubleConfirmButton.tsx b/apps/recnet/src/components/DoubleConfirmButton.tsx index b3989b05..cb65fd3f 100644 --- a/apps/recnet/src/components/DoubleConfirmButton.tsx +++ b/apps/recnet/src/components/DoubleConfirmButton.tsx @@ -9,7 +9,7 @@ interface DoubleConfirmButtonProps { onConfirm: () => Promise; children: React.ReactNode; title: string; - description: string; + description: string | React.ReactNode; cancelButtonProps?: React.ComponentProps; confirmButtonProps?: React.ComponentProps; } diff --git a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx index 6293d31b..0f3eb5d7 100644 --- a/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx +++ b/apps/recnet/src/components/setting/subscription/SubscriptionSetting.tsx @@ -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"; @@ -60,6 +61,7 @@ function SubscriptionTypeCard(props: { const { isDirty } = useFormState({ control }); const updateSubscriptionMutation = trpc.updateSubscription.useMutation(); + const { data: slackOAuthData } = trpc.getSlackOAuthStatus.useQuery(); return ( @@ -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", @@ -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, @@ -151,16 +173,6 @@ function SubscriptionTypeCard(props: { }} /> - - - BETA - - - 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. - - + + ) : ( +
+ + ✅ Currently installed in{" "} + {workspaceName} + + { + await deleteSlackOAuthInfoMutation.mutateAsync(); + utils.getSlackOAuthStatus.invalidate(); + }} + title="Are you sure?" + description={ +
+ {[ + "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} + + ))} + + here + + . +
+ } + > + +
+
+ )} ); } diff --git a/apps/recnet/src/server/routers/subscription.ts b/apps/recnet/src/server/routers/subscription.ts index 6e7da4b6..4b7d2077 100644 --- a/apps/recnet/src/server/routers/subscription.ts +++ b/apps/recnet/src/server/routers/subscription.ts @@ -1,7 +1,10 @@ import { getUsersSubscriptionsResponseSchema, + getUsersSubscriptionsSlackOauthResponseSchema, + postUsersSubscriptionsSlackOauthResponseSchema, postUsersSubscriptionsRequestSchema, postUsersSubscriptionsResponseSchema, + postUsersSubscriptionsSlackOauthRequestSchema, } from "@recnet/recnet-api-model"; import { checkRecnetJWTProcedure } from "./middleware"; @@ -27,4 +30,30 @@ export const subscriptionRouter = router({ }); return postUsersSubscriptionsResponseSchema.parse(data); }), + slackOAuth2FA: checkRecnetJWTProcedure + .input(postUsersSubscriptionsSlackOauthRequestSchema) + .output(postUsersSubscriptionsSlackOauthResponseSchema) + .mutation(async (opts) => { + const { code } = opts.input; + const { recnetApi } = opts.ctx; + + const { data } = await recnetApi.post( + "/users/subscriptions/slack/oauth", + { + code, + } + ); + return postUsersSubscriptionsSlackOauthResponseSchema.parse(data); + }), + getSlackOAuthStatus: checkRecnetJWTProcedure + .output(getUsersSubscriptionsSlackOauthResponseSchema) + .query(async (opts) => { + const { recnetApi } = opts.ctx; + const { data } = await recnetApi.get("/users/subscriptions/slack/oauth"); + return getUsersSubscriptionsSlackOauthResponseSchema.parse(data); + }), + deleteSlackOAuthInfo: checkRecnetJWTProcedure.mutation(async (opts) => { + const { recnetApi } = opts.ctx; + await recnetApi.delete("/users/subscriptions/slack/oauth"); + }), }); diff --git a/apps/recnet/src/serverEnv.ts b/apps/recnet/src/serverEnv.ts index d2d64511..e7d28f06 100644 --- a/apps/recnet/src/serverEnv.ts +++ b/apps/recnet/src/serverEnv.ts @@ -1,5 +1,15 @@ import { z } from "zod"; +import { resolveBaseUrl } from "./utils/resolveBaseUrl"; + +function resolveSlackRedirectUri(env: string | undefined) { + const baseUrl = resolveBaseUrl(env); + if (!baseUrl) { + return undefined; + } + return baseUrl + process.env.SLACK_OAUTH_REDIRECT_URI; +} + const serverConfigSchema = z.object({ USE_SECURE_COOKIES: z.coerce.boolean(), COOKIE_SIGNATURE_KEY: z.string(), @@ -9,6 +19,9 @@ const serverConfigSchema = z.object({ NEXT_PUBLIC_FIREBASE_API_KEY: z.string(), NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string(), RECNET_API_ENDPOINT: z.string(), + SLACK_APP_CLIENT_ID: z.string(), + SLACK_OAUTH_APP_SCOPES: z.string(), + SLACK_OAUTH_REDIRECT_URI: z.string(), }); const serverConfigRes = serverConfigSchema.safeParse({ @@ -20,6 +33,11 @@ const serverConfigRes = serverConfigSchema.safeParse({ NEXT_PUBLIC_FIREBASE_API_KEY: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, RECNET_API_ENDPOINT: process.env.RECNET_API_ENDPOINT, + SLACK_APP_CLIENT_ID: process.env.SLACK_APP_CLIENT_ID, + SLACK_OAUTH_APP_SCOPES: process.env.SLACK_OAUTH_APP_SCOPES, + SLACK_OAUTH_REDIRECT_URI: resolveSlackRedirectUri( + process.env.NEXT_PUBLIC_VERCEL_ENV + ), }); if (!serverConfigRes.success) { diff --git a/apps/recnet/src/utils/resolveBaseUrl.ts b/apps/recnet/src/utils/resolveBaseUrl.ts new file mode 100644 index 00000000..f262d23f --- /dev/null +++ b/apps/recnet/src/utils/resolveBaseUrl.ts @@ -0,0 +1,11 @@ +export 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; +} diff --git a/libs/recnet-api-model/src/lib/api/user.ts b/libs/recnet-api-model/src/lib/api/user.ts index e0cb3941..d7d1a053 100644 --- a/libs/recnet-api-model/src/lib/api/user.ts +++ b/libs/recnet-api-model/src/lib/api/user.ts @@ -139,6 +139,13 @@ export type PostUsersSubscriptionsSlackOauthRequest = z.infer< typeof postUsersSubscriptionsSlackOauthRequestSchema >; +export const postUsersSubscriptionsSlackOauthResponseSchema = z.object({ + workspaceName: z.string(), +}); +export type PostUsersSubscriptionsSlackOauthResponse = z.infer< + typeof postUsersSubscriptionsSlackOauthResponseSchema +>; + // GET /users/subscriptions/slack/oauth export const getUsersSubscriptionsSlackOauthResponseSchema = z.object({ workspaceName: z.string().nullable(),