From 080cc7aeab8f18ce605290c3be2c9c7f440b8296 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:57:56 +1100 Subject: [PATCH] chore(studio): authentication empty states (#40883) * auth hooks and docs * third-party auth * remove unrelated changes * remove unrelated changes * docs tighten * docs tighten * Nit clean up --------- Co-authored-by: Joshen Lim --- apps/design-system/__registry__/index.tsx | 41 +++++++++++++-- .../fragments/empty-state-presentational.mdx | 29 +++++++++-- .../content/docs/ui-patterns/empty-states.mdx | 4 +- .../empty-state-presentational-buttons.tsx | 41 +++++++++++++++ .../empty-state-presentational-demo.tsx | 16 ++++++ ...sx => empty-state-presentational-icon.tsx} | 2 +- apps/design-system/registry/examples.ts | 14 +++++- .../interfaces/Auth/Hooks/AddHookDropdown.tsx | 16 +++--- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 50 +++++++++---------- .../interfaces/Auth/Hooks/HooksListing.tsx | 16 +++--- .../AddIntegrationDropdown.tsx | 6 ++- .../Auth/ThirdPartyAuthForm/index.tsx | 34 +++++-------- .../AuthLayout/AuthProvidersLayout.tsx | 2 +- .../studio/pages/project/[ref]/auth/hooks.tsx | 4 +- .../src/EmptyStatePresentational/index.tsx | 50 ++++++++++--------- 15 files changed, 217 insertions(+), 108 deletions(-) create mode 100644 apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx create mode 100644 apps/design-system/registry/default/example/empty-state-presentational-demo.tsx rename apps/design-system/registry/default/example/{empty-state-initial-state-presentational.tsx => empty-state-presentational-icon.tsx} (88%) diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 553c807886f..1b624164b17 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -16,6 +16,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "EmptyStatePresentational": { + name: "EmptyStatePresentational", + type: "components:fragment", + registryDependencies: undefined, + component: React.lazy(() => import("@/../../packages/ui-patterns/src/EmptyStatePresentational")), + source: "", + files: ["registry/default//EmptyStatePresentational/index.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "TextConfirmModal": { name: "TextConfirmModal", type: "components:fragment", @@ -2447,13 +2458,35 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, - "empty-state-initial-state-presentational": { - name: "empty-state-initial-state-presentational", + "empty-state-presentational-demo": { + name: "empty-state-presentational-demo", type: "components:example", registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-initial-state-presentational")), + component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-demo")), source: "", - files: ["registry/default/example/empty-state-initial-state-presentational.tsx"], + files: ["registry/default/example/empty-state-presentational-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "empty-state-presentational-buttons": { + name: "empty-state-presentational-buttons", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-buttons")), + source: "", + files: ["registry/default/example/empty-state-presentational-buttons.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "empty-state-presentational-icon": { + name: "empty-state-presentational-icon", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-icon")), + source: "", + files: ["registry/default/example/empty-state-presentational-icon.tsx"], category: "undefined", subcategory: "undefined", chunks: [] diff --git a/apps/design-system/content/docs/fragments/empty-state-presentational.mdx b/apps/design-system/content/docs/fragments/empty-state-presentational.mdx index 74435dd33b5..e0936a16104 100644 --- a/apps/design-system/content/docs/fragments/empty-state-presentational.mdx +++ b/apps/design-system/content/docs/fragments/empty-state-presentational.mdx @@ -1,12 +1,35 @@ --- title: EmptyStatePresentational -description: An initial state with value propositions and actions. +description: An empty state for encouraging action. component: true fragment: true --- - + -Supports both Lucide icons and custom icon components from the `icons` package. Expects a title, description, and optional action buttons or other children. +Expects a title, description, and optional action buttons or other children. + +## Props + +### Text + +All text should be written using active language. The title should prompt the user to take an action, and the description should clearly explain the value of doing so. + +### Icon + +Supports both Lucide icons and [custom icons](../icons) via the `icons` package. If neither are passed, EmptyStatePresentational falls back to Lucide’s `SquarePlus`. + + See also [Empty States](../ui-patterns/empty-states). + +## Examples + +It’s okay to repeat buttons inside of EmptyStatePresentational that are also available outside of it. The alternative is to conditionally determine button placement whilst polling for list length (to determine whether to show an empty state or not). This is problematic for two reasons: + +1. Rendering after client-side polling often leads to confusing layout shift. This layout shift becomes exacerbated when buttons are stacked against other objects. +2. Consistent entry points outside of EmptyStatePresentational also teach a pattern that will continue to exist post initial object creation. + +When repeating buttons, set the `type` to `default` so the original `primary`, button remains the only `primary` action on display. + + diff --git a/apps/design-system/content/docs/ui-patterns/empty-states.mdx b/apps/design-system/content/docs/ui-patterns/empty-states.mdx index a069124ea6a..028746e62c0 100644 --- a/apps/design-system/content/docs/ui-patterns/empty-states.mdx +++ b/apps/design-system/content/docs/ui-patterns/empty-states.mdx @@ -44,7 +44,9 @@ Perhaps the user has not yet created any data. The presentation of this empty st The user may be learning about a feature for the first time, and could benefit from lightweight feature education or onboarding. Use the dedicated [EmptyStatePresentational](../fragments/empty-state-presentational) component in this case, putting emphasis on an action the user can take. - + + +Remember to use active language in these cases. For example: “Create a vector bucket” instead of “No vector buckets found”. The latter is more appropriate in table-based presentations, as described below. #### Informational diff --git a/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx new file mode 100644 index 00000000000..45eef4ae0a7 --- /dev/null +++ b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx @@ -0,0 +1,41 @@ +import { Plus } from 'lucide-react' +import { Button } from 'ui' +import { EmptyStatePresentational } from 'ui-patterns' + +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +export default function EmptyStatePresentationalIcon() { + return ( +
+ + + + Providers + + + + + + + + + + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx new file mode 100644 index 00000000000..7b79318535a --- /dev/null +++ b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx @@ -0,0 +1,16 @@ +import { Plus } from 'lucide-react' +import { Button } from 'ui' +import { EmptyStatePresentational } from 'ui-patterns' + +export default function EmptyStatePresentationalIcon() { + return ( + + + + ) +} diff --git a/apps/design-system/registry/default/example/empty-state-initial-state-presentational.tsx b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx similarity index 88% rename from apps/design-system/registry/default/example/empty-state-initial-state-presentational.tsx rename to apps/design-system/registry/default/example/empty-state-presentational-icon.tsx index b4103936eaf..2da35261df0 100644 --- a/apps/design-system/registry/default/example/empty-state-initial-state-presentational.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx @@ -3,7 +3,7 @@ import { Plus } from 'lucide-react' import { Button } from 'ui' import { EmptyStatePresentational } from 'ui-patterns' -export default function EmptyStateInitialStatePresentational() { +export default function EmptyStatePresentationalIcon() { return ( void } export const AddHookDropdown = ({ buttonText = 'Add hook', align = 'end', + type = 'primary', onSelectHook, }: AddHookDropdownProps) => { const { ref: projectRef } = useParams() @@ -56,7 +59,7 @@ export const AddHookDropdown = ({ return ( - @@ -89,14 +92,7 @@ export const AddHookDropdown = ({

Team or Enterprise Plan required

The following hooks are not available on{' '} - - your plan - - . + your plan.

)} diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index b17059315fc..d4a0e6ac0e8 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -1,9 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { X } from 'lucide-react' import randomBytes from 'randombytes' import { useEffect, useMemo } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' import * as z from 'zod' @@ -28,16 +26,15 @@ import { RadioGroupStackedItem, Separator, Sheet, - SheetClose, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, - cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { InfoTooltip } from 'ui-patterns/info-tooltip' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' @@ -269,28 +266,21 @@ export const CreateHookSheet = ({ }) } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [authConfig, title, visible, definition]) return ( onClose()}> - + -
- - - Close - - - {isCreating ? `Add ${title}` : `Update ${title}`} - -
+ + {isCreating ? `Add ${title}` : `Update ${title}`} +
@@ -459,11 +449,17 @@ export const CreateHookSheet = ({ - It should be a base64 encoded hook secret with a prefix `v1,whsec_`. - `v1` denotes the signature version, and `whsec_` signifies a symmetric - secret. - +
+

+ Should be a base64 encoded hook secret with a prefix{' '} + v1,whsec_. +

+ + v1 denotes the signature + version and whsec_ signifies + a symmetric secret. + +
} > @@ -472,7 +468,7 @@ export const CreateHookSheet = ({ - Select Provider + Select provider diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx index 416033c66f1..290b2a37780 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx @@ -15,6 +15,7 @@ import { import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' import { cn } from 'ui' +import { EmptyStatePresentational } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PageSection, @@ -63,7 +64,7 @@ export const ThirdPartyAuthForm = () => { return ( ) } @@ -72,27 +73,20 @@ export const ThirdPartyAuthForm = () => { - Third Party Auth + Third-Party Auth - Use third-party authentication (TPA) systems based on JWTs to access your project. -
Billing is based on the number of monthly active users (MAUs) requesting your API - throughout the billing period. Refer to our{' '} + throughout the billing period.{' '} - billing docs - {' '} - for more information. + Learn more +
-
- - {integrations.length !== 0 && ( - - )} -
+ +
@@ -108,18 +102,16 @@ export const ThirdPartyAuthForm = () => { {isSuccess ? ( integrations.length === 0 ? ( -
-

No providers configured yet

-
+
) : (
{integrations.map((integration) => { diff --git a/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx index f2a247ab280..81f3c445ed0 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx @@ -21,7 +21,7 @@ export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => { ...(authenticationThirdPartyAuth ? [ { - label: 'Third Party Auth', + label: 'Third-Party Auth', href: `/project/${ref}/auth/third-party`, }, ] diff --git a/apps/studio/pages/project/[ref]/auth/hooks.tsx b/apps/studio/pages/project/[ref]/auth/hooks.tsx index 84fa9b5cf39..e4eed3e87a5 100644 --- a/apps/studio/pages/project/[ref]/auth/hooks.tsx +++ b/apps/studio/pages/project/[ref]/auth/hooks.tsx @@ -36,9 +36,7 @@ const Hooks: NextPageWithLayout = () => { Auth Hooks - - Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth - + Customize your authentication flow diff --git a/packages/ui-patterns/src/EmptyStatePresentational/index.tsx b/packages/ui-patterns/src/EmptyStatePresentational/index.tsx index cfa533127cf..e5443b03d99 100644 --- a/packages/ui-patterns/src/EmptyStatePresentational/index.tsx +++ b/packages/ui-patterns/src/EmptyStatePresentational/index.tsx @@ -1,4 +1,5 @@ import type { LucideIcon } from 'lucide-react' +import { SquarePlus } from 'lucide-react' import { createElement, isValidElement, ReactNode } from 'react' import { cn } from 'ui' @@ -59,6 +60,9 @@ export const EmptyStatePresentational = ({ iconClassName, contentClassName, }: EmptyStatePresentationalProps) => { + // Use SquarePlus as default icon if none is provided + const iconToRender = Icon || SquarePlus + // Extract content rendering to avoid duplication between icon and no-icon cases const textContent = (
@@ -74,30 +78,28 @@ export const EmptyStatePresentational = ({ className )} > - {Icon ? ( -
- {/* - Handle different icon types: - 1. If it's already a React element (pre-rendered), render it directly - 2. If it's a function (LucideIcon component type) or forwardRef component (has $$typeof), - instantiate it with createElement and apply default props - 3. Otherwise, render as-is (fallback for other ReactNode types) - */} - {isValidElement(Icon) - ? Icon - : typeof Icon === 'function' || - (typeof Icon === 'object' && Icon !== null && '$$typeof' in Icon) - ? createElement(Icon as LucideIcon, { - size: iconSize, - strokeWidth: 1.5, - className: cn('text-foreground-muted', iconClassName), - }) - : Icon} - {textContent} -
- ) : ( - textContent - )} +
+ {/* + Handle different icon types: + 1. If it's already a React element (pre-rendered), render it directly + 2. If it's a function (LucideIcon component type) or forwardRef component (has $$typeof), + instantiate it with createElement and apply default props + 3. Otherwise, render as-is (fallback for other ReactNode types) + */} + {isValidElement(iconToRender) + ? iconToRender + : typeof iconToRender === 'function' || + (typeof iconToRender === 'object' && + iconToRender !== null && + '$$typeof' in iconToRender) + ? createElement(iconToRender as LucideIcon, { + size: iconSize, + strokeWidth: 1.5, + className: cn('text-foreground-muted', iconClassName), + }) + : iconToRender} + {textContent} +
{/* Optional children (typically action buttons) */} {children}