show API and AWS authorization errors inline (#48471)

## 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 |
| --- | --- |
| <img width="1024" height="759" alt="Authorize API Access Supabase"
src="https://github.com/user-attachments/assets/9520aff3-496d-44b1-b5b5-02b331872e32"
/> | <img width="1024" height="759" alt="Authorize API Access Supabase"
src="https://github.com/user-attachments/assets/2d09e337-573a-45b5-80ac-7c546ed1401d"
/> |
| <img width="1024" height="759" alt="Link AWS Marketplace Supabase"
src="https://github.com/user-attachments/assets/bb1a4581-0399-432a-8037-d84ab15ecc4b"
/> | <img width="1024" height="759" alt="Link AWS Marketplace Supabase"
src="https://github.com/user-attachments/assets/f9d43cd9-c661-42ed-91c0-e45ccb9c19f5"
/> |

_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 (
     <AwsMarketplaceInterstitial>
       <div className="flex flex-col gap-5">
         <InterstitialAccountRow displayName="reviewer@example.com" />
         <OrganizationSelector
           organizations={[
             {
               name: 'Example Organization',
               slug: 'example-organization',
               plan: { id: 'pro', name: 'Pro' },
             } as Organization,
           ]}
           selectedSlug="example-organization"
           disabled
           onSelect={() => undefined}
         />
         <div className="flex flex-col gap-5">
           <div className="flex flex-col gap-2">
             <Button variant="primary" block>
               Link organization
             </Button>
<InterstitialActionError error="Failed to link organization: Test error"
/>
           </div>
<p className="text-center text-xs text-foreground-lighter text-balance">
<InlineLink href={`${DOCS_URL}/guides/platform/aws-marketplace`}>
               Learn more
             </InlineLink>{' '}
             about billing through AWS.
           </p>
         </div>
       </div>
     </AwsMarketplaceInterstitial>
   )
   ```
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 (
     <ApiAuthorizationMainView
       approvalState="indeterminate"
       form={form}
       requester={{
         name: 'Test App',
         website: 'https://example.com',
         icon: null,
         domain: 'example.com',
         scopes: [],
         expires_at: '2099-01-01T00:00:00.000Z',
         approved_at: null,
         registration_type: 'static',
       }}
       organizations={{
         _tag: 'success',
         organizations: [
{ name: 'Example Organization', slug: 'example-organization' } as
Organization,
         ],
       }}
       requestedOrganizationSlug={undefined}
       actionError="Failed to authorize request: Test error"
       onOrganizationChange={() => 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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-08-03 09:43:23 +10:00
1 parent 3a3661019f
commit 2b26da360e
10 files changed
+203 -68

No files matched your search

@@ -217,20 +217,14 @@ Match feedback to its scope:
feedback for a failure the user needs to resolve on the current card.
```tsx
{
actionError && (
<div className="mt-3 border-t border-muted pt-5">
<p role="alert" className="text-center text-xs text-destructive text-balance">
{actionError}
</p>
</div>
)
}
<InterstitialActionError error={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.
<ComponentPreview
name="connect-interstitial-action-error"
@@ -2,6 +2,7 @@ import { Button } from 'ui'
import {
AccountRow,
InterstitialActionError,
InterstitialShell,
LogoPair,
StripeLogo,
@@ -24,11 +25,7 @@ export default function ConnectInterstitialActionError() {
<Button variant="text" block>
Cancel
</Button>
<div className="mt-3 border-t border-muted pt-5">
<p role="alert" className="text-center text-xs text-destructive text-balance">
Failed to authorize Stripe Projects. Please try again.
</p>
</div>
<InterstitialActionError error="Failed to authorize Stripe Projects. Please try again." />
</div>
</div>
</InterstitialShell>
@@ -120,6 +120,18 @@ export function InterstitialShell({
)
}
export function InterstitialActionError({ error }: { error?: React.ReactNode }) {
if (!error) return null
return (
<div className="mt-3 border-t border-muted pt-5">
<p role="alert" className="text-center text-xs text-destructive text-balance">
{error}
</p>
</div>
)
}
export function SignOutButton() {
return <Button variant="default" icon={<LogOut />} className="px-2" aria-label="Sign out" />
}
@@ -23,7 +23,10 @@ import {
AuthorizeConnectLogo,
AuthorizeRequesterDetails,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { InterstitialLayout } from '@/components/layouts/InterstitialLayout'
import {
InterstitialActionError,
InterstitialLayout,
} from '@/components/layouts/InterstitialLayout'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { Organization, ResponseError } from '@/types'
@@ -71,6 +74,8 @@ export interface ApiAuthorizationMainViewProps {
requester: ApiAuthorizationResponse
organizations: OrganizationsState
requestedOrganizationSlug: string | undefined
actionError?: string
onOrganizationChange: () => void
onApprove: () => void
onDecline: () => void
}
@@ -81,6 +86,8 @@ export function ApiAuthorizationMainView({
requester,
organizations,
requestedOrganizationSlug,
actionError,
onOrganizationChange,
onApprove,
onDecline,
}: ApiAuthorizationMainViewProps): ReactNode {
@@ -121,6 +128,7 @@ export function ApiAuthorizationMainView({
requester={requester}
organizations={organizations.organizations}
requestedOrganizationSlug={requestedOrganizationSlug}
onOrganizationChange={onOrganizationChange}
/>
)}
{showReadyContent && (
@@ -134,6 +142,7 @@ export function ApiAuthorizationMainView({
approvalState={approvalState}
requester={requester}
redirectUrl={externalRedirectUrl}
actionError={actionError}
onApprove={onApprove}
onDecline={onDecline}
/>
@@ -221,6 +230,7 @@ interface OrganizationSelectorProps {
requestedOrganizationSlug: string | undefined
organizations: Array<Organization>
disabled?: boolean
onOrganizationChange: () => void
}
function OrganizationSelector({
@@ -229,6 +239,7 @@ function OrganizationSelector({
requestedOrganizationSlug,
organizations,
disabled = false,
onOrganizationChange,
}: OrganizationSelectorProps): ReactNode {
return (
<Form {...form}>
@@ -251,6 +262,7 @@ function OrganizationSelector({
disabled={disabled}
onValueChange={(value) => {
field.onChange(value)
onOrganizationChange()
form.trigger('selectedOrgSlug')
}}
>
@@ -287,6 +299,7 @@ interface FormFooterProps {
approvalState: ApprovalState
requester: ApiAuthorizationResponse
redirectUrl?: string
actionError?: string
onDecline: () => void
onApprove: () => void
}
@@ -295,6 +308,7 @@ function FormFooter({
approvalState,
requester,
redirectUrl,
actionError,
onDecline,
onApprove,
}: FormFooterProps): ReactNode {
@@ -315,7 +329,8 @@ function FormFooter({
>
Cancel
</Button>
{redirectUrl && (
<InterstitialActionError error={actionError} />
{!actionError && redirectUrl && (
<div className="mt-3 border-t border-muted pt-5">
<p className="text-center text-xs text-foreground-lighter text-balance">
Authorizing will redirect you to <span className="text-foreground">{redirectUrl}</span>
@@ -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}
/>
@@ -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<string | null>(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 (
<AwsMarketplaceInterstitial>
@@ -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 }
)}
<div className="flex flex-col gap-5">
<Button
variant="primary"
block
loading={isLinking}
disabled={hasLinkableOrganizations && (!selectedOrgSlug || isLinking)}
onClick={primaryAction}
>
{primaryLabel}
</Button>
<p className="text-center text-xs text-foreground-lighter text-balance">
<InlineLink href={`${DOCS_URL}/guides/platform/aws-marketplace`}>
Learn more
</InlineLink>{' '}
about billing through AWS.
</p>
<div className="flex flex-col gap-2">
<Button
variant="primary"
block
loading={isLinking}
disabled={hasLinkableOrganizations && (!selectedOrgSlug || isLinking)}
onClick={primaryAction}
>
{primaryLabel}
</Button>
<InterstitialActionError error={linkError} />
</div>
{!linkError && (
<p className="text-center text-xs text-foreground-lighter text-balance">
<InlineLink href={`${DOCS_URL}/guides/platform/aws-marketplace`}>
Learn more
</InlineLink>{' '}
about billing through AWS.
</p>
)}
</div>
</div>
</AwsMarketplaceInterstitial>
@@ -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 = () => {
<Button asChild variant="text" block>
<Link href="/organizations">Decline</Link>
</Button>
{joinError && (
<div className="mt-3 border-t border-muted pt-5">
<p role="alert" className="text-center text-xs text-destructive text-balance">
Failed to join organization: {joinError.message}
</p>
</div>
)}
<InterstitialActionError
error={joinError && `Failed to join organization: ${joinError.message}`}
/>
</div>
</div>
)
@@ -204,3 +204,15 @@ export const InterstitialAccountRow = ({
</CardContent>
</Card>
)
export const InterstitialActionError = ({ error }: { error?: ReactNode }) => {
if (!error) return null
return (
<div className="mt-3 border-t border-muted pt-5">
<p role="alert" className="text-center text-xs text-destructive text-balance">
{error}
</p>
</div>
)
}
@@ -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<APIErrorBody>({ 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<APIErrorBody>({ 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', () => {
@@ -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