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 <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2025-11-28 14:57:56 +08:00
1 parent d02926a68f
commit 080cc7aeab
15 files changed
+217 -108

No files matched your search

+37 -4
View File
@@ -16,6 +16,17 @@ export const Index: Record<string, any> = {
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<string, any> = {
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: []
@@ -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
---
<ComponentPreview name="empty-state-initial-state-presentational" peekCode />
<ComponentPreview name="empty-state-presentational-icon" peekCode />
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`.
<ComponentPreview name="empty-state-presentational-demo" peekCode />
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.
<ComponentPreview name="empty-state-presentational-buttons" peekCode />
@@ -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.
<ComponentPreview name="empty-state-initial-state-presentational" peekCode wide />
<ComponentPreview name="empty-state-presentational-icon" peekCode wide />
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
@@ -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 (
<div className="w-full">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Providers</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<Button size="tiny" type="primary" icon={<Plus size={14} />}>
Add provider
</Button>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
<EmptyStatePresentational
title="Add a provider"
description="Use third-party authentication systems to access your project."
>
<Button size="tiny" type="default" icon={<Plus size={14} />}>
Add provider
</Button>
</EmptyStatePresentational>
</PageSectionContent>
</PageSection>
</div>
)
}
@@ -0,0 +1,16 @@
import { Plus } from 'lucide-react'
import { Button } from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
export default function EmptyStatePresentationalIcon() {
return (
<EmptyStatePresentational
title="Create an auth hook"
description="Use Postgres functions or HTTP endpoints to customize your authentication flow."
>
<Button size="tiny" type="primary" icon={<Plus size={14} />}>
Add hook
</Button>
</EmptyStatePresentational>
)
}
@@ -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 (
<EmptyStatePresentational
icon={BucketPlus}
+12 -2
View File
@@ -1319,9 +1319,19 @@ export const examples: Registry = [
files: ['example/empty-state-zero-items-table.tsx'],
},
{
name: 'empty-state-initial-state-presentational',
name: 'empty-state-presentational-demo',
type: 'components:example',
files: ['example/empty-state-initial-state-presentational.tsx'],
files: ['example/empty-state-presentational-demo.tsx'],
},
{
name: 'empty-state-presentational-buttons',
type: 'components:example',
files: ['example/empty-state-presentational-buttons.tsx'],
},
{
name: 'empty-state-presentational-icon',
type: 'components:example',
files: ['example/empty-state-presentational-icon.tsx'],
},
{
name: 'empty-state-initial-state-informational',
@@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InlineLink } from 'components/ui/InlineLink'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -21,12 +22,14 @@ import { extractMethod, isValidHook } from './hooks.utils'
interface AddHookDropdownProps {
buttonText?: string
align?: 'end' | 'center'
type?: 'primary' | 'default'
onSelectHook: (hook: HOOK_DEFINITION_TITLE) => 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 (
<ButtonTooltip
disabled
type="primary"
type={type}
tooltip={{
content: { side: 'bottom', text: 'You need additional permissions to add auth hooks' },
}}
@@ -69,7 +72,7 @@ export const AddHookDropdown = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="primary" iconRight={<ChevronDown className="w-4 h-4" strokeWidth={1} />}>
<Button type={type} iconRight={<ChevronDown />}>
{buttonText}
</Button>
</DropdownMenuTrigger>
@@ -89,14 +92,7 @@ export const AddHookDropdown = ({
<p className="text-foreground-light">Team or Enterprise Plan required</p>
<p className="text-foreground-lighter text-xs">
The following hooks are not available on{' '}
<a
target="_href"
href={`https://supabase.com/dashboard/org/${organization?.slug ?? '_'}/billing`}
className="underline"
>
your plan
</a>
.
<InlineLink href={`/org/${organization?.slug ?? '_'}/billing`}>your plan</InlineLink>.
</p>
</DropdownMenuLabel>
)}
@@ -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 (
<Sheet open={visible} onOpenChange={() => onClose()}>
<SheetContent size="lg" showClose={false} className="flex flex-col gap-0">
<SheetContent
aria-describedby={undefined}
size="lg"
showClose={false}
className="flex flex-col gap-0"
>
<SheetHeader className="py-3 flex flex-row justify-between items-center border-b-0">
<div className="flex flex-row gap-3 items-center">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle className="truncate">
{isCreating ? `Add ${title}` : `Update ${title}`}
</SheetTitle>
</div>
<SheetTitle className="truncate">
{isCreating ? `Add ${title}` : `Update ${title}`}
</SheetTitle>
<DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
</SheetHeader>
<Separator />
@@ -459,11 +449,17 @@ export const CreateHookSheet = ({
<FormItemLayout
label="Secret"
description={
<ReactMarkdown>
It should be a base64 encoded hook secret with a prefix `v1,whsec_`.
`v1` denotes the signature version, and `whsec_` signifies a symmetric
secret.
</ReactMarkdown>
<div className="flex items-center gap-x-2">
<p>
Should be a base64 encoded hook secret with a prefix{' '}
<code className="text-code-inline">v1,whsec_</code>.
</p>
<InfoTooltip side="bottom" className="w-60 text-center">
<code className="text-code-inline">v1</code> denotes the signature
version and <code className="text-code-inline">whsec_</code> signifies
a symmetric secret.
</InfoTooltip>
</div>
}
>
<FormControl_Shadcn_>
@@ -472,7 +468,7 @@ export const CreateHookSheet = ({
<Button
type="default"
size="small"
className="rounded-l-none"
className="rounded-l-none text-xs"
onClick={() => {
const authHookSecret = generateAuthHookSecret()
form.setValue('httpsValues.secret', authHookSecret)
@@ -10,7 +10,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
import { useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { EmptyStatePresentational, GenericSkeletonLoader } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
PageSection,
@@ -109,7 +109,7 @@ export const HooksListing = () => {
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>All hooks</PageSectionTitle>
<PageSectionTitle>Hooks</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<AddHookDropdown
@@ -122,14 +122,12 @@ export const HooksListing = () => {
</PageSectionMeta>
<PageSectionContent>
{hooks.filter((h) => isValidHook(h)).length === 0 && (
<div
className={[
'border rounded border-default px-20 py-16',
'flex flex-col items-center justify-center space-y-4',
].join(' ')}
<EmptyStatePresentational
title="Create an auth hook"
description="Use Postgres functions or HTTP endpoints to customize your authentication flow."
>
<p className="text-sm text-foreground-light">No hooks configured yet</p>
<AddHookDropdown
type="default"
align="center"
buttonText="Add a new hook"
onSelectHook={(title) => {
@@ -137,7 +135,7 @@ export const HooksListing = () => {
if (hook) setSelectedHook(hook.id)
}}
/>
</div>
</EmptyStatePresentational>
)}
<div className="-space-y-px">
@@ -21,6 +21,7 @@ import {
interface AddIntegrationDropdownProps {
buttonText?: string
align?: 'end' | 'center'
type?: 'primary' | 'default'
onSelectIntegrationType: (type: INTEGRATION_TYPES) => void
}
@@ -47,6 +48,7 @@ const ProviderDropdownItem = ({
}
export const AddIntegrationDropdown = ({
type = 'primary',
align = 'end',
onSelectIntegrationType,
}: AddIntegrationDropdownProps) => {
@@ -55,12 +57,12 @@ export const AddIntegrationDropdown = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="primary" iconRight={<ChevronDown size={14} strokeWidth={1} />}>
<Button type={type} iconRight={<ChevronDown />}>
Add provider
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-56">
<DropdownMenuLabel>Select Provider</DropdownMenuLabel>
<DropdownMenuLabel>Select provider</DropdownMenuLabel>
<DropdownMenuSeparator />
<ProviderDropdownItem type="firebase" onSelectIntegrationType={onSelectIntegrationType} />
@@ -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 (
<AlertError
error={error}
subject="Failed to retrieve auth configuration for Third Party Auth Integrations"
subject="Failed to retrieve auth configuration for Third-Party Auth Integrations"
/>
)
}
@@ -72,27 +73,20 @@ export const ThirdPartyAuthForm = () => {
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Third Party Auth</PageSectionTitle>
<PageSectionTitle>Third-Party Auth</PageSectionTitle>
<PageSectionDescription>
Use third-party authentication (TPA) systems based on JWTs to access your project.
<br />
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.{' '}
<InlineLink
href={`${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users-third-party`}
>
billing docs
</InlineLink>{' '}
for more information.
Learn more
</InlineLink>
</PageSectionDescription>
</PageSectionSummary>
<PageSectionAside>
<div className="flex items-center gap-2">
<DocsButton href={`${DOCS_URL}/guides/auth/third-party/overview`} />
{integrations.length !== 0 && (
<AddIntegrationDropdown onSelectIntegrationType={setSelectedIntegration} />
)}
</div>
<DocsButton href={`${DOCS_URL}/guides/auth/third-party/overview`} />
<AddIntegrationDropdown onSelectIntegrationType={setSelectedIntegration} />
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
@@ -108,18 +102,16 @@ export const ThirdPartyAuthForm = () => {
{isSuccess ? (
integrations.length === 0 ? (
<div
className={cn(
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4'
)}
<EmptyStatePresentational
title="Add an authentication provider"
description="Use third-party authentication systems based on JWTs to access your project."
>
<p className="text-sm text-foreground-light">No providers configured yet</p>
<AddIntegrationDropdown
align="center"
buttonText="Add a new integration"
type="default"
onSelectIntegrationType={setSelectedIntegration}
/>
</div>
</EmptyStatePresentational>
) : (
<div className="-space-y-px">
{integrations.map((integration) => {
@@ -21,7 +21,7 @@ export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => {
...(authenticationThirdPartyAuth
? [
{
label: 'Third Party Auth',
label: 'Third-Party Auth',
href: `/project/${ref}/auth/third-party`,
},
]
@@ -36,9 +36,7 @@ const Hooks: NextPageWithLayout = () => {
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Auth Hooks</PageHeaderTitle>
<PageHeaderDescription>
Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth
</PageHeaderDescription>
<PageHeaderDescription>Customize your authentication flow</PageHeaderDescription>
</PageHeaderSummary>
<PageHeaderAside>
<DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks`} />
@@ -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 = (
<div className={cn('flex flex-col items-center text-center text-balance', contentClassName)}>
@@ -74,30 +78,28 @@ export const EmptyStatePresentational = ({
className
)}
>
{Icon ? (
<div className="flex flex-col gap-y-3 items-center">
{/*
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}
</div>
) : (
textContent
)}
<div className="flex flex-col gap-y-3 items-center">
{/*
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}
</div>
{/* Optional children (typically action buttons) */}
{children}