From 2b26da360e27d3c95ef19bc088840dc8984e0541 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:43:23 +1000 Subject: [PATCH] show API and AWS authorization errors inline (#48471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix and design-system update. ## What is the current behavior? API authorisation and AWS Marketplace action failures use transient toasts. The inline action-error treatment introduced for organisation invitations is implemented locally. ## What is the new behavior? Action failures remain visible below their actions and clear on retry or organisation change. This PR adds a shared `InterstitialActionError` component, updates the connect-interstitial guidance and demo to use it, and retroactively applies it to `OrganizationInvite`. Mutation errors are read directly from their mutation hooks rather than copied into component state. | Before | After | | --- | --- | | Authorize API Access Supabase | Authorize API Access Supabase | | Link AWS Marketplace Supabase | Link AWS Marketplace Supabase | _Note since taking that AWS screenshot: the error message now replaces the prior footer text. I.e. “Learn more about billing through AWS.” is now gone when an error message is present._ ## To test ### AWS Marketplace For a visual check with local Studio running: 1. In `apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx`, immediately before `if (!buyerId)`, temporarily add: ```tsx return (
undefined} />

Learn more {' '} about billing through AWS.

) ``` 2. Open `http://localhost:8082/aws-marketplace-onboarding?buyer_id=test` while signed in. 3. Confirm the error appears below **Link organization** with a divider. Remove the temporary return before committing anything. ### API authorization For a visual check with local Studio running: 1. In `apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx`, immediately before `if (isLoading)`, temporarily add: ```tsx return ( undefined} onApprove={() => undefined} onDecline={() => undefined} /> ) ``` 2. Open `http://localhost:8082/authorize?auth_id=test` while signed in. 3. Confirm the error appears below the authorisation actions with a divider. Remove the temporary return before committing anything. ## Summary by CodeRabbit * **New Features** * Added consistent inline error messaging for authorization, organization invitations, and AWS Marketplace onboarding. * Error messages now appear within the relevant interstitial and replace supporting footer content until resolved. * Retry and action buttons remain available after failed operations. * **Bug Fixes** * AWS Marketplace linking failures no longer trigger toast notifications. * Billing guidance is hidden while an onboarding error is displayed. * **Tests** * Added coverage for authorization, cancellation, and AWS Marketplace failure states. --------- Co-authored-by: Joshen Lim --- .../ui-patterns/connect-interstitials.mdx | 14 +-- .../connect-interstitial-action-error.tsx | 7 +- .../example/connect-interstitial-shared.tsx | 12 +++ .../ApiAuthorization.Form.tsx | 19 +++- .../ApiAuthorization.Valid.tsx | 41 ++++++--- .../AwsMarketplaceOnboarding.tsx | 90 ++++++++++++------- .../OrganizationInvite/OrganizationInvite.tsx | 11 +-- .../components/layouts/InterstitialLayout.tsx | 12 +++ .../components/ApiAuthorization.test.tsx | 38 ++++++++ .../pages/aws-marketplace-onboarding.test.tsx | 27 ++++++ 10 files changed, 203 insertions(+), 68 deletions(-) diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index 99c0671ea69..4457f7ef69c 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -217,20 +217,14 @@ Match feedback to its scope: feedback for a failure the user needs to resolve on the current card. ```tsx -{ - actionError && ( -
-

- {actionError} -

