diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx
new file mode 100644
index 00000000000..fb224229007
--- /dev/null
+++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx
@@ -0,0 +1,53 @@
+import dayjs from 'dayjs'
+import type { ReactNode } from 'react'
+import {
+ Alert_Shadcn_,
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Card,
+ CardContent,
+ CardHeader,
+ CheckIcon,
+} from 'ui'
+
+import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
+import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
+import type { Organization } from '@/types'
+
+export interface ApiAuthorizationApprovedScreenProps {
+ requester: ApiAuthorizationResponse
+ organization: Organization | undefined
+}
+
+export function ApiAuthorizationApprovedScreen({
+ requester,
+ organization,
+}: ApiAuthorizationApprovedScreenProps): ReactNode {
+ return (
+
+ Authorize API access for {requester.name}
+
+
+
+ This authorization request has been approved
+
+
+ {requester.name} has been approved access to the organization "
+ {organization?.name ?? 'Unknown'}" and all of its projects for the following scopes:
+
+
+
+ Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx
new file mode 100644
index 00000000000..4fbef36eefd
--- /dev/null
+++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from 'react'
+import {
+ Alert_Shadcn_,
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Card,
+ CardContent,
+ CardHeader,
+ WarningIcon,
+} from 'ui'
+
+import type { ResourceError } from '@/data/api-authorization/api-authorization-query'
+
+export interface ApiAuthorizationErrorScreenProps {
+ error: ResourceError | undefined
+}
+
+export function ApiAuthorizationErrorScreen({
+ error,
+}: ApiAuthorizationErrorScreenProps): ReactNode {
+ return (
+
+ Authorize API access
+
+
+
+
+ Failed to fetch details for API authorization request
+
+
+
Please retry your authorization request from the requesting app
@@ -46,5 +52,3 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren
)
}
-
-export default APIAuthorizationLayout
diff --git a/apps/studio/components/ui/Head.tsx b/apps/studio/components/ui/Head.tsx
new file mode 100644
index 00000000000..402045b97b5
--- /dev/null
+++ b/apps/studio/components/ui/Head.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from 'react'
+
+export type HeadProvider = ({ children }: { children: ReactNode }) => ReactNode
+
+export interface HeadProps {
+ HeadProvider: HeadProvider
+ title?: string
+}
+
+export function Head({ HeadProvider, title }: HeadProps): ReactNode {
+ return {title && {title}}
+}
diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx
index 20eae2ef0eb..4d9a6be479a 100644
--- a/apps/studio/pages/authorize.tsx
+++ b/apps/studio/pages/authorize.tsx
@@ -1,360 +1,32 @@
-import { zodResolver } from '@hookform/resolvers/zod'
-import dayjs from 'dayjs'
-import Link from 'next/link'
-import { useRouter } from 'next/router'
-import { useEffect, useState } from 'react'
-import { useForm } from 'react-hook-form'
-import { toast } from 'sonner'
-import * as z from 'zod'
-
import { useParams } from 'common'
-import { AuthorizeRequesterDetails } from 'components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
-import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout'
-import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-authorization-approve-mutation'
-import { useApiAuthorizationDeclineMutation } from 'data/api-authorization/api-authorization-decline-mutation'
-import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query'
-import { useOrganizationsQuery } from 'data/organizations/organizations-query'
-import { withAuth } from 'hooks/misc/withAuth'
-import type { NextPageWithLayout } from 'types'
-import {
- Alert_Shadcn_,
- AlertDescription_Shadcn_,
- AlertTitle_Shadcn_,
- Button,
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CheckIcon,
- Form_Shadcn_,
- FormControl_Shadcn_,
- FormField_Shadcn_,
- FormItem_Shadcn_,
- FormMessage_Shadcn_,
- Select_Shadcn_,
- SelectContent_Shadcn_,
- SelectItem_Shadcn_,
- SelectTrigger_Shadcn_,
- SelectValue_Shadcn_,
- WarningIcon,
-} from 'ui'
-import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
-import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+import Head from 'next/head'
+import { useRouter } from 'next/router'
-// Need to handle if no organizations in account
-// Need to handle if not logged in yet state
+import { ApiAuthorizationScreen } from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
+import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout'
+import { withAuth } from '@/hooks/misc/withAuth'
+import type { NextPageWithLayout } from '@/types'
const APIAuthorizationPage: NextPageWithLayout = () => {
const router = useRouter()
+ const routerReady = router.isReady
const { auth_id, organization_slug } = useParams()
- const [isApproving, setIsApproving] = useState(false)
- const [isDeclining, setIsDeclining] = useState(false)
- const formSchema = z.object({
- selectedOrgSlug: z.string().min(1, 'Please select an organization'),
- })
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: { selectedOrgSlug: '' },
- mode: 'onSubmit',
- reValidateMode: 'onBlur',
- })
-
- const selectedOrgSlug = form.watch('selectedOrgSlug')
-
- const {
- data: organizations,
- isSuccess: isSuccessOrganizations,
- isPending: isLoadingOrganizations,
- } = useOrganizationsQuery()
- const {
- data: requester,
- isPending: isLoading,
- isError,
- isSuccess,
- error,
- } = useApiAuthorizationQuery({ id: auth_id })
- const isApproved = (requester?.approved_at ?? null) !== null
- const isExpired = dayjs().isAfter(dayjs(requester?.expires_at))
- const isMcpClient = requester?.registration_type === 'dynamic'
-
- const searchParams =
- typeof window !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams()
- const basePath = process.env.NEXT_PUBLIC_BASE_PATH
- const pathname =
- typeof window !== 'undefined'
- ? basePath
- ? location.pathname.replace(basePath, '')
- : location.pathname
- : ''
- searchParams.set('returnTo', pathname)
-
- const { mutate: approveRequest } = useApiAuthorizationApproveMutation({
- onSuccess: (res) => {
- window.location.href = res.url
- },
- })
- const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({
- onSuccess: () => {
- toast.success('Declined API authorization request')
- router.push('/organizations')
- },
- })
-
- const onApproveRequest = form.handleSubmit((values) => {
- if (!auth_id) {
- return toast.error('Unable to approve request: auth_id is missing ')
- }
-
- setIsApproving(true)
- approveRequest(
- { id: auth_id, slug: values.selectedOrgSlug },
- { onError: () => setIsApproving(false) }
- )
- })
-
- const onDeclineRequest = form.handleSubmit((values) => {
- if (!auth_id) {
- return toast.error('Unable to decline request: auth_id is missing ')
- }
-
- setIsDeclining(true)
- declineRequest(
- { id: auth_id, slug: values.selectedOrgSlug },
- { onError: () => setIsDeclining(false) }
- )
- })
-
- useEffect(() => {
- if (isSuccessOrganizations && organizations.length > 0) {
- if (organization_slug) {
- const preselected = organizations.find(({ slug }) => slug === organization_slug)?.slug
- if (preselected) form.setValue('selectedOrgSlug', preselected)
- } else if (!form.getValues('selectedOrgSlug') && organizations.length === 1) {
- // If user only has one organization and none pre-selected via URL, default to it
- form.setValue('selectedOrgSlug', organizations[0].slug)
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSuccessOrganizations])
-
- if (isLoading) {
- return (
-
- Authorize API access
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- if (isError) {
- return (
-
- Authorize API access
-
-
-
-
- Failed to fetch details for API authorization request
-
-
-
Please retry your authorization request from the requesting app
- {error !== undefined &&
Error: {error?.message}
}
-
-
-
-
- )
- }
-
- if (isApproved) {
- const approvedOrganization = organizations?.find(
- (org) => org?.slug === requester.approved_organization_slug
- )
-
- return (
-
- Authorize API access for {requester?.name}
-
-
-
- This authorization request has been approved
-
-
- {requester.name} has been approved access to the organization "
- {approvedOrganization?.name ?? 'Unknown'}" and all of its projects for the following
- scopes:
-
-
-
- Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
-
-
-
-
-
- )
+ if (!routerReady) {
+ return null
}
return (
-
- Authorize API access for {requester?.name}
-
- {isMcpClient && (
-
-
- MCP Client Connection
-
- This is an MCP (Model Context Protocol) client designed to connect with AI
- applications. Please ensure you trust this application before granting access to your
- organization's data.
-
-
- )}
-
-
-
- {isExpired && (
-
-
- This authorization request is expired
-
- Please retry your authorization request from the requesting app
-
-
- )}
-
- {isLoadingOrganizations ? (
-
-
-
-
- ) : organizations?.length === 0 ? (
-
-
-
- Organization is needed for installing an integration
-
-
- Your account isn't associated with any organizations. To use this integration, it must
- be installed within an organization. You'll be redirected to create an organization
- first.
-
-
- ) : organization_slug && !selectedOrgSlug ? (
-
-
-
- Organization is needed for installing an integration
-
-
- Your account is not a member of the pre-selected organization. To use this
- integration, it must be installed within an organization your account is associated
- with.
-
-
- ) : (
-
- (
-
-
-
-
-
-
-
-
- {(organizations ?? []).map((organization) => (
-
- {organization.name}
-
- ))}
-
-
-
-
-
-
- )}
- />
-
- )}
-
-
-
- {isLoadingOrganizations ? (
-
- ) : isSuccessOrganizations && organizations.length === 0 ? (
-
-
-
- ) : (
-
- )}
-
-
+ router.push(destination)}
+ />
)
}
-APIAuthorizationPage.getLayout = (page) => {page}
+APIAuthorizationPage.getLayout = (page) => (
+ {page}
+)
export default withAuth(APIAuthorizationPage)
diff --git a/apps/studio/pages/cli/login.tsx b/apps/studio/pages/cli/login.tsx
index 8fb8b2cfe14..d8327893433 100644
--- a/apps/studio/pages/cli/login.tsx
+++ b/apps/studio/pages/cli/login.tsx
@@ -1,16 +1,18 @@
import { useIsLoggedIn, useParams } from 'common'
-import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout'
-import CopyButton from 'components/ui/CopyButton'
-import { createCliLoginSession } from 'data/cli/login'
-import { withAuth } from 'hooks/misc/withAuth'
+import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { toast } from 'sonner'
-import type { NextPageWithLayout } from 'types'
import { InputOTP, InputOTPGroup, InputOTPSlot, LogoLoader } from 'ui'
import { Admonition } from 'ui-patterns'
+import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout'
+import CopyButton from '@/components/ui/CopyButton'
+import { createCliLoginSession } from '@/data/cli/login'
+import { withAuth } from '@/hooks/misc/withAuth'
+import type { NextPageWithLayout } from '@/types'
+
const CliLoginPage: NextPageWithLayout = () => {
const router = useRouter()
const { session_id, public_key, token_name, device_code } = useParams()
@@ -45,7 +47,7 @@ const CliLoginPage: NextPageWithLayout = () => {
}, [isLoggedIn, router, router.isReady, session_id, public_key, token_name, device_code])
return (
-
+
{device_code ? (
<>
diff --git a/apps/studio/pages/partners/stripe/projects/login.tsx b/apps/studio/pages/partners/stripe/projects/login.tsx
index 32dd5a1a8ab..5d6ce68d1de 100644
--- a/apps/studio/pages/partners/stripe/projects/login.tsx
+++ b/apps/studio/pages/partners/stripe/projects/login.tsx
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
+import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import {
@@ -12,7 +13,7 @@ import {
WarningIcon,
} from 'ui'
-import APIAuthorizationLayout from '@/components/layouts/APIAuthorizationLayout'
+import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout'
import { OrganizationSelector } from '@/components/ui/org-selector'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useConfirmAccountRequestMutation } from '@/data/partners/stripe-projects-confirm-mutation'
@@ -94,7 +95,7 @@ const StripeProjectsLoginPage = () => {
: 'Your Supabase organization has been linked to your Stripe account.'
return (
-
+
{isConfirming ? (
<>
diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx
new file mode 100644
index 00000000000..e84fa57169f
--- /dev/null
+++ b/apps/studio/tests/components/ApiAuthorization.test.tsx
@@ -0,0 +1,333 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import dayjs from 'dayjs'
+import { HttpResponse } from 'msw'
+import { describe, expect, test, vi } from 'vitest'
+
+import {
+ ApiAuthorizationScreen,
+ type ApiAuthorizationScreenProps,
+} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
+import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
+import type { ProfileContextType } from '@/lib/profile'
+import { createMockOrganization } from '@/tests/helpers'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+import type { Organization } from '@/types'
+
+// --- Fixtures ---
+
+const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
+ profile: {
+ id: 1,
+ auth0_id: 'auth0|test',
+ gotrue_id: 'gotrue-test',
+ username: 'testuser',
+ primary_email: 'test@example.com',
+ first_name: null,
+ last_name: null,
+ mobile: null,
+ is_alpha_user: false,
+ is_sso_user: false,
+ disabled_features: [],
+ free_project_limit: null,
+ },
+ error: null,
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+}
+
+function createMockAuthResponse(
+ overrides: Partial = {}
+): ApiAuthorizationResponse {
+ return {
+ name: 'Test App',
+ website: 'https://testapp.com',
+ icon: null,
+ domain: 'testapp.com',
+ scopes: [],
+ expires_at: dayjs().add(1, 'hour').toISOString(),
+ approved_at: null,
+ registration_type: 'static',
+ ...overrides,
+ }
+}
+
+const DEFAULT_ORG = createMockOrganization({ name: 'My Org', slug: 'my-org' })
+const SECOND_ORG = createMockOrganization({ id: 2, name: 'Second Org', slug: 'second-org' })
+
+// --- MSW helpers ---
+
+// Both the auth query and the organizations query fire for any valid auth_id render.
+// Since MSW is configured with onUnhandledRequest: 'error', both must always be mocked.
+
+function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/oauth/authorizations/:id',
+ response: () => HttpResponse.json(authResponse),
+ })
+}
+
+function mockOrgsEndpoint(orgs: Array = [DEFAULT_ORG]) {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => HttpResponse.json(orgs),
+ })
+}
+
+function mockBothEndpoints(
+ authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
+ orgs: Array = [DEFAULT_ORG]
+) {
+ mockAuthEndpoint(authResponse)
+ mockOrgsEndpoint(orgs)
+}
+
+// --- Render helper ---
+
+function renderScreen(props: Partial = {}) {
+ const navigate = vi.fn()
+ const result = customRender(
+ ,
+ { profileContext: DEFAULT_PROFILE_CONTEXT }
+ )
+ return { ...result, navigate }
+}
+
+// --- Tests ---
+
+describe('ApiAuthorizationScreen', () => {
+ describe('when auth_id is missing', () => {
+ test('renders invalid screen when auth_id is undefined', () => {
+ renderScreen({ auth_id: undefined })
+ expect(screen.getByText('Missing parameters')).toBeInTheDocument()
+ expect(screen.getByText(/auth_id/)).toBeInTheDocument()
+ })
+ })
+
+ describe('when auth_id is provided', () => {
+ test('renders loading screen while authorization data is being fetched', () => {
+ mockOrgsEndpoint()
+ addAPIMock({
+ method: 'get',
+ path: '/platform/oauth/authorizations/:id',
+ response: () => new Promise(() => {}),
+ })
+ const { container } = renderScreen()
+ expect(screen.getByText('Loading...')).toBeInTheDocument()
+ expect(container.querySelectorAll('.shimmering-loader').length).toBeGreaterThan(0)
+ })
+
+ test('renders error screen when authorization query fails', async () => {
+ mockOrgsEndpoint()
+ addAPIMock({
+ method: 'get',
+ path: '/platform/oauth/authorizations/:id',
+ response: () => HttpResponse.json({ message: 'Not found' }, { status: 404 }),
+ })
+ renderScreen()
+ await screen.findByText('Failed to fetch details for API authorization request')
+ })
+
+ describe('when already approved', () => {
+ test('renders approved screen with matching organization name', async () => {
+ mockBothEndpoints(
+ createMockAuthResponse({
+ approved_at: '2025-01-15T10:00:00Z',
+ approved_organization_slug: 'my-org',
+ })
+ )
+ renderScreen()
+ await screen.findByText('This authorization request has been approved')
+ expect(screen.getByText(/organization "My Org"/)).toBeInTheDocument()
+ })
+
+ test('shows Unknown when approved organization is not in the user organizations list', async () => {
+ mockBothEndpoints(
+ createMockAuthResponse({
+ approved_at: '2025-01-15T10:00:00Z',
+ approved_organization_slug: 'other-org',
+ })
+ )
+ renderScreen()
+ await screen.findByText('This authorization request has been approved')
+ expect(screen.getByText(/organization "Unknown"/)).toBeInTheDocument()
+ })
+ })
+
+ describe('main authorization form', () => {
+ describe('organizations states', () => {
+ test('disables action buttons while organizations are being fetched', async () => {
+ mockAuthEndpoint(createMockAuthResponse())
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => new Promise(() => {}),
+ })
+ renderScreen()
+ await screen.findByText('Authorize API access for Test App')
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
+ })
+
+ test('shows error notice, disables decline button, and hides accept button when organizations query fails', async () => {
+ mockAuthEndpoint(createMockAuthResponse())
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => HttpResponse.json({ message: 'Server error' }, { status: 500 }),
+ })
+ renderScreen()
+ await screen.findByText('There was an error loading your organizations')
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
+ expect(
+ screen.queryByRole('button', { name: /Authorize Test App/ })
+ ).not.toBeInTheDocument()
+ })
+
+ test('shows empty state when user has no organizations', async () => {
+ mockBothEndpoints(createMockAuthResponse(), [])
+ renderScreen()
+ await screen.findByText(/Your account isn't associated with any organizations/)
+ expect(screen.getByRole('link', { name: 'Create an organization' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
+ expect(
+ screen.queryByRole('button', { name: /Authorize Test App/ })
+ ).not.toBeInTheDocument()
+ })
+
+ test('shows not_member notice when organization_slug does not match any user organization', async () => {
+ mockBothEndpoints()
+ renderScreen({ organization_slug: 'nonexistent-org' })
+ await screen.findByText(/Your account is not a member of the pre-selected organization/)
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
+ })
+ })
+
+ describe('success state with organization selector', () => {
+ test('renders form with organization selector and action buttons', async () => {
+ mockBothEndpoints(createMockAuthResponse({ name: 'My OAuth App' }))
+ renderScreen()
+ await screen.findByText('Authorize API access for My OAuth App')
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument()
+ })
+
+ test('auto-selects the only organization when no organization_slug is provided', async () => {
+ mockBothEndpoints()
+ renderScreen()
+ const combobox = await screen.findByRole('combobox')
+ expect(combobox).toHaveTextContent('My Org')
+ })
+
+ test('pre-selects organization when organization_slug matches a user organization', async () => {
+ mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
+ renderScreen({ organization_slug: 'second-org' })
+ const combobox = await screen.findByRole('combobox')
+ expect(combobox).toHaveTextContent('Second Org')
+ expect(combobox).not.toHaveTextContent('My Org')
+ expect(
+ screen.getByText('This organization has been pre-selected by Test App.')
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe('MCP client warning', () => {
+ test('shows MCP warning when registration_type is dynamic', async () => {
+ mockBothEndpoints(createMockAuthResponse({ registration_type: 'dynamic' }))
+ renderScreen()
+ await screen.findByText('MCP Client Connection')
+ })
+
+ test('does not show MCP warning for non-dynamic registration type', async () => {
+ mockBothEndpoints()
+ renderScreen()
+ await screen.findByText('Authorize API access for Test App')
+ expect(screen.queryByText('MCP Client Connection')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('expiration', () => {
+ test('shows expiration warning and disables buttons when request has expired', async () => {
+ mockBothEndpoints(
+ createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() })
+ )
+ renderScreen()
+ await screen.findByText('This authorization request is expired')
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
+ })
+
+ test('does not show expiration warning when request has not expired', async () => {
+ mockBothEndpoints()
+ renderScreen()
+ await screen.findByText('Authorize API access for Test App')
+ expect(
+ screen.queryByText('This authorization request is expired')
+ ).not.toBeInTheDocument()
+ })
+ })
+
+ describe('approve action', () => {
+ test('calls approve endpoint when Authorize button is clicked', async () => {
+ const user = userEvent.setup()
+ const approveHandler = vi.fn(() =>
+ HttpResponse.json({ url: 'https://redirect.example.com' })
+ )
+ mockBothEndpoints()
+ addAPIMock({
+ method: 'post',
+ path: '/platform/organizations/:slug/oauth/authorizations/:id',
+ response: approveHandler,
+ })
+ renderScreen()
+ await screen.findByRole('button', { name: /Authorize Test App/ })
+ await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
+ await waitFor(() => expect(approveHandler).toHaveBeenCalled())
+ })
+ })
+
+ describe('decline action', () => {
+ test('navigates to /organizations after declining', async () => {
+ const user = userEvent.setup()
+ const declineHandler = vi.fn(() => HttpResponse.json({ id: 'test-auth-id' }))
+ mockBothEndpoints()
+ addAPIMock({
+ method: 'delete',
+ path: '/platform/organizations/:slug/oauth/authorizations/:id',
+ response: declineHandler,
+ })
+ const { navigate } = renderScreen()
+ await screen.findByRole('button', { name: 'Decline' })
+ await user.click(screen.getByRole('button', { name: 'Decline' }))
+ await waitFor(() => expect(declineHandler).toHaveBeenCalled())
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'))
+ })
+ })
+
+ describe('form validation', () => {
+ test('shows validation error when Authorize is clicked without selecting an organization', async () => {
+ const user = userEvent.setup()
+ // Two orgs → no auto-selection, user must pick one manually
+ mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
+ renderScreen()
+ await screen.findByRole('button', { name: /Authorize Test App/ })
+ await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
+ expect(
+ (await screen.findAllByText('Please select an organization')).length
+ ).toBeGreaterThan(0)
+ })
+ })
+ })
+ })
+})