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.
This commit is contained in:
Charis authored and GitHub committed 2026-04-06 10:02:56 -04:00
1 parent 205dac33da
commit 6563dc7998
14 files changed
+1149 -361

No files matched your search

@@ -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 (
<Card>
<CardHeader>Authorize API access for {requester.name}</CardHeader>
<CardContent className="p-0">
<Alert_Shadcn_ className="border-0 rounded-t-none">
<CheckIcon />
<AlertTitle_Shadcn_>This authorization request has been approved</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
{requester.name} has been approved access to the organization "
{organization?.name ?? 'Unknown'}" and all of its projects for the following scopes:
</p>
<AuthorizeRequesterDetails
showOnlyScopes
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
<p className="mt-2">
Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</CardContent>
</Card>
)
}
@@ -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 (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent className="p-0">
<Alert_Shadcn_ variant="warning" className="border-0 rounded-t-none">
<WarningIcon />
<AlertTitle_Shadcn_>
Failed to fetch details for API authorization request
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>Please retry your authorization request from the requesting app</p>
{error && <p className="mt-2">Error: {error?.message}</p>}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</CardContent>
</Card>
)
}
@@ -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<Organization>
}
type OrganizationsState =
| OrganizationsState_Loading
| OrganizationsState_Error
| OrganizationsState_Empty
| OrganizationsState_NotMember
| OrganizationsState_Success
export interface ApiAuthorizationMainViewProps {
approvalState: ApprovalState
form: UseFormReturn<IApprovalFormSchema>
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 (
<FormShell title={`Authorize API access for ${requester.name}`}>
{isMcpClient && <McpNotice />}
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
{isExpired && <ExpiredNotice />}
{organizations._tag === 'loading' && <OrganizationsLoader />}
{organizations._tag === 'error' && <OrganizationsErrorNotice error={organizations.error} />}
{organizations._tag === 'empty' && <OrganizationsEmptyState />}
{organizations._tag === 'not_member' && <NotMemberOfOrganizationNotice />}
{organizations._tag === 'success' && (
<OrganizationSelector
form={form}
disabled={isExpired || !!requestedOrganizationSlug}
requester={requester}
organizations={organizations.organizations}
requestedOrganizationSlug={requestedOrganizationSlug}
/>
)}
<FormFooter
disabled={isExpired || organizations._tag !== 'success'}
approvalState={approvalState}
requester={requester}
organizations={organizations}
onApprove={onApprove}
onDecline={onDecline}
/>
</FormShell>
)
}
interface FormShellProps {
title: string
children: ReactNode
}
function FormShell({ title, children }: FormShellProps): ReactNode {
return (
<Card>
<CardHeader>{title}</CardHeader>
<CardContent className="space-y-8">{children}</CardContent>
</Card>
)
}
function McpNotice(): ReactNode {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>MCP Client Connection</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
function ExpiredNotice(): ReactNode {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>This authorization request is expired</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Please retry your authorization request from the requesting app
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
function OrganizationsLoader(): ReactNode {
return (
<div className="py-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
)
}
interface OrganizationsErrorNoticeProps {
error: ResponseError | null
}
function OrganizationsErrorNotice({ error }: OrganizationsErrorNoticeProps): ReactNode {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>There was an error loading your organizations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Please try again. If the problem persists, contact support.
{error && <p className="mt-2">Error: {error.message}</p>}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
function OrganizationsEmptyState(): ReactNode {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Organization is needed for installing an integration</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
function NotMemberOfOrganizationNotice(): ReactNode {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Organization is needed for installing an integration</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
interface OrganizationSelectorProps {
form: UseFormReturn<IApprovalFormSchema>
requester: ApiAuthorizationResponse
requestedOrganizationSlug: string | undefined
organizations: Array<Organization>
disabled?: boolean
}
function OrganizationSelector({
form,
requester,
requestedOrganizationSlug,
organizations,
disabled = false,
}: OrganizationSelectorProps): ReactNode {
return (
<Form_Shadcn_ {...form}>
<FormField_Shadcn_
control={form.control}
name="selectedOrgSlug"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLayout
label="Organization to grant API access to"
description={
requestedOrganizationSlug
? `This organization has been pre-selected by ${requester.name}.`
: undefined
}
isReactForm
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value || undefined}
disabled={disabled}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ size="small">
<SelectValue_Shadcn_ placeholder="Select an organization" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{organizations.map((organization) => (
<SelectItem_Shadcn_
key={organization.slug}
value={organization.slug}
className="text-xs"
>
{organization.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormLayout>
</FormItem_Shadcn_>
)}
/>
</Form_Shadcn_>
)
}
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 (
<CardFooter className="justify-end space-x-2">
<Button
type="default"
loading={approvalState === 'declining'}
disabled={disabled || approvalState !== 'indeterminate'}
onClick={onDecline}
>
Decline
</Button>
{organizations._tag === 'loading' && (
<LoadingApprovalButton>Authorize {requester.name}</LoadingApprovalButton>
)}
{organizations._tag === 'empty' && <CreateOrganizationLink />}
{showApprovalButton && (
<ApprovalButton
disabled={disabled || approvalState !== 'indeterminate'}
approvalState={approvalState}
requester={requester}
onApprove={onApprove}
/>
)}
</CardFooter>
)
}
interface LoadingApprovalButtonProps {
children: ReactNode
}
function LoadingApprovalButton({ children }: LoadingApprovalButtonProps): ReactNode {
return <Button loading={true}>{children}</Button>
}
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 (
<Button asChild>
<Link href={`/new?${searchParamString}`}>Create an organization</Link>
</Button>
)
}
interface ApprovalButtonProps {
disabled?: boolean
approvalState: ApprovalState
requester: ApiAuthorizationResponse
onApprove: () => void
}
function ApprovalButton({
disabled,
approvalState,
requester,
onApprove,
}: ApprovalButtonProps): ReactNode {
return (
<Button loading={approvalState === 'approving'} disabled={disabled} onClick={onApprove}>
Authorize {requester.name}
</Button>
)
}
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { Card, CardContent, CardHeader } from 'ui'
export interface ApiAuthorizationInvalidScreenProps {
missingParameters: Array<string>
}
export function ApiAuthorizationInvalidScreen({
missingParameters,
}: ApiAuthorizationInvalidScreenProps): ReactNode {
const isPlural = missingParameters.length > 1
const paragraphFontClass = 'text-sm text-muted-foreground'
return (
<Card>
<CardHeader>Missing parameters</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className={paragraphFontClass}>
Cannot authorize this request because the URL is missing the following parameter
{isPlural ? 's' : ''}: {missingParameters.join(', ')}.
</p>
<p className={paragraphFontClass}>
If you followed a link here, please check the link and try again.
</p>
</CardContent>
</Card>
)
}
@@ -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 (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent>
<span className="sr-only">Loading...</span>
<div className="flex gap-x-4 items-center">
<ShimmeringLoader className="w-12 h-12 md:w-14 md:h-14" />
<ShimmeringLoader className="h-6 w-64" />
</div>
<div className="flex flex-col gap-y-2 mt-4">
<ShimmeringLoader className="w-1/4" />
<ShimmeringLoader />
</div>
<div className="flex flex-col gap-y-2 mt-8">
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
</div>
</CardContent>
</Card>
)
}
@@ -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<typeof approvalFormSchema>
export type ApprovalState = 'indeterminate' | 'approving' | 'declining' | 'approved' | 'declined'
@@ -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<Organization> | undefined
): Organization | null {
if (!organization_slug || !organizations) return null
return organizations.find(({ slug }) => slug === organization_slug) ?? null
}
interface PreselectOrganizationSlugParameters {
form: UseFormReturn<IApprovalFormSchema>
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<IApprovalFormSchema>,
organizationsState: ReturnType<typeof useOrganizationsState>,
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<ApprovalState>('indeterminate')
const form = useForm<IApprovalFormSchema>({
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 <ApiAuthorizationLoadingScreen />
}
if (isError) {
return <ApiAuthorizationErrorScreen error={error} />
}
if (isApproved) {
const approvedOrganization =
organizationsState._tag === 'success'
? organizationsState.organizations.find(
(org) => org.slug === requester.approved_organization_slug
)
: undefined
return (
<ApiAuthorizationApprovedScreen requester={requester} organization={approvedOrganization} />
)
}
return (
<ApiAuthorizationMainView
approvalState={approvalState}
form={form}
requester={requester}
requestedOrganizationSlug={organization_slug}
organizations={organizationsState}
onApprove={onApproveRequest}
onDecline={onDeclineRequest}
/>
)
}
@@ -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 <ApiAuthorizationInvalidScreen missingParameters={['auth_id']} />
}
return (
<ApiAuthorizationValidScreen
auth_id={auth_id}
organization_slug={organization_slug}
navigate={navigate}
/>
)
}
@@ -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<APIAuthorizationLayoutProps>) => {
export const APIAuthorizationLayout = ({
HeadProvider,
children,
}: PropsWithChildren<APIAuthorizationLayoutProps>) => {
const { resolvedTheme } = useTheme()
const { appTitle } = useCustomContent(['app:title'])
return (
<>
<Head>
<title>Authorize API access | {appTitle || 'Supabase'}</title>
</Head>
<Head
HeadProvider={HeadProvider}
title={`Authorize API access | ${appTitle || 'Supabase'}`}
/>
<main className="h-screen flex flex-col w-full h-full overflow-y-auto">
<div>
<div className="mx-auto px-4 sm:px-6">
@@ -46,5 +52,3 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren<APIAuthorization
</>
)
}
export default APIAuthorizationLayout
+12
View File
@@ -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 <HeadProvider>{title && <title>{title}</title>}</HeadProvider>
}
+17 -345
View File
@@ -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<z.infer<typeof formSchema>>({
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 (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent>
<div className="flex gap-x-4 items-center">
<ShimmeringLoader className="w-12 h-12 md:w-14 md:h-14" />
<ShimmeringLoader className="h-6 w-64" />
</div>
<div className="flex flex-col gap-y-2 mt-4">
<ShimmeringLoader className="w-1/4" />
<ShimmeringLoader />
</div>
<div className="flex flex-col gap-y-2 mt-8">
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
</div>
</CardContent>
</Card>
)
}
if (isError) {
return (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent className="p-0">
<Alert_Shadcn_ variant="warning" className="border-0 rounded-t-none">
<WarningIcon />
<AlertTitle_Shadcn_>
Failed to fetch details for API authorization request
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>Please retry your authorization request from the requesting app</p>
{error !== undefined && <p className="mt-2">Error: {error?.message}</p>}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</CardContent>
</Card>
)
}
if (isApproved) {
const approvedOrganization = organizations?.find(
(org) => org?.slug === requester.approved_organization_slug
)
return (
<Card>
<CardHeader>Authorize API access for {requester?.name}</CardHeader>
<CardContent className="p-0">
<Alert_Shadcn_ className="border-0 rounded-t-none">
<CheckIcon />
<AlertTitle_Shadcn_>This authorization request has been approved</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
{requester.name} has been approved access to the organization "
{approvedOrganization?.name ?? 'Unknown'}" and all of its projects for the following
scopes:
</p>
<AuthorizeRequesterDetails
showOnlyScopes
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
<p className="mt-2">
Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</CardContent>
</Card>
)
if (!routerReady) {
return null
}
return (
<Card>
<CardHeader>Authorize API access for {requester?.name}</CardHeader>
<CardContent className="space-y-8">
{isMcpClient && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>MCP Client Connection</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
{isExpired && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>This authorization request is expired</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Please retry your authorization request from the requesting app
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{isLoadingOrganizations ? (
<div className="py-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : organizations?.length === 0 ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Organization is needed for installing an integration
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : organization_slug && !selectedOrgSlug ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Organization is needed for installing an integration
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<Form_Shadcn_ {...form}>
<FormField_Shadcn_
control={form.control}
name="selectedOrgSlug"
render={({ field }) => (
<FormItem_Shadcn_>
<FormLayout
label="Organization to grant API access to"
description={
organization_slug
? `This organization has been pre-selected by ${requester?.name}.`
: undefined
}
isReactForm
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value || undefined}
disabled={isExpired || Boolean(organization_slug)}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ size="small">
<SelectValue_Shadcn_ placeholder="Select an organization" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{(organizations ?? []).map((organization) => (
<SelectItem_Shadcn_
key={organization?.slug}
value={organization?.slug}
className="text-xs"
>
{organization.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormLayout>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
</Form_Shadcn_>
)}
</CardContent>
<CardFooter className="justify-end space-x-2">
<Button
type="default"
loading={isDeclining}
disabled={isApproving || isExpired}
onClick={onDeclineRequest}
>
Decline
</Button>
{isLoadingOrganizations ? (
<Button loading={isLoadingOrganizations}>Authorize {requester?.name}</Button>
) : isSuccessOrganizations && organizations.length === 0 ? (
<Link href={`/new?${searchParams.toString()}`}>
<Button loading={isLoadingOrganizations}>Create an organization</Button>
</Link>
) : (
<Button
loading={isApproving}
disabled={isDeclining || isExpired || (Boolean(organization_slug) && !selectedOrgSlug)}
onClick={onApproveRequest}
>
Authorize {requester?.name}
</Button>
)}
</CardFooter>
</Card>
<ApiAuthorizationScreen
auth_id={auth_id}
organization_slug={organization_slug}
navigate={(destination) => router.push(destination)}
/>
)
}
APIAuthorizationPage.getLayout = (page) => <APIAuthorizationLayout>{page}</APIAuthorizationLayout>
APIAuthorizationPage.getLayout = (page) => (
<APIAuthorizationLayout HeadProvider={Head}>{page}</APIAuthorizationLayout>
)
export default withAuth(APIAuthorizationPage)
+8 -6
View File
@@ -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 (
<APIAuthorizationLayout>
<APIAuthorizationLayout HeadProvider={Head}>
<div className={`flex flex-col items-center justify-center h-full`}>
{device_code ? (
<>
@@ -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 (
<APIAuthorizationLayout>
<APIAuthorizationLayout HeadProvider={Head}>
<div className="flex flex-col items-center min-h-[500px]">
{isConfirming ? (
<>
@@ -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> = {}
): 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<Organization> = [DEFAULT_ORG]) {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json(orgs),
})
}
function mockBothEndpoints(
authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
orgs: Array<Organization> = [DEFAULT_ORG]
) {
mockAuthEndpoint(authResponse)
mockOrgsEndpoint(orgs)
}
// --- Render helper ---
function renderScreen(props: Partial<ApiAuthorizationScreenProps> = {}) {
const navigate = vi.fn()
const result = customRender(
<ApiAuthorizationScreen
auth_id="test-auth-id"
organization_slug={undefined}
navigate={navigate}
{...props}
/>,
{ 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)
})
})
})
})
})