chore: show organization invite errors inline (#48470)

## What kind of change does this PR introduce?

Bug fix and design-system documentation update.

## What is the current behavior?

Invite acceptance failures only appear in a transient toast.

## What is the new behavior?

Invite failures remain visible beside the actions. The design-system
guidance now distinguishes field, action, state, and toast feedback.

| Before | After |
| --- | --- |
| <img width="759" height="619" alt="Join Organization Supabase"
src="https://github.com/user-attachments/assets/ed8e974c-5da3-477a-81da-628d3f847131"
/> | <img width="741" height="768" alt="Join Organization Supabase"
src="https://github.com/user-attachments/assets/4c3f6bcd-4ed9-40b2-8280-e8c8a44ecbd6"
/> |

## To test

With local Studio running at `http://localhost:8082`:

1. Open
`apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts`.
2. At line 37, immediately inside `getOrganizationInviteStatus`, add:
   ```tsx
   return 'ready'
   ```
This deliberately bypasses invite lookup and account checks for the
visual test.
3. Open
`apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx`.
4. At line 30, change:
   ```tsx
   const [joinError, setJoinError] = useState<string>()
   ```
   to:
   ```tsx
const [joinError, setJoinError] = useState<string>('Invite token can
only be accepted via an SSO account')
   ```
5. Open `http://localhost:8082/join?token=test&slug=test` while signed
in.
6. Confirm the card says **Join an organization** and shows the error
below **Decline**, separated from the actions by a divider.
7. Revert both temporary edits before committing anything.

## Additional context

First PR in a five-PR stack.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a new connect interstitial example showcasing an inline
action-error state with clear retry guidance.

- **Bug Fixes**
- Invitation acceptance failures now show inline destructive feedback
under “Accept invite,” keeping the button enabled for retry (and
removing prior toast-based failure behavior).
  - Updated the invalid-invitation title to “Invalid invitation.”
  - Changed the “Decline” link destination to `/organizations`.

- **Documentation**
  - Expanded Sonner toast “When to use” guidance.
- Refined form and connect interstitial action-feedback patterns (inline
vs toast usage).

- **Tests**
- Updated and added coverage for the inline error rendering and “Invalid
invitation” text.
<!-- 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-07-31 06:23:24 +10:00
1 parent 205527c716
commit 5edcaef74c
10 files changed
+150 -19

No files matched your search

+11
View File
@@ -2623,6 +2623,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"connect-interstitial-action-error": {
name: "connect-interstitial-action-error",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/connect-interstitial-action-error")),
source: "",
files: ["registry/default/example/connect-interstitial-action-error.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"connect-interstitial-logo-pair": {
name: "connect-interstitial-logo-pair",
type: "components:example",
@@ -58,6 +58,21 @@ import { toast } from 'sonner'
toast('Event has been created.')
```
## When to use
Use a toast for short-lived, non-blocking feedback or to confirm an operation
after its originating surface has closed or navigated away.
Do not use a toast as the only feedback for:
- Field validation. Use `FormMessage` or `FieldError` beside the field.
- A failed submission that leaves the form or interstitial visible. Show a
`role="alert"` message in destructive text near the actions.
- A blocking or materially changed page state. Use an inline state component
such as `Admonition` or `ErrorDisplay`.
Keep error copy specific and include the next step when it is not obvious.
## Expand
You can change the amount of toasts visible through the visibleToasts prop.
@@ -200,6 +200,48 @@ for new states instead of inventing a parallel card style.
Prefer one full-width primary action. A full-width text button is fine for a
secondary action that still belongs in the flow.
### Action feedback
Match feedback to its scope:
- Use `FormMessage` or `FieldError` beside a field when that field needs to
change.
- Show a submission or action failure as simple destructive text below the
actions. Separate it with a subtle divider when needed for composition. Keep
the current account, selections, and actions visible so the user can retry.
- Use `Admonition` when the whole interstitial is blocked or has materially
changed state, such as an invalid link, wrong account, or partially completed
setup.
- Use a toast only for non-blocking feedback or a completed action whose
originating surface is no longer visible. A toast must not be the only
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>
)
}
```
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.
<ComponentPreview
name="connect-interstitial-action-error"
description="Retryable action error shown beside the actions"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## States
Keep loading, invalid, error, and success states inside the same card when the
@@ -42,7 +42,7 @@ Keep repeated-row validation in the form schema or shared validation helper, not
Build a custom row when the cells are mixed controls, such as an input paired with a `Select`.
## Best Practices
## Best practices
1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`.
@@ -57,7 +57,7 @@ Build a custom row when the cells are mixed controls, such as an input paired wi
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate)
6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications.
6. **Error handling**: Match feedback to its scope. Use `FormMessage` or `FieldError` for field validation. Show submission failures inline near the form actions when the user needs to retry or change something. Reserve toasts for non-blocking feedback or completed operations whose originating surface is no longer visible.
7. **Loading states**: Show loading states on submit buttons using the `loading` prop.
@@ -0,0 +1,36 @@
import { Button } from 'ui'
import {
AccountRow,
InterstitialShell,
LogoPair,
StripeLogo,
SupabaseLogo,
} from './connect-interstitial-shared'
export default function ConnectInterstitialActionError() {
return (
<InterstitialShell
logo={<LogoPair left={<StripeLogo />} right={<SupabaseLogo />} />}
title="Authorize Stripe Projects"
description="This will create an organization on your behalf in Supabase"
>
<div className="flex flex-col gap-4">
<AccountRow displayName="alex@example.com" />
<div className="flex flex-col gap-2">
<Button variant="primary" block>
Authorize Stripe Projects
</Button>
<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>
</div>
</div>
</InterstitialShell>
)
}
+5
View File
@@ -1408,6 +1408,11 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/connect-interstitial-demo.tsx'],
},
{
name: 'connect-interstitial-action-error',
type: 'components:example',
files: ['example/connect-interstitial-action-error.tsx'],
},
{
name: 'connect-interstitial-logo-pair',
type: 'components:example',
@@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite'
import { OrganizationInvite } from './OrganizationInvite'
import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
import type { ProfileContextType } from '@/lib/profile'
import { render } from '@/tests/helpers'
@@ -172,6 +172,20 @@ describe('OrganizationInvite', () => {
})
})
test('renders an inline error when joining the organization fails', () => {
mocks.useAcceptInvitationMutation.mockReturnValue({
mutate: mocks.acceptInvitation,
isPending: false,
error: responseError('You are already a member of this organization'),
})
render(<OrganizationInvite />)
expect(screen.getByRole('alert')).toHaveTextContent(
'Failed to join organization: You are already a member of this organization'
)
})
test('renders a wrong-account warning and signs out', async () => {
const user = userEvent.setup()
mocks.useInvitationQuery.mockReturnValue({
@@ -225,7 +239,7 @@ describe('OrganizationInvite', () => {
rerender(<OrganizationInvite />)
expect(screen.getByText('Invite invalid')).toBeInTheDocument()
expect(screen.getByText('Invalid invitation')).toBeInTheDocument()
expect(
screen.getByText(
'Open the full invite link again, or ask the organization owner for a new invite.'
@@ -1,8 +1,7 @@
import { useIsLoggedIn, useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import type { ReactNode } from 'react'
import { toast } from 'sonner'
import { type ReactNode } from 'react'
import { Button, Card, CardContent } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
@@ -25,9 +24,9 @@ import { useProfile, useProfileNameAndPicture } from '@/lib/profile'
export const OrganizationInvite = () => {
const router = useRouter()
const isLoggedIn = useIsLoggedIn()
const { slug, token } = useParams()
const { profile, isLoading: isLoadingProfile } = useProfile()
const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture()
const { slug, token } = useParams()
const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up')
@@ -70,15 +69,17 @@ export const OrganizationInvite = () => {
const mfaRequiredError = error?.message.includes('MFA required')
const { mutate: joinOrganization, isPending: isJoining } =
useOrganizationAcceptInvitationMutation({
onSuccess: () => {
router.push('/organizations')
},
onError: (error) => {
toast.error(`Failed to join organization: ${error.message}`)
},
})
const {
mutate: joinOrganization,
isPending: isJoining,
error: joinError,
} = useOrganizationAcceptInvitationMutation({
onSuccess: () => {
router.push('/organizations')
},
// [Joshen] Silence the default toast handler
onError: () => {},
})
async function handleJoinOrganization() {
if (!slug) return console.error('Slug is required')
@@ -196,6 +197,13 @@ 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>
)}
</div>
</div>
)
@@ -4,7 +4,7 @@ import {
getOrganizationInviteContent,
getOrganizationInviteStatus,
type OrganizationInviteStatus,
} from '@/components/interfaces/OrganizationInvite/OrganizationInvite.utils'
} from './OrganizationInvite.utils'
import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
import type { ResponseError } from '@/types'
@@ -101,7 +101,7 @@ describe('OrganizationInvite utils', () => {
['ready', 'Join Acme Corp', 'You have been invited to join this Supabase organization'],
['wrong-account', 'Wrong account', undefined],
['expired', 'Invite expired', undefined],
['invalid', 'Invite invalid', undefined],
['invalid', 'Invalid invitation', undefined],
['no-longer-valid', 'Invite no longer available', undefined],
['error', 'Unable to load invitation', undefined],
])('returns content for %s', (status, title, description) => {
@@ -88,7 +88,7 @@ export function getOrganizationInviteContent({
if (status === 'wrong-account') return { title: 'Wrong account' }
if (status === 'expired') return { title: 'Invite expired' }
if (status === 'invalid') return { title: 'Invite invalid' }
if (status === 'invalid') return { title: 'Invalid invitation' }
if (status === 'no-longer-valid') return { title: 'Invite no longer available' }
if (status === 'error') {
if (error?.message.includes('MFA required')) {