From 5edcaef74cfa9851c5c4c1496b5a033e50681060 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:23:24 -0400
Subject: [PATCH] chore: show organization invite errors inline (#48470)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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 |
| --- | --- |
| | |
## 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()
```
to:
```tsx
const [joinError, setJoinError] = useState('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.
## 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.
---------
Co-authored-by: Joshen Lim
---
apps/design-system/__registry__/index.tsx | 11 +++++
.../content/docs/components/sonner.mdx | 15 +++++++
.../ui-patterns/connect-interstitials.mdx | 42 +++++++++++++++++++
.../content/docs/ui-patterns/forms.mdx | 4 +-
.../connect-interstitial-action-error.tsx | 36 ++++++++++++++++
apps/design-system/registry/examples.ts | 5 +++
.../OrganizationInvite.test.tsx | 18 +++++++-
.../OrganizationInvite/OrganizationInvite.tsx | 32 ++++++++------
.../OrganizationInvite.utils.test.ts | 4 +-
.../OrganizationInvite.utils.ts | 2 +-
10 files changed, 150 insertions(+), 19 deletions(-)
create mode 100644 apps/design-system/registry/default/example/connect-interstitial-action-error.tsx
rename apps/studio/{tests/components => components/interfaces/OrganizationInvite}/OrganizationInvite.test.tsx (92%)
rename apps/studio/{tests/components => components/interfaces/OrganizationInvite}/OrganizationInvite.utils.test.ts (96%)
diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx
index fade8897fb5..7e9ec446ef2 100644
--- a/apps/design-system/__registry__/index.tsx
+++ b/apps/design-system/__registry__/index.tsx
@@ -2623,6 +2623,17 @@ export const Index: Record = {
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",
diff --git a/apps/design-system/content/docs/components/sonner.mdx b/apps/design-system/content/docs/components/sonner.mdx
index 3ae4ce8ee0a..bfcae2524a2 100644
--- a/apps/design-system/content/docs/components/sonner.mdx
+++ b/apps/design-system/content/docs/components/sonner.mdx
@@ -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.
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 ceb6d36b189..99c0671ea69 100644
--- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx
+++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx
@@ -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 && (
+
+
+ {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.
+
+
+
## States
Keep loading, invalid, error, and success states inside the same card when the
diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx
index 305231b796b..add92626642 100644
--- a/apps/design-system/content/docs/ui-patterns/forms.mdx
+++ b/apps/design-system/content/docs/ui-patterns/forms.mdx
@@ -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.
diff --git a/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx b/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx
new file mode 100644
index 00000000000..085b3a48678
--- /dev/null
+++ b/apps/design-system/registry/default/example/connect-interstitial-action-error.tsx
@@ -0,0 +1,36 @@
+import { Button } from 'ui'
+
+import {
+ AccountRow,
+ InterstitialShell,
+ LogoPair,
+ StripeLogo,
+ SupabaseLogo,
+} from './connect-interstitial-shared'
+
+export default function ConnectInterstitialActionError() {
+ return (
+ } right={} />}
+ title="Authorize Stripe Projects"
+ description="This will create an organization on your behalf in Supabase"
+ >
+
+
+
+
+
+
+
+ Failed to authorize Stripe Projects. Please try again.
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts
index 2697a4f176e..04b6a3ec5b6 100644
--- a/apps/design-system/registry/examples.ts
+++ b/apps/design-system/registry/examples.ts
@@ -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',
diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx
similarity index 92%
rename from apps/studio/tests/components/OrganizationInvite.test.tsx
rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx
index baad458e0e9..f6f4201b7cc 100644
--- a/apps/studio/tests/components/OrganizationInvite.test.tsx
+++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx
@@ -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()
+
+ 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()
- 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.'
diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
index 6b150756a5d..7e62eb43785 100644
--- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
+++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
@@ -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 = () => {
+ {joinError && (
+
+
+ Failed to join organization: {joinError.message}
+
+
+ )}
)
diff --git a/apps/studio/tests/components/OrganizationInvite.utils.test.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts
similarity index 96%
rename from apps/studio/tests/components/OrganizationInvite.utils.test.ts
rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts
index c119fb46531..6d6c7cc7c8d 100644
--- a/apps/studio/tests/components/OrganizationInvite.utils.test.ts
+++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts
@@ -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) => {
diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts
index 8738739589b..53526db5ccf 100644
--- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts
+++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts
@@ -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')) {