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