-
- ) -} + ``` Clear stale action feedback when the user retries or changes a relevant selection. Error copy should say what failed and, when it is not obvious, what -the user can do next. +the user can do next. When passive supporting copy occupies the same footer +region, replace it with the action error until the error is cleared instead of +stacking both messages. Cancel -
-

- Failed to authorize Stripe Projects. Please try again. -

-
+ diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx index e9100175ae0..49c53778e80 100644 --- a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -120,6 +120,18 @@ export function InterstitialShell({ ) } +export function InterstitialActionError({ error }: { error?: React.ReactNode }) { + if (!error) return null + + return ( +
+

+ {error} +

+
+ ) +} + export function SignOutButton() { return - {redirectUrl && ( + + {!actionError && redirectUrl && (

Authorizing will redirect you to {redirectUrl} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx index 621e49ea9d7..6d1ab488c80 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx @@ -142,38 +142,57 @@ export function ApiAuthorizationValidScreen({ } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null - const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ + const { + mutate: approveRequest, + error: approveError, + reset: resetApproveError, + } = useApiAuthorizationApproveMutation({ onSuccess: (res) => { window.location.href = res.url }, + onError: () => { + setApprovalState('indeterminate') + }, }) - const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ + const { + mutate: declineRequest, + error: declineError, + reset: resetDeclineError, + } = useApiAuthorizationDeclineMutation({ onSuccess: () => { toast.success('Declined API authorization request') navigate('/organizations') }, + onError: () => { + setApprovalState('indeterminate') + }, }) + const actionError = approveError + ? `Failed to authorize request: ${approveError.message}` + : declineError + ? `Failed to cancel authorization request: ${declineError.message}` + : undefined + const resetActionError = () => { + resetApproveError() + resetDeclineError() + } const onApproveRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('approving') - approveRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + approveRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) const onDeclineRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('declining') - declineRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + declineRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) if (isLoading) { @@ -224,6 +243,8 @@ export function ApiAuthorizationValidScreen({ requester={effectiveRequester} requestedOrganizationSlug={effectiveOrganizationSlug} organizations={effectiveOrganizationsState} + actionError={actionError} + onOrganizationChange={resetActionError} onApprove={onApproveRequest} onDecline={onDeclineRequest} /> diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx index 1dd3511cd3d..42fababc170 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -1,6 +1,5 @@ import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useEffectEvent, useMemo, useState } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -20,7 +19,10 @@ import { type CloudMarketplaceOnboardingInfo, } from '@/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query' import { NewAwsMarketplaceOrgModal } from '@/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal' -import { InterstitialAccountRow } from '@/components/layouts/InterstitialLayout' +import { + InterstitialAccountRow, + InterstitialActionError, +} from '@/components/layouts/InterstitialLayout' import { InlineLink } from '@/components/ui/InlineLink' import { useOrganizationLinkAwsMarketplaceMutation } from '@/data/organizations/organization-link-aws-marketplace-mutation' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -37,12 +39,6 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const [linkedOrgSlug, setLinkedOrgSlug] = useState(null) const [showOrgCreationDialog, setShowOrgCreationDialog] = useState(false) - useEffect(() => { - setSelectedOrgSlug(null) - setLinkedOrgSlug(null) - setShowOrgCreationDialog(false) - }, [buyerId]) - const { data: organizations, error: organizationsError, @@ -72,15 +68,20 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } { enabled: !!shouldLoadOnboardingInfo } ) - const { mutate: linkOrganization, isPending: isLinkingOrganization } = - useOrganizationLinkAwsMarketplaceMutation({ - onSuccess: (_, variables) => { - setLinkedOrgSlug(variables.slug) - }, - onError: (error) => { - toast.error(error.message, { duration: 7_000 }) - }, - }) + const { + mutate: linkOrganization, + isPending: isLinkingOrganization, + error: linkOrganizationError, + reset: resetLinkOrganizationError, + } = useOrganizationLinkAwsMarketplaceMutation({ + onSuccess: (_, variables) => { + setLinkedOrgSlug(variables.slug) + }, + onError: () => undefined, + }) + const linkError = linkOrganizationError + ? `Failed to link organization: ${linkOrganizationError.message}` + : undefined const effectiveOrganizations = useMemo( () => organizations ?? EMPTY_ORGANIZATIONS, @@ -142,6 +143,18 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } }, [onboardingInfo, effectiveOrganizations]) + const resetOnBuyerChange = useEffectEvent(() => { + setSelectedOrgSlug(null) + setLinkedOrgSlug(null) + setShowOrgCreationDialog(false) + resetLinkOrganizationError() + }) + + useEffect(() => { + resetOnBuyerChange() + // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) + }, [buyerId]) + if (!buyerId) { return ( @@ -249,6 +262,7 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const primaryAction = hasLinkableOrganizations ? () => { if (!selectedOrgSlug || !buyerId) return + resetLinkOrganizationError() linkOrganization({ slug: selectedOrgSlug, buyerId }) } : () => setShowOrgCreationDialog(true) @@ -278,7 +292,10 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } selectedSlug={selectedOrgSlug} disabled={isLinking} - onSelect={setSelectedOrgSlug} + onSelect={(slug) => { + setSelectedOrgSlug(slug) + resetLinkOrganizationError() + }} createLabel={hasLinkableOrganizations ? 'Create new organization' : undefined} onCreate={hasLinkableOrganizations ? () => setShowOrgCreationDialog(true) : undefined} /> @@ -292,21 +309,26 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } )}

- -

- - Learn more - {' '} - about billing through AWS. -

+
+ + +
+ {!linkError && ( +

+ + Learn more + {' '} + about billing through AWS. +

+ )}
diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index 7e62eb43785..bdd1c39c301 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -13,6 +13,7 @@ import { import { OrganizationInviteError } from './OrganizationInviteError' import { InterstitialAccountRow, + InterstitialActionError, InterstitialLayout, SupabaseLogo, } from '@/components/layouts/InterstitialLayout' @@ -197,13 +198,9 @@ export const OrganizationInvite = () => { - {joinError && ( -
-

- Failed to join organization: {joinError.message} -

-
- )} + ) diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index 1764b546c72..d7597dd32a4 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -204,3 +204,15 @@ export const InterstitialAccountRow = ({ ) + +export const InterstitialActionError = ({ error }: { error?: ReactNode }) => { + if (!error) return null + + return ( +
+

+ {error} +

+
+ ) +} diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 477bc847fd1..4f88fadf43e 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -419,6 +419,25 @@ describe('ApiAuthorizationScreen', () => { await user.click(screen.getByRole('button', { name: /Authorize Test App/ })) await waitFor(() => expect(approveHandler).toHaveBeenCalled()) }) + + test('shows an approval failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockBothEndpoints() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: () => + HttpResponse.json({ message: 'Authorization failed' }, { status: 500 }), + }) + renderScreen() + + await user.click(await screen.findByRole('button', { name: /Authorize Test App/ })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to authorize request: Authorization failed' + ) + expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeEnabled() + }) }) describe('decline action', () => { @@ -439,6 +458,25 @@ describe('ApiAuthorizationScreen', () => { await waitFor(() => expect(declineHandler).toHaveBeenCalled()) await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')) }) + + test('shows a cancellation failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockBothEndpoints() + addAPIMock({ + method: 'delete', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: () => + HttpResponse.json({ message: 'Cancellation failed' }, { status: 500 }), + }) + renderScreen() + + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to cancel authorization request: Cancellation failed' + ) + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled() + }) }) describe('form validation', () => { diff --git a/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx b/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx index 1422fe9d8b9..466f16385bb 100644 --- a/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx +++ b/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { platformComponents as components } from 'api-types' import { LOCAL_STORAGE_KEYS } from 'common' import { http, HttpResponse } from 'msw' +import { toast } from 'sonner' import { beforeEach, describe, expect, test, vi } from 'vitest' import { AwsMarketplaceOnboardingScreen } from '@/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding' @@ -17,6 +18,10 @@ import { createMockOrganizationResponse } from '@/tests/helpers' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, mswServer } from '@/tests/lib/msw' +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})) + type OrganizationResponse = components['schemas']['OrganizationResponse'] const DEFAULT_PROFILE_CONTEXT: ProfileContextType = { @@ -170,6 +175,28 @@ describe('AwsMarketplaceOnboardingScreen', () => { await screen.findByText('Organization linked') }) + test('renders a link failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockAwsEndpoints() + mswServer.use( + http.put(`${API_URL}/platform/organizations/:slug/cloud-marketplace/link`, () => + HttpResponse.json({ message: 'Marketplace link failed' }, { status: 500 }) + ) + ) + + renderScreen() + + await user.click(await screen.findByRole('button', { name: /Acme Production/ })) + await user.click(screen.getByRole('button', { name: 'Link organization' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to link organization: Marketplace link failed' + ) + expect(toast.error).not.toHaveBeenCalled() + expect(screen.queryByText(/Learn more/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Link organization' })).toBeEnabled() + }) + test('creates an AWS-managed organization with buyerId and returns to linked state', async () => { const user = userEvent.setup() let createRequest: unknown