mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
## 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>
219 lines
6.4 KiB
TypeScript
219 lines
6.4 KiB
TypeScript
import { motion } from 'framer-motion'
|
|
import { ArrowRightLeft } from 'lucide-react'
|
|
import type { PropsWithChildren, ReactNode } from 'react'
|
|
import { Card, CardContent, CardHeader, cn } from 'ui'
|
|
|
|
import { ProfileImage } from '@/components/ui/ProfileImage'
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
|
|
const MotionCard = motion.create(Card)
|
|
|
|
interface InterstitialLayoutProps {
|
|
logo?: ReactNode
|
|
title?: ReactNode
|
|
description?: ReactNode
|
|
/** Optional content rendered beneath the card (e.g. a terms disclaimer), at the card's width. */
|
|
footer?: ReactNode
|
|
containerClassName?: string
|
|
cardClassName?: string
|
|
/** Shared max-width for the card and footer column. Defaults to `max-w-[400px]`. */
|
|
widthClassName?: string
|
|
titleClassName?: string
|
|
descriptionClassName?: string
|
|
}
|
|
|
|
/**
|
|
* Minimal full-screen centered layout for interstitial flows:
|
|
* partner authorization, org invites, CLI auth, credit redemption, etc.
|
|
*
|
|
* The logo, title, and description render inside the card (above children),
|
|
* so every consumer gets a consistent header for free.
|
|
*/
|
|
export const InterstitialLayout = ({
|
|
logo,
|
|
title,
|
|
description,
|
|
footer,
|
|
containerClassName,
|
|
cardClassName,
|
|
widthClassName = 'max-w-[400px]',
|
|
titleClassName,
|
|
descriptionClassName,
|
|
children,
|
|
}: PropsWithChildren<InterstitialLayoutProps>) => {
|
|
const TitleElement = typeof title === 'string' ? 'h1' : 'div'
|
|
const DescriptionElement = typeof description === 'string' ? 'p' : 'div'
|
|
|
|
const titleElement = title ? (
|
|
<TitleElement
|
|
className={cn(
|
|
'font-sans tracking-tight text-balance text-lg font-medium normal-case text-foreground',
|
|
titleClassName
|
|
)}
|
|
>
|
|
{title}
|
|
</TitleElement>
|
|
) : null
|
|
|
|
const descriptionElement = description ? (
|
|
<DescriptionElement
|
|
className={cn(
|
|
'!m-0 px-3 !text-balance text-sm text-foreground-lighter leading-tight',
|
|
descriptionClassName
|
|
)}
|
|
>
|
|
{description}
|
|
</DescriptionElement>
|
|
) : null
|
|
|
|
const card = (
|
|
<MotionCard
|
|
layout="size"
|
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
className={cn('overflow-hidden w-full mx-auto', widthClassName, cardClassName)}
|
|
>
|
|
{(logo || title || description) && (
|
|
<CardHeader className="font-normal items-center gap-0 space-y-0 px-6 py-6 text-center [--card-padding-x:1.5rem] border-0">
|
|
{logo && <div className="mb-4 flex justify-center">{logo}</div>}
|
|
{(titleElement || descriptionElement) && (
|
|
<div className="flex flex-col items-center gap-1">
|
|
{titleElement}
|
|
{descriptionElement}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
)}
|
|
{children}
|
|
</MotionCard>
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex min-h-screen w-full items-center justify-center bg-studio px-2 py-6',
|
|
containerClassName
|
|
)}
|
|
>
|
|
{footer ? (
|
|
<div className={cn('flex w-full flex-col items-center gap-4', widthClassName)}>
|
|
{card}
|
|
<div className="px-2 text-center text-balance">{footer}</div>
|
|
</div>
|
|
) : (
|
|
card
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Standard rounded-rect logo container (48x48).
|
|
* Partner logos fill edge-to-edge (see `PartnerLogo`); the Supabase symbol and
|
|
* Lucide icons sit inset (sized at `size-7`).
|
|
*/
|
|
export const LogoBox = ({ children, className }: { children: ReactNode; className?: string }) => (
|
|
<div
|
|
className={cn(
|
|
'flex size-12 items-center justify-center overflow-hidden rounded-xl border bg-muted',
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
/** Two pre-boxed logos side-by-side with a swap separator. */
|
|
export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode }) => (
|
|
<div className="flex items-center justify-center gap-2.5">
|
|
{left}
|
|
<ArrowRightLeft className="size-4 text-foreground-muted" />
|
|
{right}
|
|
</div>
|
|
)
|
|
|
|
/** Partner logo rendered edge-to-edge inside a LogoBox by default. */
|
|
export const PartnerLogo = ({
|
|
src,
|
|
alt,
|
|
className,
|
|
imageClassName,
|
|
}: {
|
|
src: string
|
|
alt: string
|
|
className?: string
|
|
imageClassName?: string
|
|
}) => (
|
|
<LogoBox className={className}>
|
|
<img alt={alt} src={src} className={cn('size-full object-cover', imageClassName)} />
|
|
</LogoBox>
|
|
)
|
|
|
|
/**
|
|
* Sign-in destination mark, inset to match {@link SupabaseLogo}. Falls back to the destination's
|
|
* initial when no icon is available.
|
|
*/
|
|
export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string }) => (
|
|
<LogoBox>
|
|
{icon ?? <span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span>}
|
|
</LogoBox>
|
|
)
|
|
|
|
/** Fixed light tile chrome for Connect pairs with unclassified (uploaded) marks. */
|
|
export const CONNECT_LOGO_LIGHT_TILE_CLASSNAME = 'border-black/10 bg-white'
|
|
|
|
/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */
|
|
export const SupabaseLogo = ({ forceLight = false }: { forceLight?: boolean } = {}) => (
|
|
<LogoBox className={forceLight ? CONNECT_LOGO_LIGHT_TILE_CLASSNAME : 'bg-surface-75'}>
|
|
<img alt="Supabase" src={`${BASE_PATH}/img/supabase-logo.svg`} className="size-7" />
|
|
</LogoBox>
|
|
)
|
|
|
|
export const InterstitialAccountRow = ({
|
|
avatarUrl,
|
|
displayName,
|
|
action,
|
|
className,
|
|
detail,
|
|
}: {
|
|
avatarUrl?: string
|
|
displayName?: string
|
|
action?: ReactNode
|
|
className?: string
|
|
detail?: string
|
|
}) => (
|
|
<Card className={cn('shadow-none', !action && 'border-muted bg-surface-200/50', className)}>
|
|
<CardContent
|
|
className={cn(
|
|
'flex gap-3 border-none',
|
|
action ? 'items-center px-4 py-3' : 'items-start p-3'
|
|
)}
|
|
>
|
|
<ProfileImage
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
className="size-8 flex-shrink-0 rounded-full border border-muted"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs text-foreground-light">Signed in as</p>
|
|
<p className="truncate text-sm text-foreground">
|
|
{displayName || <span className="invisible">Loading account</span>}
|
|
</p>
|
|
{detail && <p className="mt-1 truncate text-xs text-foreground-light">{detail}</p>}
|
|
</div>
|
|
{action}
|
|
</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>
|
|
)
|
|
}
|