mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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}
|
||||
/>
|
||||
|
||||
+56
-34
@@ -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
|
||||
|
||||
Reference in new issue
Block a user