mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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:
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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user