From 6563dc7998d4325e4b1ffaa29a71cc789debcfbc Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:02:56 -0400 Subject: [PATCH] refactor: authorize page (#44522) Refactor authorize page to move Next.js dependencies into the page shell and have Next.js-agnostic code for the core logic. Add unit tests for authorize screen. ## Summary by CodeRabbit * **New Features** * New end-to-end API authorization UI: loading, error, invalid, approved, and main approval screens. * Organization selector with preselection, create-organization link, MCP warning, expiration handling, and approve/decline actions. * Improved page title handling via layout/head provider. * **Tests** * Added comprehensive component tests covering loading, error, approval flows, organization states, validation, and side effects. --- .../ApiAuthorization.Approved.tsx | 53 +++ .../ApiAuthorization.Error.tsx | 38 ++ .../ApiAuthorization.Form.tsx | 376 ++++++++++++++++++ .../ApiAuthorization.Invalid.tsx | 28 ++ .../ApiAuthorization.Loading.tsx | 28 ++ .../ApiAuthorization.Schema.ts | 8 + .../ApiAuthorization.Valid.tsx | 206 ++++++++++ .../ApiAuthorization/ApiAuthorization.tsx | 27 ++ .../layouts/APIAuthorizationLayout.tsx | 20 +- apps/studio/components/ui/Head.tsx | 12 + apps/studio/pages/authorize.tsx | 362 +---------------- apps/studio/pages/cli/login.tsx | 14 +- .../pages/partners/stripe/projects/login.tsx | 5 +- .../components/ApiAuthorization.test.tsx | 333 ++++++++++++++++ 14 files changed, 1149 insertions(+), 361 deletions(-) create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Schema.ts create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx create mode 100644 apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx create mode 100644 apps/studio/components/ui/Head.tsx create mode 100644 apps/studio/tests/components/ApiAuthorization.test.tsx diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx new file mode 100644 index 00000000000..fb224229007 --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx @@ -0,0 +1,53 @@ +import dayjs from 'dayjs' +import type { ReactNode } from 'react' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Card, + CardContent, + CardHeader, + CheckIcon, +} from 'ui' + +import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' +import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' +import type { Organization } from '@/types' + +export interface ApiAuthorizationApprovedScreenProps { + requester: ApiAuthorizationResponse + organization: Organization | undefined +} + +export function ApiAuthorizationApprovedScreen({ + requester, + organization, +}: ApiAuthorizationApprovedScreenProps): ReactNode { + return ( + + Authorize API access for {requester.name} + + + + This authorization request has been approved + +

+ {requester.name} has been approved access to the organization " + {organization?.name ?? 'Unknown'}" and all of its projects for the following scopes: +

+ +

+ Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')} +

+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx new file mode 100644 index 00000000000..4fbef36eefd --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Card, + CardContent, + CardHeader, + WarningIcon, +} from 'ui' + +import type { ResourceError } from '@/data/api-authorization/api-authorization-query' + +export interface ApiAuthorizationErrorScreenProps { + error: ResourceError | undefined +} + +export function ApiAuthorizationErrorScreen({ + error, +}: ApiAuthorizationErrorScreenProps): ReactNode { + return ( + + Authorize API access + + + + + Failed to fetch details for API authorization request + + +

Please retry your authorization request from the requesting app

+ {error &&

Error: {error?.message}

} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx new file mode 100644 index 00000000000..64e429f60cf --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx @@ -0,0 +1,376 @@ +import dayjs from 'dayjs' +import Link from 'next/link' +import { useMemo, type ReactNode } from 'react' +import type { UseFormReturn } from 'react-hook-form' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormMessage_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + WarningIcon, +} from 'ui' +import { ShimmeringLoader } from 'ui-patterns' +import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' + +import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema' +import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' +import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' +import { BASE_PATH } from '@/lib/constants' +import type { Organization, ResponseError } from '@/types' + +type OrganizationsState_Loading = { + _tag: 'loading' +} + +type OrganizationsState_Error = { + _tag: 'error' + error: ResponseError | null +} + +type OrganizationsState_Empty = { + _tag: 'empty' +} + +type OrganizationsState_NotMember = { + _tag: 'not_member' +} + +type OrganizationsState_Success = { + _tag: 'success' + organizations: Array +} + +type OrganizationsState = + | OrganizationsState_Loading + | OrganizationsState_Error + | OrganizationsState_Empty + | OrganizationsState_NotMember + | OrganizationsState_Success + +export interface ApiAuthorizationMainViewProps { + approvalState: ApprovalState + form: UseFormReturn + requester: ApiAuthorizationResponse + organizations: OrganizationsState + requestedOrganizationSlug: string | undefined + onApprove: () => void + onDecline: () => void +} + +export function ApiAuthorizationMainView({ + approvalState, + form, + requester, + organizations, + requestedOrganizationSlug, + onApprove, + onDecline, +}: ApiAuthorizationMainViewProps): ReactNode { + const isMcpClient = requester.registration_type === 'dynamic' + const isExpired = dayjs().isAfter(dayjs(requester.expires_at)) + + return ( + + {isMcpClient && } + + {isExpired && } + {organizations._tag === 'loading' && } + {organizations._tag === 'error' && } + {organizations._tag === 'empty' && } + {organizations._tag === 'not_member' && } + {organizations._tag === 'success' && ( + + )} + + + ) +} + +interface FormShellProps { + title: string + children: ReactNode +} + +function FormShell({ title, children }: FormShellProps): ReactNode { + return ( + + {title} + {children} + + ) +} + +function McpNotice(): ReactNode { + return ( + + + MCP Client Connection + + This is an MCP (Model Context Protocol) client designed to connect with AI applications. + Please ensure you trust this application before granting access to your organization's data. + + + ) +} + +function ExpiredNotice(): ReactNode { + return ( + + + This authorization request is expired + + Please retry your authorization request from the requesting app + + + ) +} + +function OrganizationsLoader(): ReactNode { + return ( +
+ + +
+ ) +} + +interface OrganizationsErrorNoticeProps { + error: ResponseError | null +} + +function OrganizationsErrorNotice({ error }: OrganizationsErrorNoticeProps): ReactNode { + return ( + + + There was an error loading your organizations + + Please try again. If the problem persists, contact support. + {error &&

Error: {error.message}

} +
+
+ ) +} + +function OrganizationsEmptyState(): ReactNode { + return ( + + + Organization is needed for installing an integration + + Your account isn't associated with any organizations. To use this integration, it must be + installed within an organization. You'll be redirected to create an organization first. + + + ) +} + +function NotMemberOfOrganizationNotice(): ReactNode { + return ( + + + Organization is needed for installing an integration + + Your account is not a member of the pre-selected organization. To use this integration, it + must be installed within an organization your account is associated with. + + + ) +} + +interface OrganizationSelectorProps { + form: UseFormReturn + requester: ApiAuthorizationResponse + requestedOrganizationSlug: string | undefined + organizations: Array + disabled?: boolean +} + +function OrganizationSelector({ + form, + requester, + requestedOrganizationSlug, + organizations, + disabled = false, +}: OrganizationSelectorProps): ReactNode { + return ( + + ( + + + + + + + + + {organizations.map((organization) => ( + + {organization.name} + + ))} + + + + + + )} + /> + + ) +} + +interface FormFooterProps { + disabled?: boolean + approvalState: ApprovalState + requester: ApiAuthorizationResponse + organizations: OrganizationsState + onDecline: () => void + onApprove: () => void +} + +function FormFooter({ + disabled = false, + approvalState, + requester, + organizations, + onDecline, + onApprove, +}: FormFooterProps): ReactNode { + const showApprovalButton = organizations._tag === 'success' || organizations._tag === 'not_member' + + return ( + + + {organizations._tag === 'loading' && ( + Authorize {requester.name} + )} + {organizations._tag === 'empty' && } + {showApprovalButton && ( + + )} + + ) +} + +interface LoadingApprovalButtonProps { + children: ReactNode +} + +function LoadingApprovalButton({ children }: LoadingApprovalButtonProps): ReactNode { + return +} + +function createReturnToSearchParam(): string | null { + if (typeof window === 'undefined') { + return null + } + + const basePath = BASE_PATH + + let pathname = basePath ? location.pathname.replace(basePath, '') : location.pathname + if (location.search) { + pathname += location.search + } + + return pathname +} + +function CreateOrganizationLink(): ReactNode { + const searchParamString = useMemo(function createSearchParams() { + const searchParams = new URLSearchParams() + const returnTo = createReturnToSearchParam() + if (returnTo) { + searchParams.set('returnTo', returnTo) + } + return searchParams.toString() + }, []) + + return ( + + ) +} + +interface ApprovalButtonProps { + disabled?: boolean + approvalState: ApprovalState + requester: ApiAuthorizationResponse + onApprove: () => void +} + +function ApprovalButton({ + disabled, + approvalState, + requester, + onApprove, +}: ApprovalButtonProps): ReactNode { + return ( + + ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx new file mode 100644 index 00000000000..1ccfd92cea6 --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Card, CardContent, CardHeader } from 'ui' + +export interface ApiAuthorizationInvalidScreenProps { + missingParameters: Array +} + +export function ApiAuthorizationInvalidScreen({ + missingParameters, +}: ApiAuthorizationInvalidScreenProps): ReactNode { + const isPlural = missingParameters.length > 1 + const paragraphFontClass = 'text-sm text-muted-foreground' + + return ( + + Missing parameters + +

+ Cannot authorize this request because the URL is missing the following parameter + {isPlural ? 's' : ''}: {missingParameters.join(', ')}. +

+

+ If you followed a link here, please check the link and try again. +

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx new file mode 100644 index 00000000000..89e6c6ff0a2 --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Card, CardContent, CardHeader } from 'ui' +import { ShimmeringLoader } from 'ui-patterns' + +export function ApiAuthorizationLoadingScreen(): ReactNode { + return ( + + Authorize API access + + Loading... +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Schema.ts b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Schema.ts new file mode 100644 index 00000000000..4b407e6c228 --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const approvalFormSchema = z.object({ + selectedOrgSlug: z.string().min(1, 'Please select an organization'), +}) +export type IApprovalFormSchema = z.infer + +export type ApprovalState = 'indeterminate' | 'approving' | 'declining' | 'approved' | 'declined' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx new file mode 100644 index 00000000000..bf81214439b --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx @@ -0,0 +1,206 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useForm, type UseFormReturn } from 'react-hook-form' +import { toast } from 'sonner' + +import { ApiAuthorizationApprovedScreen } from './ApiAuthorization.Approved' +import { ApiAuthorizationErrorScreen } from './ApiAuthorization.Error' +import { ApiAuthorizationMainView } from './ApiAuthorization.Form' +import { ApiAuthorizationLoadingScreen } from './ApiAuthorization.Loading' +import { + approvalFormSchema, + type ApprovalState, + type IApprovalFormSchema, +} from './ApiAuthorization.Schema' +import { useApiAuthorizationApproveMutation } from '@/data/api-authorization/api-authorization-approve-mutation' +import { useApiAuthorizationDeclineMutation } from '@/data/api-authorization/api-authorization-decline-mutation' +import { useApiAuthorizationQuery } from '@/data/api-authorization/api-authorization-query' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' +import type { Organization } from '@/types' + +function getMatchingOrganization( + organization_slug: string | undefined, + organizations: Array | undefined +): Organization | null { + if (!organization_slug || !organizations) return null + return organizations.find(({ slug }) => slug === organization_slug) ?? null +} + +interface PreselectOrganizationSlugParameters { + form: UseFormReturn + organization_slug: string | undefined + organizations: Array<{ slug: string }> +} + +function preselectOrganizationSlug({ + form, + organization_slug, + organizations, +}: PreselectOrganizationSlugParameters) { + if (organization_slug) { + const preselected = organizations.find(({ slug }) => slug === organization_slug) + if (preselected) form.setValue('selectedOrgSlug', preselected.slug) + } else if (!form.getValues('selectedOrgSlug') && organizations.length === 1) { + form.setValue('selectedOrgSlug', organizations[0].slug) + } +} + +function useOrganizationsState(organization_slug: string | undefined) { + const { + data: organizations, + isPending: isLoadingOrganizations, + isError: isErrorOrganizations, + error: organizationsError, + } = useOrganizationsQuery() + + const organizationsState = useMemo( + function calculateOrganizationsState() { + if (isLoadingOrganizations) { + return { _tag: 'loading' as const } + } + if (isErrorOrganizations) { + return { _tag: 'error' as const, error: organizationsError } + } + if (organizations.length === 0) { + return { _tag: 'empty' as const } + } + if (organization_slug) { + const matchingOrganization = getMatchingOrganization(organization_slug, organizations) + if (!matchingOrganization) { + return { _tag: 'not_member' as const } + } + } + return { _tag: 'success' as const, organizations } + }, + [ + isLoadingOrganizations, + isErrorOrganizations, + organizationsError, + organizations, + organization_slug, + ] + ) + + return organizationsState +} + +function usePrefillFormOnOrganizationsSuccess( + form: UseFormReturn, + organizationsState: ReturnType, + organization_slug: string | undefined +) { + const prefillForm = useStaticEffectEvent(() => { + if (organizationsState._tag === 'success') { + preselectOrganizationSlug({ + form, + organization_slug, + organizations: organizationsState.organizations, + }) + } + }) + useEffect(() => { + if (organizationsState._tag === 'success') { + prefillForm() + } + }, [organizationsState._tag, prefillForm]) +} + +export interface ApiAuthorizationValidScreenProps { + auth_id: string + organization_slug: string | undefined + navigate: (destination: string) => void +} + +export function ApiAuthorizationValidScreen({ + auth_id, + organization_slug, + navigate, +}: ApiAuthorizationValidScreenProps): ReactNode { + const [approvalState, setApprovalState] = useState('indeterminate') + + const form = useForm({ + resolver: zodResolver(approvalFormSchema), + defaultValues: { selectedOrgSlug: '' }, + mode: 'onSubmit', + reValidateMode: 'onBlur', + }) + + const organizationsState = useOrganizationsState(organization_slug) + usePrefillFormOnOrganizationsSuccess(form, organizationsState, organization_slug) + + const { + data: requester, + isPending: isLoading, + isError, + error, + } = useApiAuthorizationQuery({ id: auth_id }) + const isApproved = (requester?.approved_at ?? null) !== null + + const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ + onSuccess: (res) => { + window.location.href = res.url + }, + }) + const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ + onSuccess: () => { + toast.success('Declined API authorization request') + navigate('/organizations') + }, + }) + + const onApproveRequest = form.handleSubmit((values) => { + if (approvalState !== 'indeterminate') { + return + } + setApprovalState('approving') + approveRequest( + { id: auth_id, slug: values.selectedOrgSlug }, + { onError: () => setApprovalState('indeterminate') } + ) + }) + + const onDeclineRequest = form.handleSubmit((values) => { + if (approvalState !== 'indeterminate') { + return + } + setApprovalState('declining') + declineRequest( + { id: auth_id, slug: values.selectedOrgSlug }, + { onError: () => setApprovalState('indeterminate') } + ) + }) + + if (isLoading) { + return + } + + if (isError) { + return + } + + if (isApproved) { + const approvedOrganization = + organizationsState._tag === 'success' + ? organizationsState.organizations.find( + (org) => org.slug === requester.approved_organization_slug + ) + : undefined + + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx new file mode 100644 index 00000000000..43ff848e09d --- /dev/null +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx @@ -0,0 +1,27 @@ +import { ApiAuthorizationInvalidScreen } from './ApiAuthorization.Invalid' +import { ApiAuthorizationValidScreen } from './ApiAuthorization.Valid' + +export interface ApiAuthorizationScreenProps { + auth_id: string | undefined + organization_slug: string | undefined + navigate: (destination: string) => void +} + +export function ApiAuthorizationScreen({ + auth_id, + organization_slug, + navigate, +}: ApiAuthorizationScreenProps) { + const valid = !!auth_id + if (!valid) { + return + } + + return ( + + ) +} diff --git a/apps/studio/components/layouts/APIAuthorizationLayout.tsx b/apps/studio/components/layouts/APIAuthorizationLayout.tsx index 6fb194222fd..59c16b0d295 100644 --- a/apps/studio/components/layouts/APIAuthorizationLayout.tsx +++ b/apps/studio/components/layouts/APIAuthorizationLayout.tsx @@ -1,23 +1,29 @@ import { useTheme } from 'next-themes' -import Head from 'next/head' import Image from 'next/legacy/image' import type { PropsWithChildren } from 'react' import { Separator } from 'ui' +import { Head, type HeadProvider } from '@/components/ui/Head' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { BASE_PATH } from '@/lib/constants' -export interface APIAuthorizationLayoutProps {} +export interface APIAuthorizationLayoutProps { + HeadProvider: HeadProvider +} -const APIAuthorizationLayout = ({ children }: PropsWithChildren) => { +export const APIAuthorizationLayout = ({ + HeadProvider, + children, +}: PropsWithChildren) => { const { resolvedTheme } = useTheme() const { appTitle } = useCustomContent(['app:title']) return ( <> - - Authorize API access | {appTitle || 'Supabase'} - +
@@ -46,5 +52,3 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren ) } - -export default APIAuthorizationLayout diff --git a/apps/studio/components/ui/Head.tsx b/apps/studio/components/ui/Head.tsx new file mode 100644 index 00000000000..402045b97b5 --- /dev/null +++ b/apps/studio/components/ui/Head.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +export type HeadProvider = ({ children }: { children: ReactNode }) => ReactNode + +export interface HeadProps { + HeadProvider: HeadProvider + title?: string +} + +export function Head({ HeadProvider, title }: HeadProps): ReactNode { + return {title && {title}} +} diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 20eae2ef0eb..4d9a6be479a 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -1,360 +1,32 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import dayjs from 'dayjs' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { useParams } from 'common' -import { AuthorizeRequesterDetails } from 'components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' -import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout' -import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-authorization-approve-mutation' -import { useApiAuthorizationDeclineMutation } from 'data/api-authorization/api-authorization-decline-mutation' -import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { withAuth } from 'hooks/misc/withAuth' -import type { NextPageWithLayout } from 'types' -import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - CheckIcon, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - FormItem_Shadcn_, - FormMessage_Shadcn_, - Select_Shadcn_, - SelectContent_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - WarningIcon, -} from 'ui' -import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' -import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import Head from 'next/head' +import { useRouter } from 'next/router' -// Need to handle if no organizations in account -// Need to handle if not logged in yet state +import { ApiAuthorizationScreen } from '@/components/interfaces/ApiAuthorization/ApiAuthorization' +import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout' +import { withAuth } from '@/hooks/misc/withAuth' +import type { NextPageWithLayout } from '@/types' const APIAuthorizationPage: NextPageWithLayout = () => { const router = useRouter() + const routerReady = router.isReady const { auth_id, organization_slug } = useParams() - const [isApproving, setIsApproving] = useState(false) - const [isDeclining, setIsDeclining] = useState(false) - const formSchema = z.object({ - selectedOrgSlug: z.string().min(1, 'Please select an organization'), - }) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { selectedOrgSlug: '' }, - mode: 'onSubmit', - reValidateMode: 'onBlur', - }) - - const selectedOrgSlug = form.watch('selectedOrgSlug') - - const { - data: organizations, - isSuccess: isSuccessOrganizations, - isPending: isLoadingOrganizations, - } = useOrganizationsQuery() - const { - data: requester, - isPending: isLoading, - isError, - isSuccess, - error, - } = useApiAuthorizationQuery({ id: auth_id }) - const isApproved = (requester?.approved_at ?? null) !== null - const isExpired = dayjs().isAfter(dayjs(requester?.expires_at)) - const isMcpClient = requester?.registration_type === 'dynamic' - - const searchParams = - typeof window !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams() - const basePath = process.env.NEXT_PUBLIC_BASE_PATH - const pathname = - typeof window !== 'undefined' - ? basePath - ? location.pathname.replace(basePath, '') - : location.pathname - : '' - searchParams.set('returnTo', pathname) - - const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ - onSuccess: (res) => { - window.location.href = res.url - }, - }) - const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ - onSuccess: () => { - toast.success('Declined API authorization request') - router.push('/organizations') - }, - }) - - const onApproveRequest = form.handleSubmit((values) => { - if (!auth_id) { - return toast.error('Unable to approve request: auth_id is missing ') - } - - setIsApproving(true) - approveRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setIsApproving(false) } - ) - }) - - const onDeclineRequest = form.handleSubmit((values) => { - if (!auth_id) { - return toast.error('Unable to decline request: auth_id is missing ') - } - - setIsDeclining(true) - declineRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setIsDeclining(false) } - ) - }) - - useEffect(() => { - if (isSuccessOrganizations && organizations.length > 0) { - if (organization_slug) { - const preselected = organizations.find(({ slug }) => slug === organization_slug)?.slug - if (preselected) form.setValue('selectedOrgSlug', preselected) - } else if (!form.getValues('selectedOrgSlug') && organizations.length === 1) { - // If user only has one organization and none pre-selected via URL, default to it - form.setValue('selectedOrgSlug', organizations[0].slug) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSuccessOrganizations]) - - if (isLoading) { - return ( - - Authorize API access - -
- - -
- -
- - -
- -
- - -
-
-
- ) - } - - if (isError) { - return ( - - Authorize API access - - - - - Failed to fetch details for API authorization request - - -

Please retry your authorization request from the requesting app

- {error !== undefined &&

Error: {error?.message}

} -
-
-
-
- ) - } - - if (isApproved) { - const approvedOrganization = organizations?.find( - (org) => org?.slug === requester.approved_organization_slug - ) - - return ( - - Authorize API access for {requester?.name} - - - - This authorization request has been approved - -

- {requester.name} has been approved access to the organization " - {approvedOrganization?.name ?? 'Unknown'}" and all of its projects for the following - scopes: -

- -

- Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')} -

-
-
-
-
- ) + if (!routerReady) { + return null } return ( - - Authorize API access for {requester?.name} - - {isMcpClient && ( - - - MCP Client Connection - - This is an MCP (Model Context Protocol) client designed to connect with AI - applications. Please ensure you trust this application before granting access to your - organization's data. - - - )} - - - - {isExpired && ( - - - This authorization request is expired - - Please retry your authorization request from the requesting app - - - )} - - {isLoadingOrganizations ? ( -
- - -
- ) : organizations?.length === 0 ? ( - - - - Organization is needed for installing an integration - - - Your account isn't associated with any organizations. To use this integration, it must - be installed within an organization. You'll be redirected to create an organization - first. - - - ) : organization_slug && !selectedOrgSlug ? ( - - - - Organization is needed for installing an integration - - - Your account is not a member of the pre-selected organization. To use this - integration, it must be installed within an organization your account is associated - with. - - - ) : ( - - ( - - - - - - - - - {(organizations ?? []).map((organization) => ( - - {organization.name} - - ))} - - - - - - - )} - /> - - )} -
- - - {isLoadingOrganizations ? ( - - ) : isSuccessOrganizations && organizations.length === 0 ? ( - - - - ) : ( - - )} - -
+ router.push(destination)} + /> ) } -APIAuthorizationPage.getLayout = (page) => {page} +APIAuthorizationPage.getLayout = (page) => ( + {page} +) export default withAuth(APIAuthorizationPage) diff --git a/apps/studio/pages/cli/login.tsx b/apps/studio/pages/cli/login.tsx index 8fb8b2cfe14..d8327893433 100644 --- a/apps/studio/pages/cli/login.tsx +++ b/apps/studio/pages/cli/login.tsx @@ -1,16 +1,18 @@ import { useIsLoggedIn, useParams } from 'common' -import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout' -import CopyButton from 'components/ui/CopyButton' -import { createCliLoginSession } from 'data/cli/login' -import { withAuth } from 'hooks/misc/withAuth' +import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect } from 'react' import { toast } from 'sonner' -import type { NextPageWithLayout } from 'types' import { InputOTP, InputOTPGroup, InputOTPSlot, LogoLoader } from 'ui' import { Admonition } from 'ui-patterns' +import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout' +import CopyButton from '@/components/ui/CopyButton' +import { createCliLoginSession } from '@/data/cli/login' +import { withAuth } from '@/hooks/misc/withAuth' +import type { NextPageWithLayout } from '@/types' + const CliLoginPage: NextPageWithLayout = () => { const router = useRouter() const { session_id, public_key, token_name, device_code } = useParams() @@ -45,7 +47,7 @@ const CliLoginPage: NextPageWithLayout = () => { }, [isLoggedIn, router, router.isReady, session_id, public_key, token_name, device_code]) return ( - +
{device_code ? ( <> diff --git a/apps/studio/pages/partners/stripe/projects/login.tsx b/apps/studio/pages/partners/stripe/projects/login.tsx index 32dd5a1a8ab..5d6ce68d1de 100644 --- a/apps/studio/pages/partners/stripe/projects/login.tsx +++ b/apps/studio/pages/partners/stripe/projects/login.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' +import Head from 'next/head' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { @@ -12,7 +13,7 @@ import { WarningIcon, } from 'ui' -import APIAuthorizationLayout from '@/components/layouts/APIAuthorizationLayout' +import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout' import { OrganizationSelector } from '@/components/ui/org-selector' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useConfirmAccountRequestMutation } from '@/data/partners/stripe-projects-confirm-mutation' @@ -94,7 +95,7 @@ const StripeProjectsLoginPage = () => { : 'Your Supabase organization has been linked to your Stripe account.' return ( - +
{isConfirming ? ( <> diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx new file mode 100644 index 00000000000..e84fa57169f --- /dev/null +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -0,0 +1,333 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' +import { HttpResponse } from 'msw' +import { describe, expect, test, vi } from 'vitest' + +import { + ApiAuthorizationScreen, + type ApiAuthorizationScreenProps, +} from '@/components/interfaces/ApiAuthorization/ApiAuthorization' +import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' +import type { ProfileContextType } from '@/lib/profile' +import { createMockOrganization } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import type { Organization } from '@/types' + +// --- Fixtures --- + +const DEFAULT_PROFILE_CONTEXT: ProfileContextType = { + profile: { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'testuser', + primary_email: 'test@example.com', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, + }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, +} + +function createMockAuthResponse( + overrides: Partial = {} +): ApiAuthorizationResponse { + return { + name: 'Test App', + website: 'https://testapp.com', + icon: null, + domain: 'testapp.com', + scopes: [], + expires_at: dayjs().add(1, 'hour').toISOString(), + approved_at: null, + registration_type: 'static', + ...overrides, + } +} + +const DEFAULT_ORG = createMockOrganization({ name: 'My Org', slug: 'my-org' }) +const SECOND_ORG = createMockOrganization({ id: 2, name: 'Second Org', slug: 'second-org' }) + +// --- MSW helpers --- + +// Both the auth query and the organizations query fire for any valid auth_id render. +// Since MSW is configured with onUnhandledRequest: 'error', both must always be mocked. + +function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) { + addAPIMock({ + method: 'get', + path: '/platform/oauth/authorizations/:id', + response: () => HttpResponse.json(authResponse), + }) +} + +function mockOrgsEndpoint(orgs: Array = [DEFAULT_ORG]) { + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => HttpResponse.json(orgs), + }) +} + +function mockBothEndpoints( + authResponse: ApiAuthorizationResponse = createMockAuthResponse(), + orgs: Array = [DEFAULT_ORG] +) { + mockAuthEndpoint(authResponse) + mockOrgsEndpoint(orgs) +} + +// --- Render helper --- + +function renderScreen(props: Partial = {}) { + const navigate = vi.fn() + const result = customRender( + , + { profileContext: DEFAULT_PROFILE_CONTEXT } + ) + return { ...result, navigate } +} + +// --- Tests --- + +describe('ApiAuthorizationScreen', () => { + describe('when auth_id is missing', () => { + test('renders invalid screen when auth_id is undefined', () => { + renderScreen({ auth_id: undefined }) + expect(screen.getByText('Missing parameters')).toBeInTheDocument() + expect(screen.getByText(/auth_id/)).toBeInTheDocument() + }) + }) + + describe('when auth_id is provided', () => { + test('renders loading screen while authorization data is being fetched', () => { + mockOrgsEndpoint() + addAPIMock({ + method: 'get', + path: '/platform/oauth/authorizations/:id', + response: () => new Promise(() => {}), + }) + const { container } = renderScreen() + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(container.querySelectorAll('.shimmering-loader').length).toBeGreaterThan(0) + }) + + test('renders error screen when authorization query fails', async () => { + mockOrgsEndpoint() + addAPIMock({ + method: 'get', + path: '/platform/oauth/authorizations/:id', + response: () => HttpResponse.json({ message: 'Not found' }, { status: 404 }), + }) + renderScreen() + await screen.findByText('Failed to fetch details for API authorization request') + }) + + describe('when already approved', () => { + test('renders approved screen with matching organization name', async () => { + mockBothEndpoints( + createMockAuthResponse({ + approved_at: '2025-01-15T10:00:00Z', + approved_organization_slug: 'my-org', + }) + ) + renderScreen() + await screen.findByText('This authorization request has been approved') + expect(screen.getByText(/organization "My Org"/)).toBeInTheDocument() + }) + + test('shows Unknown when approved organization is not in the user organizations list', async () => { + mockBothEndpoints( + createMockAuthResponse({ + approved_at: '2025-01-15T10:00:00Z', + approved_organization_slug: 'other-org', + }) + ) + renderScreen() + await screen.findByText('This authorization request has been approved') + expect(screen.getByText(/organization "Unknown"/)).toBeInTheDocument() + }) + }) + + describe('main authorization form', () => { + describe('organizations states', () => { + test('disables action buttons while organizations are being fetched', async () => { + mockAuthEndpoint(createMockAuthResponse()) + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => new Promise(() => {}), + }) + renderScreen() + await screen.findByText('Authorize API access for Test App') + expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled() + expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled() + }) + + test('shows error notice, disables decline button, and hides accept button when organizations query fails', async () => { + mockAuthEndpoint(createMockAuthResponse()) + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => HttpResponse.json({ message: 'Server error' }, { status: 500 }), + }) + renderScreen() + await screen.findByText('There was an error loading your organizations') + expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled() + expect( + screen.queryByRole('button', { name: /Authorize Test App/ }) + ).not.toBeInTheDocument() + }) + + test('shows empty state when user has no organizations', async () => { + mockBothEndpoints(createMockAuthResponse(), []) + renderScreen() + await screen.findByText(/Your account isn't associated with any organizations/) + expect(screen.getByRole('link', { name: 'Create an organization' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled() + expect( + screen.queryByRole('button', { name: /Authorize Test App/ }) + ).not.toBeInTheDocument() + }) + + test('shows not_member notice when organization_slug does not match any user organization', async () => { + mockBothEndpoints() + renderScreen({ organization_slug: 'nonexistent-org' }) + await screen.findByText(/Your account is not a member of the pre-selected organization/) + expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled() + expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled() + }) + }) + + describe('success state with organization selector', () => { + test('renders form with organization selector and action buttons', async () => { + mockBothEndpoints(createMockAuthResponse({ name: 'My OAuth App' })) + renderScreen() + await screen.findByText('Authorize API access for My OAuth App') + expect(screen.getByRole('combobox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument() + }) + + test('auto-selects the only organization when no organization_slug is provided', async () => { + mockBothEndpoints() + renderScreen() + const combobox = await screen.findByRole('combobox') + expect(combobox).toHaveTextContent('My Org') + }) + + test('pre-selects organization when organization_slug matches a user organization', async () => { + mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG]) + renderScreen({ organization_slug: 'second-org' }) + const combobox = await screen.findByRole('combobox') + expect(combobox).toHaveTextContent('Second Org') + expect(combobox).not.toHaveTextContent('My Org') + expect( + screen.getByText('This organization has been pre-selected by Test App.') + ).toBeInTheDocument() + }) + }) + + describe('MCP client warning', () => { + test('shows MCP warning when registration_type is dynamic', async () => { + mockBothEndpoints(createMockAuthResponse({ registration_type: 'dynamic' })) + renderScreen() + await screen.findByText('MCP Client Connection') + }) + + test('does not show MCP warning for non-dynamic registration type', async () => { + mockBothEndpoints() + renderScreen() + await screen.findByText('Authorize API access for Test App') + expect(screen.queryByText('MCP Client Connection')).not.toBeInTheDocument() + }) + }) + + describe('expiration', () => { + test('shows expiration warning and disables buttons when request has expired', async () => { + mockBothEndpoints( + createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() }) + ) + renderScreen() + await screen.findByText('This authorization request is expired') + expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled() + expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled() + }) + + test('does not show expiration warning when request has not expired', async () => { + mockBothEndpoints() + renderScreen() + await screen.findByText('Authorize API access for Test App') + expect( + screen.queryByText('This authorization request is expired') + ).not.toBeInTheDocument() + }) + }) + + describe('approve action', () => { + test('calls approve endpoint when Authorize button is clicked', async () => { + const user = userEvent.setup() + const approveHandler = vi.fn(() => + HttpResponse.json({ url: 'https://redirect.example.com' }) + ) + mockBothEndpoints() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: approveHandler, + }) + renderScreen() + await screen.findByRole('button', { name: /Authorize Test App/ }) + await user.click(screen.getByRole('button', { name: /Authorize Test App/ })) + await waitFor(() => expect(approveHandler).toHaveBeenCalled()) + }) + }) + + describe('decline action', () => { + test('navigates to /organizations after declining', async () => { + const user = userEvent.setup() + const declineHandler = vi.fn(() => HttpResponse.json({ id: 'test-auth-id' })) + mockBothEndpoints() + addAPIMock({ + method: 'delete', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: declineHandler, + }) + const { navigate } = renderScreen() + await screen.findByRole('button', { name: 'Decline' }) + await user.click(screen.getByRole('button', { name: 'Decline' })) + await waitFor(() => expect(declineHandler).toHaveBeenCalled()) + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')) + }) + }) + + describe('form validation', () => { + test('shows validation error when Authorize is clicked without selecting an organization', async () => { + const user = userEvent.setup() + // Two orgs → no auto-selection, user must pick one manually + mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG]) + renderScreen() + await screen.findByRole('button', { name: /Authorize Test App/ }) + await user.click(screen.getByRole('button', { name: /Authorize Test App/ })) + expect( + (await screen.findAllByText('Please select an organization')).length + ).toBeGreaterThan(0) + }) + }) + }) + }) +})