diff --git a/apps/web/app/api/oauth/authorize/route.ts b/apps/web/app/api/oauth/authorize/route.ts index 922a97beb08..50d5226ddd2 100644 --- a/apps/web/app/api/oauth/authorize/route.ts +++ b/apps/web/app/api/oauth/authorize/route.ts @@ -10,96 +10,101 @@ import { SHOPIFY_INTEGRATION_ID, STRIPE_INTEGRATION_ID } from "@dub/utils"; import { NextResponse } from "next/server"; // POST /api/oauth/authorize - approve OAuth authorization request -export const POST = withWorkspace(async ({ session, req, workspace }) => { - const { - state, - scope, - client_id: clientId, - redirect_uri: redirectUri, - code_challenge: codeChallenge, - code_challenge_method: codeChallengeMethod, - } = authorizeRequestSchema.parse(await parseRequestBody(req)); +export const POST = withWorkspace( + async ({ session, req, workspace }) => { + const { + state, + scope, + client_id: clientId, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + } = authorizeRequestSchema.parse(await parseRequestBody(req)); - // Check if the user has the required scopes for the workspace selected - const userRole = workspace.users[0].role; - const scopesForRole = getScopesForRole(userRole); - const scopesMissing = consolidateScopes(scope).filter( - (scope) => !scopesForRole.includes(scope) && scope !== "user.read", - ); + // Check if the user has the required scopes for the workspace selected + const userRole = workspace.users[0].role; + const scopesForRole = getScopesForRole(userRole); + const scopesMissing = consolidateScopes(scope).filter( + (scope) => !scopesForRole.includes(scope) && scope !== "user.read", + ); - if (scopesMissing.length > 0) { - throw new DubApiError({ - code: "bad_request", - message: "You don't have the permission to install this integration.", + if (scopesMissing.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: "You don't have the permission to install this integration.", + }); + } + + const app = await prisma.oAuthApp.findUniqueOrThrow({ + where: { + clientId, + }, + select: { + redirectUris: true, + pkce: true, + integrationId: true, + }, }); - } - const app = await prisma.oAuthApp.findUniqueOrThrow({ - where: { - clientId, - }, - select: { - redirectUris: true, - pkce: true, - integrationId: true, - }, - }); + if ( + [STRIPE_INTEGRATION_ID, SHOPIFY_INTEGRATION_ID].includes( + app.integrationId, + ) && + (workspace.plan === "free" || workspace.plan === "pro") + ) { + throw new DubApiError({ + code: "bad_request", + message: + "This integration is only available for workspaces with a Business plan or higher. Please upgrade your plan to continue.", + }); + } - if ( - [STRIPE_INTEGRATION_ID, SHOPIFY_INTEGRATION_ID].includes( - app.integrationId, - ) && - (workspace.plan === "free" || workspace.plan === "pro") - ) { - throw new DubApiError({ - code: "bad_request", - message: - "This integration is only available for workspaces with a Business plan or higher. Please upgrade your plan to continue.", - }); - } + const redirectUris = (app.redirectUris || []) as string[]; - const redirectUris = (app.redirectUris || []) as string[]; + if (!redirectUris.includes(redirectUri)) { + throw new DubApiError({ + code: "bad_request", + message: "Invalid redirect_uri parameter for the application.", + }); + } - if (!redirectUris.includes(redirectUri)) { - throw new DubApiError({ - code: "bad_request", - message: "Invalid redirect_uri parameter for the application.", - }); - } + // If PKCE is required, ensure that the code_challenge and code_challenge_method are present + if (app.pkce && (!codeChallenge || !codeChallengeMethod)) { + throw new DubApiError({ + code: "bad_request", + message: "Missing code_challenge or code_challenge_method parameters.", + }); + } - // If PKCE is required, ensure that the code_challenge and code_challenge_method are present - if (app.pkce && (!codeChallenge || !codeChallengeMethod)) { - throw new DubApiError({ - code: "bad_request", - message: "Missing code_challenge or code_challenge_method parameters.", + const { code } = await prisma.oAuthCode.create({ + data: { + clientId, + redirectUri, + projectId: workspace.id, + userId: session.user.id, + scopes: scope.join(" "), + code: createToken({ length: OAUTH_CONFIG.CODE_LENGTH }), + expiresAt: new Date(Date.now() + OAUTH_CONFIG.CODE_LIFETIME * 1000), + ...(app.pkce && { codeChallenge, codeChallengeMethod }), + }, }); - } - - const { code } = await prisma.oAuthCode.create({ - data: { - clientId, - redirectUri, - projectId: workspace.id, - userId: session.user.id, - scopes: scope.join(" "), - code: createToken({ length: OAUTH_CONFIG.CODE_LENGTH }), - expiresAt: new Date(Date.now() + OAUTH_CONFIG.CODE_LIFETIME * 1000), - ...(app.pkce && { codeChallenge, codeChallengeMethod }), - }, - }); - // Generate the callback URL - const callbackUrl = new URL(redirectUri); + // Generate the callback URL + const callbackUrl = new URL(redirectUri); - callbackUrl.searchParams.set("code", code); + callbackUrl.searchParams.set("code", code); - if (state) { - callbackUrl.searchParams.set("state", state); - } + if (state) { + callbackUrl.searchParams.set("state", state); + } - const response = { - callbackUrl: callbackUrl.toString(), - }; + const response = { + callbackUrl: callbackUrl.toString(), + }; - return NextResponse.json(response); -}); + return NextResponse.json(response); + }, + { + requiredPermissions: ["integrations.write"], + }, +); diff --git a/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx b/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx index 5f50af3a76e..788dc74e7b5 100644 --- a/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx +++ b/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx @@ -1,12 +1,13 @@ "use client"; import { consolidateScopes, getScopesForRole } from "@/lib/api/tokens/scopes"; +import { clientAccessCheck } from "@/lib/client-access-check"; import useWorkspaces from "@/lib/swr/use-workspaces"; import { authorizeRequestSchema } from "@/lib/zod/schemas/oauth"; import { WorkspaceSelector } from "@/ui/workspaces/workspace-selector"; import { Button } from "@dub/ui"; import { useSession } from "next-auth/react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -20,41 +21,65 @@ export const AuthorizeForm = ({ code_challenge_method, }: z.infer) => { const { data: session } = useSession(); - const { workspaces } = useWorkspaces(); + const { workspaces, loading: workspacesLoading } = useWorkspaces(); const [submitting, setSubmitting] = useState(false); const [selectedWorkspace, setSelectedWorkspace] = useState( null, ); - // missing scopes for the user's role on the workspace selected - const [missingScopes, setMissingScopes] = useState([]); - useEffect(() => { setSelectedWorkspace(session?.user?.["defaultWorkspace"] || null); }, [session]); - // Check if the user has the required scopes for the workspace selected - useEffect(() => { - if (!selectedWorkspace) { - return; - } - - const workspace = workspaces?.find( - (workspace) => workspace.slug === selectedWorkspace, - ); - - if (!workspace) { - return; - } - - const userRole = workspace.users[0].role; - const scopesForRole = getScopesForRole(userRole); - const scopesMissing = consolidateScopes(scope).filter( - (scope) => !scopesForRole.includes(scope) && scope !== "user.read", - ); - - setMissingScopes(scopesMissing); - }, [selectedWorkspace]); + const { permissionsError, missingScopes, workspaceUnresolvedMessage } = + useMemo(() => { + if (!selectedWorkspace) { + return { + permissionsError: undefined, + missingScopes: [] as string[], + workspaceUnresolvedMessage: undefined, + }; + } + + if (workspacesLoading || workspaces === undefined) { + return { + permissionsError: undefined, + missingScopes: [] as string[], + workspaceUnresolvedMessage: "Loading workspaces...", + }; + } + + const workspace = workspaces.find( + (workspace) => workspace.slug === selectedWorkspace, + ); + + if (!workspace) { + return { + permissionsError: undefined, + missingScopes: [] as string[], + workspaceUnresolvedMessage: "Please select a valid workspace", + }; + } + + const userRole = workspace.users[0].role; + + const permissionsError = clientAccessCheck({ + action: "integrations.write", + role: userRole, + customPermissionDescription: "install this integration", + }).error; + + const scopesForRole = getScopesForRole(userRole); + const missingScopes = consolidateScopes(scope).filter( + (scope) => !scopesForRole.includes(scope) && scope !== "user.read", + ); + + return { + permissionsError, + missingScopes, + workspaceUnresolvedMessage: undefined, + }; + }, [workspaces, workspacesLoading, selectedWorkspace, scope]); // Decline the request const onDecline = () => { @@ -75,12 +100,17 @@ export const AuthorizeForm = ({ return; } - setSubmitting(true); - const workspaceId = workspaces?.find( (workspace) => workspace.slug === selectedWorkspace, )?.id; + if (!workspaceId) { + toast.error("Please select a workspace to continue"); + return; + } + + setSubmitting(true); + const response = await fetch( `/api/oauth/authorize?workspaceId=${workspaceId}`, { @@ -138,13 +168,20 @@ export const AuthorizeForm = ({ text="Authorize" type="submit" loading={submitting} - disabled={!selectedWorkspace} + disabled={ + !selectedWorkspace || + !!workspaceUnresolvedMessage || + !!permissionsError || + missingScopes.length > 0 + } disabledTooltip={ !selectedWorkspace ? "Please select a workspace to continue" - : missingScopes.length > 0 - ? "You don't have the permission to install this integration" - : undefined + : workspaceUnresolvedMessage || + permissionsError || + (missingScopes.length > 0 + ? "You don't have the permission to install this integration" + : undefined) } /> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx index c8fcf8540c7..d9173a8767d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx @@ -360,7 +360,9 @@ export default function IntegrationPageClient({ cta="Upgrade to Advanced" href={`/${slug}/settings/billing/upgrade?plan=advanced`} /> - ) : undefined + ) : ( + permissionsError || undefined + ) } /> )}